'use client'; // ============================================================ // /join/[token] — invitation redemption landing page. // // Four UI states driven by: // - the peek result (server-validated invite payload), and // - whether the visitor is currently authenticated. // // ┌──────────────────────┬───────────────┬─────────────────────────┐ // │ peek │ auth │ render │ // ├──────────────────────┼───────────────┼─────────────────────────┤ // │ loading │ — │ spinner │ // │ ok:false (any reason)│ — │ friendly error + signup │ // │ ok:true │ signed out │ "Sign up" + "Sign in" │ // │ ok:true │ signed in │ "Accept" button → redeem │ // └──────────────────────┴───────────────┴─────────────────────────┘ // // We deliberately do NOT redeem automatically on page load — the // invitee should confirm what account/role they're accepting. // Auto-redeem would also race with the signup flow returning to // this page after email verification. // ============================================================ import { useCallback, useEffect, useState } from 'react'; import Link from 'next/link'; import { useParams } from 'next/navigation'; import { toast } from 'sonner'; import { AlertTriangle, CheckCircle, Loader2, MailX, ShieldCheck, UsersRound, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from '@/components/ui/card'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { createClient } from '@/lib/supabase/client'; interface PeekOk { ok: true; account_name: string; role: 'admin' | 'agent' | 'viewer'; expires_at: string; } interface PeekFail { ok: false; reason: 'not_found' | 'used' | 'expired' | 'server_error'; } type PeekResult = PeekOk | PeekFail; const ROLE_LABEL: Record = { admin: 'Admin', agent: 'Agent', viewer: 'Viewer', }; const FAIL_COPY: Record = { not_found: { title: 'Invite not found', body: 'This link doesn’t match a valid invitation. Double-check the URL or ask the person who invited you to send a new one.', }, used: { title: 'Invite already used', body: 'This invitation has already been accepted. If that wasn’t you, ask the account admin to send a fresh link.', }, expired: { title: 'Invite expired', body: 'This invitation has expired. Ask the account admin to send a new one — they take a few seconds to generate.', }, server_error: { title: 'Something went wrong', body: 'We couldn’t verify this invitation right now. Try refreshing the page in a moment.', }, }; export default function JoinPage() { const params = useParams<{ token: string }>(); const token = params?.token; const [peek, setPeek] = useState(null); // Local auth probe — the AuthProvider lives inside the (dashboard) // route group, so it doesn't reach this page. We hit Supabase // directly the same way `/login` and `/signup` do. const [authedUserId, setAuthedUserId] = useState( undefined, // undefined = unknown / still loading; null = signed out ); const [accepting, setAccepting] = useState(false); // `redeem_invitation` returns 409 when the caller's current account // has domain data, or they're already a member of a shared account. // A transient toast wasn't enough — the user has no actionable next // step. Surface a blocking modal that walks them through it. const [conflictMessage, setConflictMessage] = useState(null); const [signingOut, setSigningOut] = useState(false); // Extracted so the "Try again" button on the server_error card // can re-run the same logic without remounting the component. const loadPeekAndAuth = useCallback(async () => { if (!token) return; setPeek(null); setAuthedUserId(undefined); try { const [peekRes, authRes] = await Promise.all([ fetch(`/api/invitations/${encodeURIComponent(token)}/peek`, { cache: 'no-store', }), createClient().auth.getUser(), ]); const peekBody = (await peekRes.json()) as PeekResult; setPeek(peekBody); setAuthedUserId(authRes.data.user?.id ?? null); } catch (err) { console.error('[join] peek error:', err); setPeek({ ok: false, reason: 'server_error' }); setAuthedUserId(null); } }, [token]); // Fetch peek + auth state on mount. The peek endpoint is // rate-limited per-IP (30/min) so double-mounting in React 19 // strict mode dev is harmless. We also use the `cancelled` flag // to drop setState calls if the component unmounts mid-fetch. useEffect(() => { if (!token) return; let cancelled = false; (async () => { try { const [peekRes, authRes] = await Promise.all([ fetch(`/api/invitations/${encodeURIComponent(token)}/peek`, { cache: 'no-store', }), createClient().auth.getUser(), ]); const peekBody = (await peekRes.json()) as PeekResult; if (cancelled) return; setPeek(peekBody); setAuthedUserId(authRes.data.user?.id ?? null); } catch (err) { console.error('[join] peek error:', err); if (cancelled) return; setPeek({ ok: false, reason: 'server_error' }); setAuthedUserId(null); } })(); return () => { cancelled = true; }; }, [token]); const handleAccept = useCallback(async () => { if (!token) return; setAccepting(true); try { const res = await fetch( `/api/invitations/${encodeURIComponent(token)}/redeem`, { method: 'POST' }, ); if (!res.ok) { const payload = (await res.json().catch(() => ({}))) as { error?: string; }; // 409 = caller already has data / is in another shared // account. The redeem RPC's error message is descriptive // enough to show directly; we open a modal so the user has // a clear next-action (sign out → use different email) // rather than a 3-second toast. if (res.status === 409) { setConflictMessage( payload.error || 'You are already in another account. Sign in with a different email to join this one.', ); } else { toast.error(payload.error || 'Failed to accept invitation'); } setAccepting(false); return; } toast.success('Welcome to the team'); // Full reload (not router.push) so AuthProvider re-fetches // the profile with the new account_id and account_role. window.location.href = '/dashboard'; } catch (err) { console.error('[join] redeem error:', err); toast.error('Could not reach the server'); setAccepting(false); } }, [token]); const handleSignOutAndRetry = useCallback(async () => { setSigningOut(true); try { await createClient().auth.signOut(); // Hard reload so the new auth state propagates everywhere // (middleware, AuthProvider). Preserves the invite token in // the URL so the rebuilt page renders the signed-out CTA path. window.location.reload(); } catch (err) { console.error('[join] sign-out error:', err); toast.error('Could not sign out. Try refreshing the page.'); setSigningOut(false); } }, []); // ----- Loading state (peek pending OR auth not yet resolved) ----- if (peek === null || authedUserId === undefined) { return (

Verifying invitation…

); } // ----- Peek failed ----- if (!peek.ok) { const copy = FAIL_COPY[peek.reason]; return (
{copy.title} {copy.body}
{/* For server_error the failure is transient — the network flapped or the peek endpoint hiccupped. Try-again is the right primary action; the "create account" / "sign in" links stay as secondary options. Other failure reasons (not_found / used / expired) are terminal for this token, so no retry — just the signup/sign-in escape hatches. */} {peek.reason === 'server_error' ? ( <> ) : ( <> )}
); } // ----- Peek OK ----- const inviteHeader = (
You're invited to{' '} {peek.account_name} You'll join as{' '} {ROLE_LABEL[peek.role]} . Link valid until{' '} {new Date(peek.expires_at).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', })} .
); // ----- Authed: show Accept button ----- if (authedUserId) { return ( <> {inviteHeader}

Accepting moves your login into{' '} {peek.account_name}. Your empty personal account from signup will be cleaned up.

{/* Conflict modal — opens when the redeem endpoint returns 409 (caller already in a shared account or has domain data). Blocks the flow until the user picks a recovery action so they aren't stuck retrying an inevitable failure. */} { if (!open) setConflictMessage(null); }} > Can't join {peek.account_name} with this account {conflictMessage}

To join{' '} {peek.account_name}, sign out and sign up again with a different email address. The invite link stays valid as long as it hasn't expired.

); } // ----- Not authed: prompt to sign up or sign in ----- return ( {inviteHeader} ); }