diff --git a/src/app/join/[token]/page.tsx b/src/app/join/[token]/page.tsx index 552ae74..9cbcfe3 100644 --- a/src/app/join/[token]/page.tsx +++ b/src/app/join/[token]/page.tsx @@ -26,7 +26,14 @@ import { useCallback, useEffect, useState } from 'react'; import Link from 'next/link'; import { useParams } from 'next/navigation'; import { toast } from 'sonner'; -import { CheckCircle, Loader2, MailX, ShieldCheck, UsersRound } from 'lucide-react'; +import { + AlertTriangle, + CheckCircle, + Loader2, + MailX, + ShieldCheck, + UsersRound, +} from 'lucide-react'; import { Button } from '@/components/ui/button'; import { @@ -36,6 +43,14 @@ import { 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 { @@ -87,6 +102,12 @@ export default function JoinPage() { 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); // Fetch peek + auth state on mount. The peek endpoint is // rate-limited per-IP (30/min) so double-mounting in React 19 @@ -130,7 +151,19 @@ export default function JoinPage() { const payload = (await res.json().catch(() => ({}))) as { error?: string; }; - toast.error(payload.error || 'Failed to accept invitation'); + // 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; } @@ -145,6 +178,21 @@ export default function JoinPage() { } }, [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 ( @@ -220,33 +268,90 @@ export default function JoinPage() { // ----- 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. -

-
-
+ <> + + {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. +

+
+ + + + +
+
+ ); } diff --git a/src/components/settings/invite-member-dialog.tsx b/src/components/settings/invite-member-dialog.tsx index d44f5a1..5bf2887 100644 --- a/src/components/settings/invite-member-dialog.tsx +++ b/src/components/settings/invite-member-dialog.tsx @@ -36,6 +36,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { useAuth } from '@/hooks/use-auth'; type InviteRole = 'admin' | 'agent' | 'viewer'; @@ -65,6 +66,9 @@ interface CreatedInvite { url: string; role: InviteRole; expiresInDays: number; + /** Snapshotted at creation time so a later account rename can't + * retroactively change the wa.me message text on the result step. */ + accountName: string; } export function InviteMemberDialog({ @@ -72,6 +76,7 @@ export function InviteMemberDialog({ onOpenChange, onCreated, }: InviteMemberDialogProps) { + const { account } = useAuth(); const [role, setRole] = useState('agent'); const [expiry, setExpiry] = useState('7'); const [label, setLabel] = useState(''); @@ -110,7 +115,17 @@ export function InviteMemberDialog({ expiresInDays: number; }; - setResult({ url: data.url, role, expiresInDays: data.expiresInDays }); + setResult({ + url: data.url, + role, + expiresInDays: data.expiresInDays, + // Snapshot the account name into the result so the wa.me + // share message has team context. Falls back to a generic + // string if `account` hasn't loaded yet (shouldn't happen + // — the dialog requires admin+ which requires a loaded + // profile — but stay safe). + accountName: account?.name ?? 'our wacrm account', + }); onCreated(); } catch (err) { console.error('[InviteMemberDialog] create error:', err); @@ -134,7 +149,12 @@ export function InviteMemberDialog({ } function whatsappShareUrl(url: string): string { - const message = `Join our wacrm account using this link (valid for ${result?.expiresInDays} days): ${url}`; + // Include the account name so the recipient knows which team + // they're being invited to before clicking through. This matters + // for users in multi-team contexts where "our wacrm account" + // wouldn't be enough to disambiguate. + const accountName = result?.accountName ?? 'our wacrm account'; + const message = `Join ${accountName} on wacrm using this link (valid for ${result?.expiresInDays} days): ${url}`; return `https://wa.me/?text=${encodeURIComponent(message)}`; } diff --git a/src/components/settings/members-tab.tsx b/src/components/settings/members-tab.tsx index b549ac5..8fd69e6 100644 --- a/src/components/settings/members-tab.tsx +++ b/src/components/settings/members-tab.tsx @@ -287,92 +287,107 @@ export function MembersTab() { return (
  • - - {member.avatar_url ? ( - - ) : null} - - {(member.full_name || member.email || 'U') - .charAt(0) - .toUpperCase()} - - +
    + + {member.avatar_url ? ( + + ) : null} + + {(member.full_name || member.email || 'U') + .charAt(0) + .toUpperCase()} + + -
    -
    - - {member.full_name || 'Unnamed'} - - {isSelf && ( - - You - +
    +
    + + {member.full_name || 'Unnamed'} + + {isSelf && ( + + You + + )} +
    + {member.email && ( +

    + {member.email} +

    )}
    - {member.email && ( -

    - {member.email} -

    - )}
    + {/* Joined date stays desktop-only. The mobile row's + vertical density makes the joined date noise. */}
    Joined {fmtDate(member.joined_at)}
    - {/* Role display / editor. Inline Select is admin+ - only AND not allowed on the owner row (owner - changes go through transfer, which lands later). */} - {canManageMembers && !isOwnerRow && !isSelf ? ( - + // Base UI Select can emit null on clear. We + // don't expose a clear affordance, so the + // guard is defensive — but the typed + // signature requires it. + v && handleRoleChange(member, v as AccountRole) + } > - - - - {EDITABLE_ROLES.map((r) => ( - - {r.label} - - ))} - - - ) : ( - - - {ROLE_LABELS[member.role]} - - )} + + + + + {EDITABLE_ROLES.map((r) => ( + + {r.label} + + ))} + + + ) : ( + + + {ROLE_LABELS[member.role]} + + )} - {/* Remove. Admin+ only; never on the owner row; - never on yourself. */} - {canManageMembers && !isOwnerRow && !isSelf && ( - - )} + {/* Remove. Admin+ only; never on the owner row; + never on yourself. */} + {canManageMembers && !isOwnerRow && !isSelf && ( + + )} +
  • ); })}