mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
Introduce a light/dark MODE axis orthogonal to the existing accent themes. Neutral surface tokens now live in html[data-mode="light|dark"] blocks; accent tokens (--primary*, --ring, --chart-1, --sidebar-primary*) stay in html[data-theme] blocks, so any accent works in either mode. - globals.css: split neutrals (mode) from accent (theme); add light palette; dark values unchanged (dark stays the default). - lib/themes.ts: Mode type, DEFAULT_MODE=dark, MODE_STORAGE_KEY, isMode. - use-theme.tsx: provider owns mode/setMode/toggleMode + cross-tab sync. - layout.tsx: boot script applies persisted mode before first paint (no flash); colorScheme "dark light". - mode-toggle.tsx (header sun/moon) + themed-toaster.tsx (mode-aware). - appearance-panel.tsx: light/dark control + updated copy. - Tokenize hardcoded neutral colors (slate/white/gray) -> semantic tokens across the app so every surface flips with mode. Flow canvas and message-bubble timestamps made mode-aware too. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
432 lines
16 KiB
TypeScript
432 lines
16 KiB
TypeScript
'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<PeekOk['role'], string> = {
|
||
admin: 'Admin',
|
||
agent: 'Agent',
|
||
viewer: 'Viewer',
|
||
};
|
||
|
||
const FAIL_COPY: Record<PeekFail['reason'], { title: string; body: string }> = {
|
||
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<PeekResult | null>(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<string | null | undefined>(
|
||
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<string | null>(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 (
|
||
<Card className="w-full max-w-md border-border bg-card">
|
||
<CardContent className="flex flex-col items-center gap-3 py-12">
|
||
<Loader2 className="size-6 animate-spin text-primary" />
|
||
<p className="text-sm text-muted-foreground">Verifying invitation…</p>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
// ----- Peek failed -----
|
||
if (!peek.ok) {
|
||
const copy = FAIL_COPY[peek.reason];
|
||
return (
|
||
<Card className="w-full max-w-md border-border bg-card">
|
||
<CardHeader className="items-center text-center">
|
||
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-red-500/10">
|
||
<MailX className="h-6 w-6 text-red-400" />
|
||
</div>
|
||
<CardTitle className="text-xl text-foreground">{copy.title}</CardTitle>
|
||
<CardDescription className="text-muted-foreground">
|
||
{copy.body}
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="flex flex-col gap-2">
|
||
{/* 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' ? (
|
||
<>
|
||
<Button
|
||
onClick={loadPeekAndAuth}
|
||
className="w-full bg-primary text-primary-foreground hover:bg-primary/90"
|
||
>
|
||
Try again
|
||
</Button>
|
||
<Link href="/signup">
|
||
<Button
|
||
variant="outline"
|
||
className="w-full border-border text-muted-foreground hover:bg-muted hover:text-foreground"
|
||
>
|
||
Create a new account instead
|
||
</Button>
|
||
</Link>
|
||
</>
|
||
) : (
|
||
<>
|
||
<Link href="/signup">
|
||
<Button className="w-full bg-primary text-primary-foreground hover:bg-primary/90">
|
||
Create a new account instead
|
||
</Button>
|
||
</Link>
|
||
<Link href="/login">
|
||
<Button
|
||
variant="outline"
|
||
className="w-full border-border text-muted-foreground hover:bg-muted hover:text-foreground"
|
||
>
|
||
Sign in
|
||
</Button>
|
||
</Link>
|
||
</>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
// ----- Peek OK -----
|
||
const inviteHeader = (
|
||
<CardHeader className="items-center text-center">
|
||
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
|
||
<UsersRound className="h-6 w-6 text-primary" />
|
||
</div>
|
||
<CardTitle className="text-xl text-foreground">
|
||
You're invited to{' '}
|
||
<span className="text-primary">{peek.account_name}</span>
|
||
</CardTitle>
|
||
<CardDescription className="text-muted-foreground">
|
||
You'll join as{' '}
|
||
<span className="inline-flex items-center gap-1 text-foreground">
|
||
<ShieldCheck className="size-3.5 text-primary" />
|
||
{ROLE_LABEL[peek.role]}
|
||
</span>
|
||
. Link valid until{' '}
|
||
{new Date(peek.expires_at).toLocaleDateString(undefined, {
|
||
year: 'numeric',
|
||
month: 'short',
|
||
day: 'numeric',
|
||
})}
|
||
.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
);
|
||
|
||
// ----- Authed: show Accept button -----
|
||
if (authedUserId) {
|
||
return (
|
||
<>
|
||
<Card className="w-full max-w-md border-border bg-card">
|
||
{inviteHeader}
|
||
<CardContent className="flex flex-col gap-3">
|
||
<Button
|
||
onClick={handleAccept}
|
||
disabled={accepting}
|
||
className="w-full bg-primary text-primary-foreground hover:bg-primary/90"
|
||
>
|
||
{accepting ? (
|
||
<>
|
||
<Loader2 className="size-4 animate-spin" />
|
||
Accepting…
|
||
</>
|
||
) : (
|
||
<>
|
||
<CheckCircle className="size-4" />
|
||
Accept invitation
|
||
</>
|
||
)}
|
||
</Button>
|
||
<p className="text-center text-xs text-muted-foreground">
|
||
Accepting moves your login into{' '}
|
||
<span className="text-muted-foreground">{peek.account_name}</span>. Your
|
||
empty personal account from signup will be cleaned up.
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 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. */}
|
||
<Dialog
|
||
open={conflictMessage !== null}
|
||
onOpenChange={(open) => {
|
||
if (!open) setConflictMessage(null);
|
||
}}
|
||
>
|
||
<DialogContent className="bg-popover border-border sm:max-w-md">
|
||
<DialogHeader>
|
||
<DialogTitle className="flex items-center gap-2 text-popover-foreground">
|
||
<AlertTriangle className="size-4 text-amber-400" />
|
||
Can't join {peek.account_name} with this account
|
||
</DialogTitle>
|
||
<DialogDescription className="text-muted-foreground">
|
||
{conflictMessage}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="space-y-2 py-2 text-xs text-muted-foreground">
|
||
<p>
|
||
To join{' '}
|
||
<span className="text-popover-foreground">{peek.account_name}</span>,
|
||
sign out and sign up again with a different email address.
|
||
The invite link stays valid as long as it hasn't
|
||
expired.
|
||
</p>
|
||
</div>
|
||
<DialogFooter className="bg-popover border-border">
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => setConflictMessage(null)}
|
||
className="border-border text-popover-foreground hover:bg-muted"
|
||
>
|
||
Stay signed in
|
||
</Button>
|
||
<Button
|
||
onClick={handleSignOutAndRetry}
|
||
disabled={signingOut}
|
||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||
>
|
||
{signingOut ? (
|
||
<>
|
||
<Loader2 className="size-4 animate-spin" />
|
||
Signing out…
|
||
</>
|
||
) : (
|
||
'Sign out & use a different email'
|
||
)}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// ----- Not authed: prompt to sign up or sign in -----
|
||
return (
|
||
<Card className="w-full max-w-md border-border bg-card">
|
||
{inviteHeader}
|
||
<CardContent className="flex flex-col gap-2">
|
||
<Link href={`/signup?invite=${encodeURIComponent(token!)}`}>
|
||
<Button className="w-full bg-primary text-primary-foreground hover:bg-primary/90">
|
||
Create account & join
|
||
</Button>
|
||
</Link>
|
||
<Link href={`/login?invite=${encodeURIComponent(token!)}`}>
|
||
<Button
|
||
variant="outline"
|
||
className="w-full border-border text-muted-foreground hover:bg-muted hover:text-foreground"
|
||
>
|
||
I already have an account
|
||
</Button>
|
||
</Link>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|