wacrm/src/app/join/[token]/page.tsx
Arnas Donauskas 4aa329b527
feat(theme): add light/dark mode toggle (#243)
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>
2026-06-16 17:29:46 +03:00

432 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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&apos;re invited to{' '}
<span className="text-primary">{peek.account_name}</span>
</CardTitle>
<CardDescription className="text-muted-foreground">
You&apos;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&apos;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&apos;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 &amp; 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>
);
}