"use client"; import * as React from "react"; import { useParams, useRouter } from "next/navigation"; import Link from "next/link"; import { signIn, useSession } from "next-auth/react"; import { CheckCircle2, Loader2, Mail, ShieldAlert } from "lucide-react"; import { Button } from "@/components/ui/button"; import { api } from "@/lib/trpc"; /** * Public-by-token invite redeem page. Three phases: * * 1. Not signed in -> bounce to /sign-in with callbackUrl that brings us * back here. We don't surface the invite contents pre-auth; the only * thing the operator needs to see is "you need to sign in first." * * 2. Signed in, calling invites.accept(). On success: route to the * workspace landing page. * * 3. Signed in but identity mismatch (FORBIDDEN with cause.reason === * "email_not_owned"). Render the explainer with a deep link to the * profile's Linked Emails section. The user can either: * - Sign out and sign in via the matching email's provider. * - Add the missing email to their profile (manual verification * is a follow-up task; in v1 the link points at the read-only * Linked Emails page). */ export default function InviteAcceptPage() { const params = useParams(); const router = useRouter(); const { data: session, status } = useSession(); const rawToken = params?.token; const token = typeof rawToken === "string" ? rawToken : undefined; const acceptMut = api.invites.accept.useMutation({ onSuccess: (result) => { router.replace(`/${result.workspace.slug}`); }, }); const triedRef = React.useRef(false); React.useEffect(() => { if (!token) return; if (status !== "authenticated") return; if (triedRef.current) return; triedRef.current = true; acceptMut.mutate({ token }); }, [token, status]); // eslint-disable-line react-hooks/exhaustive-deps if (!token) { return ; } if (status === "loading") { return ; } if (status === "unauthenticated") { return (

You need to be signed in for us to know which account to add to the workspace.

); } if (acceptMut.isPending || (acceptMut.isIdle && status === "authenticated")) { return ; } if (acceptMut.isSuccess && acceptMut.data) { return (

Joined {acceptMut.data.workspace.name} as{" "} {acceptMut.data.role}. Redirecting…

); } if (acceptMut.error) { // Identity mismatch — caller is signed in but doesn't own the invited // email. This is the dedicated explainer path, not a generic error. const cause = acceptMut.error.shape?.data?.cause as | { reason?: string; invitedEmail?: string } | undefined; if (cause?.reason === "email_not_owned" && cause.invitedEmail) { return (

The invite was for{" "} {cause.invitedEmail}, but you're signed in as{" "} {session?.user?.email}.

To accept, link the invited email to your account from your profile (e.g. sign in via that email's OAuth provider), then return to this page. Or sign out and sign back in with the invited email directly.

); } return (

{acceptMut.error.message}

); } return ; } type Tone = "loading" | "success" | "error" | "mismatch"; function InviteShell({ title, tone, children, }: { title: string; tone: Tone; children?: React.ReactNode; }) { const Icon = tone === "success" ? CheckCircle2 : tone === "mismatch" ? ShieldAlert : tone === "error" ? ShieldAlert : tone === "loading" ? Loader2 : Mail; const iconClass = tone === "success" ? "text-emerald-600" : tone === "error" || tone === "mismatch" ? "text-amber-600" : "text-muted-foreground"; return (

{title}

{children}
); }