From 7554ce7cd82077800eaa1db2ef480b14e8bbccff Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Sun, 31 May 2026 18:21:30 +0300 Subject: [PATCH] feat(settings): members tab + invite member dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 6 of the multi-user accounts series. First user-facing surface for the feature — the Members tab in Settings. What lands - `src/components/settings/members-tab.tsx` — roster + pending invitations. Every member can see the roster (read-only). Admin+ gets an inline role-select dropdown (excluding the owner row and self), a Remove button (same restrictions), and the "Pending invitations" section with revoke. - `src/components/settings/invite-member-dialog.tsx` — two-step modal. Form (role + expiry + optional label) → result (read-only URL with copy-to-clipboard + a "Send via WhatsApp" deep link). The result step is dismissed-once: the plaintext token is only ever shown here and never resurfaced, so the copy carries a warning ("Save this link now"). - `src/app/(dashboard)/settings/page.tsx` — adds `members` to the tab list, gated behind the existing `profiles.beta_features.includes('account_sharing')` flag (same column migration 011 added; flip it via Supabase Studio on a per-profile basis to dogfood). When the flag is off the tab trigger AND content render nothing — the page degrades to the original 5-tab layout. A user landing on `/settings?tab=members` without the flag is silently bounced to `?tab=profile` rather than seeing an empty pane. Why a feature flag at all - Locked decision from the planning phase: ship the schema and API unconditionally (they're structural, harmless to existing single-user installs) but keep the *user-visible* invite surface behind a flag until the full UI series ships (Join page, sidebar gating, webhook account routing). Mirrors how migration 011 / Flows beta worked. Role-gating - The tab is reachable by any member of an account. Inside the tab, mutation controls (Invite member button, role dropdown, Remove button, pending invitations section + revoke buttons) are wrapped in `` or hidden via `useAuth().canManageMembers` from #172. - Server-side RPCs (set_member_role, remove_account_member from #170; invitation create/revoke from #171) double-check role anyway — the client gate is UX, not security. Owner row - Non-editable. Inline role select and Remove button are hidden on the owner's row across the board. Owner role changes go through the (deferred) Transfer Ownership flow. Self row - "You" badge on the caller's own row. Role select and Remove are hidden on it — admins can't accidentally demote or remove themselves, even though the server RPC would also catch it. Email visibility - Admin+ sees teammate emails; agent/viewer sees name + avatar + role + joined date only. Implemented server-side by the `/api/account/members` route in #170; the component just renders `member.email` when present. UI patterns - Mirrors existing tag-manager / template-manager card + divide-y list layout and the slate-900/slate-700/primary color tokens from the dark-only theme. - Dialogs follow the established `bg-slate-900 border-slate-700` pattern. Destructive actions get the `bg-red-600 hover:bg-red-700` treatment with `Loader2` spinner. - "Send via WhatsApp" anchor uses `buttonVariants` directly rather than ` + + +
+ Save this link now.{' '} + We never store the plaintext — once you close this dialog the + URL is gone. To re-share, revoke this invite and create a new one. +
+ + {/* Anchor styled with `buttonVariants` rather than wrapping + in + + + ) : ( + <> + + Invite a teammate + + Generate a one-time invite link. Share it via WhatsApp, + Slack, or any channel you like — no email service required. + + + +
+
+ + +

+ {ROLE_DESCRIPTIONS[role]} +

+
+ +
+ + +
+ +
+ + setLabel(e.target.value)} + maxLength={80} + className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500" + /> +

+ Helps you remember who you sent the link to in the pending + list below. +

+
+
+ + + + + + + )} + + + ); +} diff --git a/src/components/settings/members-tab.tsx b/src/components/settings/members-tab.tsx new file mode 100644 index 0000000..b549ac5 --- /dev/null +++ b/src/components/settings/members-tab.tsx @@ -0,0 +1,505 @@ +'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' }, +]; + +const ROLE_ICONS: Record = { + owner: Crown, + admin: Shield, + agent: UserCog, + viewer: UserIcon, +}; + +const ROLE_LABELS: Record = { + owner: 'Owner', + admin: 'Admin', + agent: 'Agent', + viewer: 'Viewer', +}; + +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; + setPendingMemberAction(member.user_id); + 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) { + const payload = await res.json().catch(() => ({})); + toast.error(payload.error || 'Failed to update role'); + return; + } + toast.success(`Updated ${member.full_name || 'member'} to ${nextRole}`); + setMembers((prev) => + prev.map((m) => + m.user_id === member.user_id ? { ...m, role: nextRole } : m, + ), + ); + } catch (err) { + 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 RoleIcon = ROLE_ICONS[member.role]; + 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 {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 ? ( + + ) : ( + + + {ROLE_LABELS[member.role]} + + )} + + {/* Remove. Admin+ only; never on the owner row; + never on yourself. */} + {canManageMembers && !isOwnerRow && !isSelf && ( + + )} +
  • + ); + })} +
+
+
+ + {/* Pending invitations — admin+ only */} + +
+
+ +

+ Pending invitations +

+ + {invitations.length} + +
+ + {invitations.length === 0 ? ( + + + +

+ No pending invitations. +

+

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

+
+
+ ) : ( + + +
    + {invitations.map((inv) => ( +
  • +
    +
    + + {inv.label || 'Untitled invite'} + + + {ROLE_LABELS[inv.role]} + +
    +

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

    +
    + + +
  • + ))} +
+
+
+ )} +
+
+ + + + { + 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. + + + + + + + + +
+ ); +}