'use client'; // ============================================================ // MembersTab — Settings → Members // // Two stacked sections: // 1. Roster — every member of the account. Admin+ can change a // teammate's role inline and remove them. Owner row // is non-editable everywhere (transfer is its own // separate flow, deferred to a later PR). // 2. Pending — outstanding invite links. Admin+ can revoke. The // plaintext URL is gone after the create dialog // closes, so we surface a "revoke + new link" hint // rather than pretending we can resurface it. // // Role-gating // The tab itself is reachable by any member, but mutation buttons // are wrapped in `` / `useCan` so an // agent or viewer sees the roster read-only. The server-side // RPCs (set_member_role, remove_account_member) double-check // the role anyway. // ============================================================ import { useCallback, useEffect, useState } from 'react'; import { toast } from 'sonner'; import { AlertTriangle, Crown, Loader2, Mail, MailX, Plus, Shield, Trash2, UserCog, UserIcon, UsersRound, } from 'lucide-react'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { RequireRole } from '@/components/auth/require-role'; import { useAuth } from '@/hooks/use-auth'; import type { AccountRole } from '@/lib/auth/roles'; import { InviteMemberDialog } from './invite-member-dialog'; interface Member { user_id: string; full_name: string; email: string | null; avatar_url: string | null; role: AccountRole; joined_at: string; } interface Invitation { id: string; role: 'admin' | 'agent' | 'viewer'; label: string | null; created_at: string; expires_at: string; } // Editable roles in the inline dropdown. Owner is never an option — // promotions go through the (deferred) Transfer Ownership flow. const EDITABLE_ROLES: { value: AccountRole; label: string; hint: string }[] = [ { value: 'admin', label: 'Admin', hint: 'Manage members + everything' }, { value: 'agent', label: 'Agent', hint: 'Use features; no settings' }, { value: 'viewer', label: 'Viewer', hint: 'Read-only across the app' }, ]; // Per-role chip metadata. The colour scale runs amber (owner — // scarce, immutable) → primary (admin — significant) → slate // (agent — operational default) → muted slate (viewer — read- // only). Mirrors the sidebar's ROLE_CHIP so the two surfaces // don't drift; once the surface stabilises this should hoist // into a shared module. const ROLE_CHIP: Record< AccountRole, { icon: typeof Crown; label: string; className: string } > = { owner: { icon: Crown, label: 'Owner', className: 'border-amber-500/40 bg-amber-500/10 text-amber-300', }, admin: { icon: Shield, label: 'Admin', className: 'border-primary/40 bg-primary/10 text-primary', }, agent: { icon: UserCog, label: 'Agent', className: 'border-slate-700 bg-slate-800 text-slate-300', }, viewer: { icon: UserIcon, label: 'Viewer', className: 'border-slate-800 bg-slate-900 text-slate-500', }, }; function fmtDate(iso: string): string { // Match the rest of the dashboard's locale-light formatting. const d = new Date(iso); return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', }); } function fmtExpiresIn(iso: string): string { const ms = new Date(iso).getTime() - Date.now(); if (ms <= 0) return 'expired'; const days = Math.floor(ms / (24 * 60 * 60 * 1000)); if (days >= 1) return `expires in ${days} day${days === 1 ? '' : 's'}`; const hours = Math.max(1, Math.floor(ms / (60 * 60 * 1000))); return `expires in ${hours} hour${hours === 1 ? '' : 's'}`; } export function MembersTab() { const { user, canManageMembers } = useAuth(); const [members, setMembers] = useState([]); const [invitations, setInvitations] = useState([]); const [loading, setLoading] = useState(true); const [inviteOpen, setInviteOpen] = useState(false); const [removingMember, setRemovingMember] = useState(null); const [pendingMemberAction, setPendingMemberAction] = useState( null, ); const loadEverything = useCallback(async () => { try { const [mres, ires] = await Promise.all([ fetch('/api/account/members', { cache: 'no-store' }), canManageMembers ? fetch('/api/account/invitations', { cache: 'no-store' }) : Promise.resolve(null), ]); if (!mres.ok) { const payload = await mres.json().catch(() => ({})); toast.error(payload.error || 'Failed to load members'); return; } const mdata = (await mres.json()) as { members: Member[] }; setMembers(mdata.members); if (ires) { if (!ires.ok) { const payload = await ires.json().catch(() => ({})); toast.error(payload.error || 'Failed to load invitations'); return; } const idata = (await ires.json()) as { invitations: Invitation[] }; setInvitations(idata.invitations); } else { setInvitations([]); } } catch (err) { console.error('[MembersTab] load error:', err); toast.error('Could not reach the server'); } finally { setLoading(false); } }, [canManageMembers]); useEffect(() => { void loadEverything(); }, [loadEverything]); async function handleRoleChange(member: Member, nextRole: AccountRole) { if (member.role === nextRole) return; // Optimistic update — flip the dropdown immediately so the UI // feels snappy. If the server PATCH fails we revert below so // the dropdown doesn't lie about the persisted state. const previousRole = member.role; setPendingMemberAction(member.user_id); setMembers((prev) => prev.map((m) => m.user_id === member.user_id ? { ...m, role: nextRole } : m, ), ); try { const res = await fetch(`/api/account/members/${member.user_id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role: nextRole }), }); if (!res.ok) { // Revert the optimistic flip. The toast on its own wasn't // enough — the dropdown was left showing the new role // forever, so the next interaction operated on a wrong // baseline (re-trying the same change would no-op via the // `member.role === nextRole` guard at the top). setMembers((prev) => prev.map((m) => m.user_id === member.user_id ? { ...m, role: previousRole } : m, ), ); const payload = await res.json().catch(() => ({})); toast.error(payload.error || 'Failed to update role'); return; } toast.success(`Updated ${member.full_name || 'member'} to ${nextRole}`); } catch (err) { // Same revert on network failure. setMembers((prev) => prev.map((m) => m.user_id === member.user_id ? { ...m, role: previousRole } : m, ), ); console.error('[MembersTab] role change error:', err); toast.error('Could not reach the server'); } finally { setPendingMemberAction(null); } } async function handleRemove() { if (!removingMember) return; setPendingMemberAction(removingMember.user_id); try { const res = await fetch( `/api/account/members/${removingMember.user_id}`, { method: 'DELETE' }, ); if (!res.ok) { const payload = await res.json().catch(() => ({})); toast.error(payload.error || 'Failed to remove member'); return; } toast.success(`Removed ${removingMember.full_name || 'member'}`); setMembers((prev) => prev.filter((m) => m.user_id !== removingMember.user_id), ); setRemovingMember(null); } catch (err) { console.error('[MembersTab] remove error:', err); toast.error('Could not reach the server'); } finally { setPendingMemberAction(null); } } async function handleRevoke(invite: Invitation) { try { const res = await fetch(`/api/account/invitations/${invite.id}`, { method: 'DELETE', }); if (!res.ok) { const payload = await res.json().catch(() => ({})); toast.error(payload.error || 'Failed to revoke invitation'); return; } toast.success('Invitation revoked'); setInvitations((prev) => prev.filter((i) => i.id !== invite.id)); } catch (err) { console.error('[MembersTab] revoke error:', err); toast.error('Could not reach the server'); } } if (loading) { return (
); } return (
{/* Header + invite button */}

Account members

People with access to this account. Roles control what each teammate can do.

{/* Roster */}
    {members.map((member) => { const roleMeta = ROLE_CHIP[member.role]; const RoleIcon = roleMeta.icon; const isSelf = member.user_id === user?.id; const isOwnerRow = member.role === 'owner'; const isBusy = pendingMemberAction === member.user_id; return (
  • {member.avatar_url ? ( ) : null} {(member.full_name || member.email || 'U') .charAt(0) .toUpperCase()}
    {member.full_name || 'Unnamed'} {isSelf && ( You )}
    {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)}
    {/* Actions cluster. On mobile this is its own row below the identity block; on desktop it sits inline. Items align to the start on mobile so the role dropdown lines up under the avatar. */}
    {/* 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 ? ( ) : ( {roleMeta.label} )} {/* Remove. Admin+ only; never on the owner row; never on yourself. Pre-polish styling was neutral-default + red-on-hover — the destructive intent was invisible until the user moused over. Now red is the default state with a darker shade on hover so the affordance reads at-a-glance. */} {canManageMembers && !isOwnerRow && !isSelf && ( )}
  • ); })}
{/* Pending invitations — admin+ only */}

Pending invitations

{invitations.length}
{/* P10 — make the no-resend design explicit. Admins were confused why the pending list shows roles + expiry but no "copy link again" button. Stating the constraint up front (rather than letting the user discover it by looking for a button) keeps it from feeling like a bug. */} {invitations.length > 0 ? (

The plaintext invite URL is only shown once at creation for security — to re-share, revoke the invite below and create a new one.

) : null} {invitations.length === 0 ? (

No pending invitations.

Click Invite member{' '} above to generate a shareable link.

) : (
    {invitations.map((inv) => { const inviteRoleMeta = ROLE_CHIP[inv.role]; const InviteRoleIcon = inviteRoleMeta.icon; return (
  • {inv.label || 'Untitled invite'} {inviteRoleMeta.label}

    Created {fmtDate(inv.created_at)} · {fmtExpiresIn(inv.expires_at)}

    {/* Revoke: red default state, mirrors the members-tab Remove button. Pre-polish version read as a neutral secondary button until hover. */}
  • ); })}
)}
{ if (!open) setRemovingMember(null); }} > Remove member Remove{' '} {removingMember?.full_name || 'this teammate'} {' '} from the account? They'll be signed out of this account and given a fresh personal account on their next sign-in. Their login isn't deleted.
); }