Compare commits

...

2 Commits

Author SHA1 Message Date
Jiang Bohan
902e156586 fix(auth): honor ?next= redirect through Google OAuth flow
The login page now encodes the ?next= param into the Google OAuth state
so the auth callback can redirect to the right destination (e.g.
/invite/{id}) after login, instead of always going to /issues.
2026-04-15 00:45:14 +08:00
Jiang Bohan
dfb9169aaf feat(invitation): dedicated /invite/{id} page for accepting invitations
The email CTA now deep-links to /invite/{id} instead of the generic app
URL. If the user isn't logged in, they're redirected to login with a
?next= param that brings them back to the invite page.

Changes:
- Backend: GET /api/invitations/{id} endpoint (enriched with workspace/inviter names)
- Backend: Email template now links to /invite/{invitationId}
- Frontend: Shared InvitePage component (packages/views/invite/)
- Frontend: Web route at (auth)/invite/[id], Desktop route at invite/:id
- Frontend: /invite/ excluded from navigation history persistence
2026-04-15 00:35:13 +08:00
12 changed files with 301 additions and 13 deletions

View File

@@ -18,6 +18,7 @@ import { AgentsPage } from "@multica/views/agents";
import { InboxPage } from "@multica/views/inbox";
import { SettingsPage } from "@multica/views/settings";
import { OnboardingWizard } from "@multica/views/onboarding";
import { InvitePage } from "@multica/views/invite";
import { useNavigation } from "@multica/views/navigation";
import { Server } from "lucide-react";
import { DaemonSettingsTab } from "./components/daemon-settings-tab";
@@ -57,6 +58,13 @@ function OnboardingRoute() {
return <OnboardingWizard onComplete={() => nav.push("/issues")} />;
}
function InviteRoute() {
const matches = useMatches();
const match = matches.find((m) => (m.params as { id?: string }).id);
const id = (match?.params as { id?: string })?.id ?? "";
return <InvitePage invitationId={id} />;
}
/** Route definitions shared by all tabs (no layout wrapper). */
export const appRoutes: RouteObject[] = [
{
@@ -97,6 +105,11 @@ export const appRoutes: RouteObject[] = [
element: <OnboardingRoute />,
handle: { title: "Get Started" },
},
{
path: "invite/:id",
element: <InviteRoute />,
handle: { title: "Accept Invite" },
},
{
path: "settings",
element: (

View File

@@ -0,0 +1,24 @@
"use client";
import { useEffect } from "react";
import { useRouter, useParams } from "next/navigation";
import { useAuthStore } from "@multica/core/auth";
import { InvitePage } from "@multica/views/invite";
export default function InviteAcceptPage() {
const router = useRouter();
const params = useParams<{ id: string }>();
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
// Redirect to login if not authenticated, with a redirect back to this page.
useEffect(() => {
if (!isLoading && !user) {
router.replace(`/login?next=/invite/${params.id}`);
}
}, [isLoading, user, router, params.id]);
if (isLoading || !user) return null;
return <InvitePage invitationId={params.id} />;
}

View File

@@ -37,6 +37,15 @@ function LoginPageContent() {
router.push(ws ? nextUrl : "/onboarding");
};
// Build Google OAuth state: encode platform + next URL so the callback
// can redirect to the right place after login.
const googleState = [
platform === "desktop" ? "platform:desktop" : "",
nextUrl !== "/issues" ? `next:${nextUrl}` : "",
]
.filter(Boolean)
.join(",") || undefined;
return (
<LoginPage
onSuccess={handleSuccess}
@@ -45,7 +54,7 @@ function LoginPageContent() {
? {
clientId: googleClientId,
redirectUri: `${window.location.origin}/auth/callback`,
state: platform === "desktop" ? "platform:desktop" : undefined,
state: googleState,
}
: undefined
}

View File

@@ -39,8 +39,11 @@ function CallbackContent() {
return;
}
const state = searchParams.get("state");
const isDesktop = state === "platform:desktop";
const state = searchParams.get("state") || "";
const stateParts = state.split(",");
const isDesktop = stateParts.includes("platform:desktop");
const nextPart = stateParts.find((p) => p.startsWith("next:"));
const nextUrl = nextPart ? nextPart.slice(5) : null; // strip "next:" prefix
const redirectUri = `${window.location.origin}/auth/callback`;
@@ -63,7 +66,9 @@ function CallbackContent() {
qc.setQueryData(workspaceKeys.list(), wsList);
const lastWsId = localStorage.getItem("multica_workspace_id");
const ws = await hydrateWorkspace(wsList, lastWsId);
router.push(ws ? "/issues" : "/onboarding");
// Honor the ?next= redirect if present (e.g. /invite/{id})
const defaultDest = ws ? "/issues" : "/onboarding";
router.push(nextUrl || defaultDest);
})
.catch((err) => {
setError(err instanceof Error ? err.message : "Login failed");

View File

@@ -593,6 +593,10 @@ export class ApiClient {
return this.fetch("/api/invitations");
}
async getInvitation(invitationId: string): Promise<Invitation> {
return this.fetch(`/api/invitations/${invitationId}`);
}
async acceptInvitation(invitationId: string): Promise<MemberWithUser> {
return this.fetch(`/api/invitations/${invitationId}/accept`, {
method: "POST",

View File

@@ -5,7 +5,7 @@ import { createJSONStorage, persist } from "zustand/middleware";
import { createPersistStorage } from "../platform/persist-storage";
import { defaultStorage } from "../platform/storage";
const EXCLUDED_PREFIXES = ["/login", "/pair/"];
const EXCLUDED_PREFIXES = ["/login", "/pair/", "/invite/"];
interface NavigationState {
lastPath: string;

View File

@@ -0,0 +1,2 @@
export { InvitePage } from "./invite-page";
export type { InvitePageProps } from "./invite-page";

View File

@@ -0,0 +1,185 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { api } from "@multica/core/api";
import { useWorkspaceStore } from "@multica/core/workspace";
import { workspaceKeys, workspaceListOptions } from "@multica/core/workspace/queries";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigation } from "../navigation";
import { Button } from "@multica/ui/components/ui/button";
import { Card, CardContent } from "@multica/ui/components/ui/card";
import { Users, Check, X } from "lucide-react";
export interface InvitePageProps {
invitationId: string;
}
export function InvitePage({ invitationId }: InvitePageProps) {
const { push } = useNavigation();
const switchWorkspace = useWorkspaceStore((s) => s.switchWorkspace);
const qc = useQueryClient();
const [accepting, setAccepting] = useState(false);
const [declining, setDeclining] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState<"accepted" | "declined" | null>(null);
const { data: invitation, isLoading, error: fetchError } = useQuery({
queryKey: ["invitation", invitationId],
queryFn: () => api.getInvitation(invitationId),
});
const handleAccept = async () => {
setAccepting(true);
setError(null);
try {
await api.acceptInvitation(invitationId);
setDone("accepted");
// Refresh workspace list and switch to the new workspace.
const wsList = await qc.fetchQuery({ ...workspaceListOptions(), staleTime: 0 });
const ws = wsList.find((w) => w.id === invitation?.workspace_id);
if (ws) {
switchWorkspace(ws);
}
qc.invalidateQueries({ queryKey: workspaceKeys.myInvitations() });
// Navigate to the workspace after a short delay for the success state.
setTimeout(() => push("/issues"), 1000);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to accept invitation");
} finally {
setAccepting(false);
}
};
const handleDecline = async () => {
setDeclining(true);
setError(null);
try {
await api.declineInvitation(invitationId);
setDone("declined");
qc.invalidateQueries({ queryKey: workspaceKeys.myInvitations() });
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to decline invitation");
} finally {
setDeclining(false);
}
};
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-sm text-muted-foreground">Loading invitation...</div>
</div>
);
}
if (fetchError || !invitation) {
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center gap-4 py-12">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<X className="h-6 w-6 text-muted-foreground" />
</div>
<h2 className="text-lg font-semibold">Invitation not found</h2>
<p className="text-sm text-muted-foreground text-center">
This invitation may have expired, been revoked, or doesn't belong to your account.
</p>
<Button variant="outline" onClick={() => push("/issues")}>
Go to dashboard
</Button>
</CardContent>
</Card>
</div>
);
}
if (done === "accepted") {
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center gap-4 py-12">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<Check className="h-6 w-6 text-primary" />
</div>
<h2 className="text-lg font-semibold">You joined {invitation.workspace_name}!</h2>
<p className="text-sm text-muted-foreground">Redirecting to workspace...</p>
</CardContent>
</Card>
</div>
);
}
if (done === "declined") {
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center gap-4 py-12">
<h2 className="text-lg font-semibold">Invitation declined</h2>
<p className="text-sm text-muted-foreground">You won't be added to this workspace.</p>
<Button variant="outline" onClick={() => push("/issues")}>
Go to dashboard
</Button>
</CardContent>
</Card>
</div>
);
}
const isExpired = invitation.status !== "pending";
const isAlreadyHandled = invitation.status === "accepted" || invitation.status === "declined";
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center gap-6 py-12">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-primary/10">
<Users className="h-7 w-7 text-primary" />
</div>
<div className="text-center space-y-2">
<h2 className="text-xl font-semibold">
Join {invitation.workspace_name ?? "workspace"}
</h2>
<p className="text-sm text-muted-foreground">
<strong>{invitation.inviter_name || invitation.inviter_email}</strong>{" "}
invited you to join as {invitation.role === "admin" ? "an admin" : "a member"}.
</p>
</div>
{isAlreadyHandled ? (
<div className="text-sm text-muted-foreground">
This invitation has already been {invitation.status}.
</div>
) : isExpired ? (
<div className="text-sm text-muted-foreground">
This invitation has expired.
</div>
) : (
<div className="flex gap-3 w-full">
<Button
variant="outline"
className="flex-1"
onClick={handleDecline}
disabled={accepting || declining}
>
{declining ? "Declining..." : "Decline"}
</Button>
<Button
className="flex-1"
onClick={handleAccept}
disabled={accepting || declining}
>
{accepting ? "Joining..." : "Accept & Join"}
</Button>
</div>
)}
{error && (
<p className="text-sm text-destructive text-center">{error}</p>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -33,6 +33,7 @@
"./chat": "./chat/index.ts",
"./settings": "./settings/index.ts",
"./onboarding": "./onboarding/index.ts",
"./invite": "./invite/index.ts",
"./platform": "./platform/index.ts"
},
"dependencies": {

View File

@@ -187,6 +187,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
// User-scoped invitation routes (no workspace context required)
r.Get("/api/invitations", h.ListMyInvitations)
r.Get("/api/invitations/{id}", h.GetMyInvitation)
r.Post("/api/invitations/{id}/accept", h.AcceptInvitation)
r.Post("/api/invitations/{id}/decline", h.DeclineInvitation)

View File

@@ -147,8 +147,9 @@ func (h *Handler) CreateInvitation(w http.ResponseWriter, r *http.Request) {
if inviter, err := h.Queries.GetUser(r.Context(), requester.UserID); err == nil {
inviterName = inviter.Name
}
invID := uuidToString(inv.ID)
go func() {
if err := h.EmailService.SendInvitationEmail(email, inviterName, workspaceName); err != nil {
if err := h.EmailService.SendInvitationEmail(email, inviterName, workspaceName, invID); err != nil {
slog.Warn("failed to send invitation email", "email", email, "error", err)
}
}()
@@ -224,6 +225,49 @@ func (h *Handler) RevokeInvitation(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// GetMyInvitation — get a single invitation by ID (for the invite accept page).
// GET /api/invitations/{id}
// ---------------------------------------------------------------------------
func (h *Handler) GetMyInvitation(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
invitationID := chi.URLParam(r, "id")
inv, err := h.Queries.GetInvitation(r.Context(), parseUUID(invitationID))
if err != nil {
writeError(w, http.StatusNotFound, "invitation not found")
return
}
// Verify the invitation belongs to the current user.
user, err := h.Queries.GetUser(r.Context(), parseUUID(userID))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load user")
return
}
if strings.ToLower(user.Email) != inv.InviteeEmail && uuidToString(inv.InviteeUserID) != userID {
writeError(w, http.StatusForbidden, "invitation does not belong to you")
return
}
resp := invitationToResponse(inv)
// Enrich with workspace name and inviter name.
if ws, err := h.Queries.GetWorkspace(r.Context(), inv.WorkspaceID); err == nil {
resp.WorkspaceName = ws.Name
}
if inviter, err := h.Queries.GetUser(r.Context(), inv.InviterID); err == nil {
resp.InviterName = inviter.Name
resp.InviterEmail = inviter.Email
}
writeJSON(w, http.StatusOK, resp)
}
// ---------------------------------------------------------------------------
// ListMyInvitations — current user's pending invitations across all workspaces.
// GET /api/invitations

View File

@@ -55,16 +55,16 @@ func (s *EmailService) SendVerificationCode(to, code string) error {
}
// SendInvitationEmail notifies the invitee that they have been invited to a workspace.
func (s *EmailService) SendInvitationEmail(to, inviterName, workspaceName string) error {
// Build the app URL for the invitation — users will see pending invitations
// in the workspace switcher after logging in.
// invitationID is included in the URL so the email deep-links to /invite/{id}.
func (s *EmailService) SendInvitationEmail(to, inviterName, workspaceName, invitationID string) error {
appURL := strings.TrimSpace(os.Getenv("FRONTEND_ORIGIN"))
if appURL == "" {
appURL = "https://app.multica.ai"
}
inviteURL := fmt.Sprintf("%s/invite/%s", appURL, invitationID)
if s.client == nil {
fmt.Printf("[DEV] Invitation email to %s: %s invited you to %s — %s\n", to, inviterName, workspaceName, appURL)
fmt.Printf("[DEV] Invitation email to %s: %s invited you to %s — %s\n", to, inviterName, workspaceName, inviteURL)
return nil
}
@@ -77,10 +77,10 @@ func (s *EmailService) SendInvitationEmail(to, inviterName, workspaceName string
<h2>You're invited to join %s</h2>
<p><strong>%s</strong> invited you to collaborate in the <strong>%s</strong> workspace on Multica.</p>
<p style="margin: 24px 0;">
<a href="%s" style="display: inline-block; padding: 12px 24px; background: #000; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 500;">Open Multica</a>
<a href="%s" style="display: inline-block; padding: 12px 24px; background: #000; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 500;">Accept invitation</a>
</p>
<p style="color: #666; font-size: 14px;">Log in to accept or decline the invitation.</p>
</div>`, workspaceName, inviterName, workspaceName, appURL),
<p style="color: #666; font-size: 14px;">You'll need to log in to accept or decline the invitation.</p>
</div>`, workspaceName, inviterName, workspaceName, inviteURL),
}
_, err := s.client.Emails.Send(params)