Merge pull request #179 from ArnasDon/feat/account-sharing-ui-blocking-fixes

feat(ui): user-facing fixes from multi-user review (M3, M4, S3)
This commit is contained in:
Arnas Donauskas 2026-05-31 18:28:21 +02:00 committed by GitHub
commit d56c4176b5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 245 additions and 105 deletions

View File

@ -26,7 +26,14 @@ import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { toast } from 'sonner'; 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 { Button } from '@/components/ui/button';
import { import {
@ -36,6 +43,14 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@/components/ui/card'; } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { createClient } from '@/lib/supabase/client'; import { createClient } from '@/lib/supabase/client';
interface PeekOk { interface PeekOk {
@ -87,6 +102,12 @@ export default function JoinPage() {
undefined, // undefined = unknown / still loading; null = signed out undefined, // undefined = unknown / still loading; null = signed out
); );
const [accepting, setAccepting] = useState(false); 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);
// Fetch peek + auth state on mount. The peek endpoint is // Fetch peek + auth state on mount. The peek endpoint is
// rate-limited per-IP (30/min) so double-mounting in React 19 // 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 { const payload = (await res.json().catch(() => ({}))) as {
error?: string; 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); setAccepting(false);
return; return;
} }
@ -145,6 +178,21 @@ export default function JoinPage() {
} }
}, [token]); }, [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) ----- // ----- Loading state (peek pending OR auth not yet resolved) -----
if (peek === null || authedUserId === undefined) { if (peek === null || authedUserId === undefined) {
return ( return (
@ -220,33 +268,90 @@ export default function JoinPage() {
// ----- Authed: show Accept button ----- // ----- Authed: show Accept button -----
if (authedUserId) { if (authedUserId) {
return ( return (
<Card className="w-full max-w-md border-slate-800 bg-slate-900"> <>
{inviteHeader} <Card className="w-full max-w-md border-slate-800 bg-slate-900">
<CardContent className="flex flex-col gap-3"> {inviteHeader}
<Button <CardContent className="flex flex-col gap-3">
onClick={handleAccept} <Button
disabled={accepting} onClick={handleAccept}
className="w-full bg-primary text-primary-foreground hover:bg-primary/90" disabled={accepting}
> className="w-full bg-primary text-primary-foreground hover:bg-primary/90"
{accepting ? ( >
<> {accepting ? (
<Loader2 className="size-4 animate-spin" /> <>
Accepting… <Loader2 className="size-4 animate-spin" />
</> Accepting…
) : ( </>
<> ) : (
<CheckCircle className="size-4" /> <>
Accept invitation <CheckCircle className="size-4" />
</> Accept invitation
)} </>
</Button> )}
<p className="text-center text-xs text-slate-500"> </Button>
Accepting moves your login into{' '} <p className="text-center text-xs text-slate-500">
<span className="text-slate-400">{peek.account_name}</span>. Your Accepting moves your login into{' '}
empty personal account from signup will be cleaned up. <span className="text-slate-400">{peek.account_name}</span>. Your
</p> empty personal account from signup will be cleaned up.
</CardContent> </p>
</Card> </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-slate-900 border-slate-700 sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-white">
<AlertTriangle className="size-4 text-amber-400" />
Can&apos;t join {peek.account_name} with this account
</DialogTitle>
<DialogDescription className="text-slate-400">
{conflictMessage}
</DialogDescription>
</DialogHeader>
<div className="space-y-2 py-2 text-xs text-slate-500">
<p>
To join{' '}
<span className="text-slate-300">{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-slate-900 border-slate-700">
<Button
variant="outline"
onClick={() => setConflictMessage(null)}
className="border-slate-700 text-slate-300 hover:bg-slate-800"
>
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>
</>
); );
} }

View File

@ -36,6 +36,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { useAuth } from '@/hooks/use-auth';
type InviteRole = 'admin' | 'agent' | 'viewer'; type InviteRole = 'admin' | 'agent' | 'viewer';
@ -65,6 +66,9 @@ interface CreatedInvite {
url: string; url: string;
role: InviteRole; role: InviteRole;
expiresInDays: number; 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({ export function InviteMemberDialog({
@ -72,6 +76,7 @@ export function InviteMemberDialog({
onOpenChange, onOpenChange,
onCreated, onCreated,
}: InviteMemberDialogProps) { }: InviteMemberDialogProps) {
const { account } = useAuth();
const [role, setRole] = useState<InviteRole>('agent'); const [role, setRole] = useState<InviteRole>('agent');
const [expiry, setExpiry] = useState<string>('7'); const [expiry, setExpiry] = useState<string>('7');
const [label, setLabel] = useState(''); const [label, setLabel] = useState('');
@ -110,7 +115,17 @@ export function InviteMemberDialog({
expiresInDays: number; 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(); onCreated();
} catch (err) { } catch (err) {
console.error('[InviteMemberDialog] create error:', err); console.error('[InviteMemberDialog] create error:', err);
@ -134,7 +149,12 @@ export function InviteMemberDialog({
} }
function whatsappShareUrl(url: string): string { 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)}`; return `https://wa.me/?text=${encodeURIComponent(message)}`;
} }

View File

@ -287,92 +287,107 @@ export function MembersTab() {
return ( return (
<li <li
key={member.user_id} key={member.user_id}
className="flex items-center gap-4 px-4 py-3" // Mobile: stack identity (avatar+name+email) above the
// role/remove actions so the role dropdown's fixed
// 128px width doesn't force the name into a 50-pixel
// truncation. Desktop (sm+): everything inline as
// before.
className="flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:gap-4"
> >
<Avatar className="size-9 shrink-0"> <div className="flex min-w-0 flex-1 items-center gap-4">
{member.avatar_url ? ( <Avatar className="size-9 shrink-0">
<AvatarImage {member.avatar_url ? (
src={member.avatar_url} <AvatarImage
alt={member.full_name || 'Member'} src={member.avatar_url}
/> alt={member.full_name || 'Member'}
) : null} />
<AvatarFallback className="bg-primary/10 text-sm font-medium text-primary"> ) : null}
{(member.full_name || member.email || 'U') <AvatarFallback className="bg-primary/10 text-sm font-medium text-primary">
.charAt(0) {(member.full_name || member.email || 'U')
.toUpperCase()} .charAt(0)
</AvatarFallback> .toUpperCase()}
</Avatar> </AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-white"> <span className="truncate text-sm font-medium text-white">
{member.full_name || 'Unnamed'} {member.full_name || 'Unnamed'}
</span> </span>
{isSelf && ( {isSelf && (
<Badge className="bg-slate-800 text-slate-300 border-slate-700 text-[10px] uppercase tracking-wide"> <Badge className="bg-slate-800 text-slate-300 border-slate-700 text-[10px] uppercase tracking-wide">
You You
</Badge> </Badge>
)}
</div>
{member.email && (
<p className="truncate text-xs text-slate-500">
{member.email}
</p>
)} )}
</div> </div>
{member.email && (
<p className="truncate text-xs text-slate-500">
{member.email}
</p>
)}
</div> </div>
{/* Joined date stays desktop-only. The mobile row's
vertical density makes the joined date noise. */}
<div className="hidden sm:block text-right text-xs text-slate-500"> <div className="hidden sm:block text-right text-xs text-slate-500">
Joined {fmtDate(member.joined_at)} Joined {fmtDate(member.joined_at)}
</div> </div>
{/* Role display / editor. Inline Select is admin+ {/* Actions cluster. On mobile this is its own row
only AND not allowed on the owner row (owner below the identity block; on desktop it sits
changes go through transfer, which lands later). */} inline. Items align to the start on mobile so the
{canManageMembers && !isOwnerRow && !isSelf ? ( role dropdown lines up under the avatar. */}
<Select <div className="flex items-center gap-2 sm:gap-3">
value={member.role} {/* Role display / editor. Inline Select is admin+
onValueChange={(v) => only AND not allowed on the owner row (owner
// Base UI Select can emit null on clear. We changes go through transfer, which lands later). */}
// don't expose a clear affordance, so the {canManageMembers && !isOwnerRow && !isSelf ? (
// guard is defensive — but the typed <Select
// signature requires it. value={member.role}
v && handleRoleChange(member, v as AccountRole) onValueChange={(v) =>
} // Base UI Select can emit null on clear. We
> // don't expose a clear affordance, so the
<SelectTrigger // guard is defensive — but the typed
className="w-32 bg-slate-800 border-slate-700 text-slate-200" // signature requires it.
disabled={isBusy} v && handleRoleChange(member, v as AccountRole)
}
> >
<SelectValue /> <SelectTrigger
</SelectTrigger> className="w-32 bg-slate-800 border-slate-700 text-slate-200"
<SelectContent> disabled={isBusy}
{EDITABLE_ROLES.map((r) => ( >
<SelectItem key={r.value} value={r.value}> <SelectValue />
{r.label} </SelectTrigger>
</SelectItem> <SelectContent>
))} {EDITABLE_ROLES.map((r) => (
</SelectContent> <SelectItem key={r.value} value={r.value}>
</Select> {r.label}
) : ( </SelectItem>
<span className="inline-flex items-center gap-1.5 rounded-md border border-slate-700 bg-slate-800 px-2.5 py-1 text-xs font-medium text-slate-200"> ))}
<RoleIcon className="size-3.5 text-slate-400" /> </SelectContent>
{ROLE_LABELS[member.role]} </Select>
</span> ) : (
)} <span className="inline-flex items-center gap-1.5 rounded-md border border-slate-700 bg-slate-800 px-2.5 py-1 text-xs font-medium text-slate-200">
<RoleIcon className="size-3.5 text-slate-400" />
{ROLE_LABELS[member.role]}
</span>
)}
{/* Remove. Admin+ only; never on the owner row; {/* Remove. Admin+ only; never on the owner row;
never on yourself. */} never on yourself. */}
{canManageMembers && !isOwnerRow && !isSelf && ( {canManageMembers && !isOwnerRow && !isSelf && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setRemovingMember(member)} onClick={() => setRemovingMember(member)}
disabled={isBusy} disabled={isBusy}
className="border-slate-700 text-slate-300 hover:bg-red-500/10 hover:border-red-500/40 hover:text-red-300" className="border-slate-700 text-slate-300 hover:bg-red-500/10 hover:border-red-500/40 hover:text-red-300"
> >
<Trash2 className="size-4" /> <Trash2 className="size-4" />
</Button> </Button>
)} )}
</div>
</li> </li>
); );
})} })}