wacrm/src/components/settings/members-tab.tsx
Arnas Donauskas 1474657d03
fix(theme): light-mode polish from review pass
Self-review of the light/dark sweep surfaced neutral colors that lived
in raw inline-style / <style jsx> / gradient values (so the className
sweep correctly skipped them) and a few contrast/affordance nits:

- reply-quote: adapt to the outbound (primary) bubble via onPrimary so
  the quote/author stay legible on the violet fill in light mode; wire
  it from message-bubble.
- dashboard SVG chrome -> tokens: conversations-chart gridline/crosshair,
  pipeline-donut background ring, automation-builder dot-grid gradient.
- members-tab viewer chip: bg-card -> bg-transparent (outline) so it
  doesn't blend into a card surface in light mode.
- tag-manager / pipeline-settings color-swatch selection rings: raw
  slate/white -> var(--popover)/var(--foreground) so they flip.
- pipeline-board scrollbar (<style jsx>): slate rgb -> var tokens.
- activity-feed zebra stripe: bg-card/40 -> bg-muted/40 (survives light).
- forgot-password / import-modal: restore real hover (dead no-op hovers).
- themed-toaster: gate theme via useSyncExternalStore to avoid a
  hydration mismatch on sonner's theme attribute (no setState-in-effect).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 09:28:22 +03:00

593 lines
22 KiB
TypeScript

'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 `<RequireRole min="admin">` / `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-border bg-muted text-muted-foreground',
},
viewer: {
icon: UserIcon,
label: 'Viewer',
// Outline-only so it stays quieter than the filled Agent chip in
// both modes — bg-card would blend into a card surface in light mode.
className: 'border-border bg-transparent text-muted-foreground',
},
};
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<Member[]>([]);
const [invitations, setInvitations] = useState<Invitation[]>([]);
const [loading, setLoading] = useState(true);
const [inviteOpen, setInviteOpen] = useState(false);
const [removingMember, setRemovingMember] = useState<Member | null>(null);
const [pendingMemberAction, setPendingMemberAction] = useState<string | null>(
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 (
<div className="flex items-center justify-center py-12">
<Loader2 className="size-6 animate-spin text-primary" />
</div>
);
}
return (
<div className="space-y-6 mt-4">
{/* Header + invite button */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-foreground">Account members</h2>
<p className="text-sm text-muted-foreground">
People with access to this account. Roles control what each
teammate can do.
</p>
</div>
<RequireRole min="admin">
<Button
onClick={() => setInviteOpen(true)}
className="bg-primary hover:bg-primary/90 text-primary-foreground"
>
<Plus className="size-4" />
Invite member
</Button>
</RequireRole>
</div>
{/* Roster */}
<Card className="bg-card border-border ring-0 ring-transparent">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{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 (
<li
key={member.user_id}
// 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"
>
<div className="flex min-w-0 flex-1 items-center gap-4">
<Avatar className="size-9 shrink-0">
{member.avatar_url ? (
<AvatarImage
src={member.avatar_url}
alt={member.full_name || 'Member'}
/>
) : null}
<AvatarFallback className="bg-primary/10 text-sm font-medium text-primary">
{(member.full_name || member.email || 'U')
.charAt(0)
.toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-foreground">
{member.full_name || 'Unnamed'}
</span>
{isSelf && (
<Badge className="bg-muted text-muted-foreground border-border text-[10px] uppercase tracking-wide">
You
</Badge>
)}
</div>
{member.email && (
<p className="truncate text-xs text-muted-foreground">
{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-muted-foreground">
Joined {fmtDate(member.joined_at)}
</div>
{/* 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. */}
<div className="flex items-center gap-2 sm:gap-3">
{/* 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 ? (
<Select
value={member.role}
onValueChange={(v) =>
// 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)
}
>
<SelectTrigger
className="w-32 bg-muted border-border text-foreground"
disabled={isBusy}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{EDITABLE_ROLES.map((r) => (
<SelectItem key={r.value} value={r.value}>
{r.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span
className={`inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium ${roleMeta.className}`}
>
<RoleIcon className="size-3.5" />
{roleMeta.label}
</span>
)}
{/* 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 && (
<Button
variant="outline"
size="sm"
onClick={() => setRemovingMember(member)}
disabled={isBusy}
className="border-red-500/40 bg-red-500/10 text-red-300 hover:bg-red-500/20 hover:border-red-500/60 hover:text-red-200"
>
<Trash2 className="size-4" />
</Button>
)}
</div>
</li>
);
})}
</ul>
</CardContent>
</Card>
{/* Pending invitations — admin+ only */}
<RequireRole min="admin">
<div>
<div className="mb-2 flex items-center gap-2">
<UsersRound className="size-4 text-muted-foreground" />
<h3 className="text-sm font-semibold text-foreground">
Pending invitations
</h3>
<Badge className="bg-muted text-muted-foreground border-border">
{invitations.length}
</Badge>
</div>
{/* 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 ? (
<p className="mb-3 text-xs text-muted-foreground">
The plaintext invite URL is only shown once at creation
for security — to re-share, revoke the invite below and
create a new one.
</p>
) : null}
{invitations.length === 0 ? (
<Card className="bg-card border-border ring-0 ring-transparent">
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
<Mail className="size-6 text-muted-foreground" />
<p className="mt-2 text-sm text-muted-foreground">
No pending invitations.
</p>
<p className="mt-1 text-xs text-muted-foreground">
Click <span className="text-muted-foreground">Invite member</span>{' '}
above to generate a shareable link.
</p>
</CardContent>
</Card>
) : (
<Card className="bg-card border-border ring-0 ring-transparent">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{invitations.map((inv) => {
const inviteRoleMeta = ROLE_CHIP[inv.role];
const InviteRoleIcon = inviteRoleMeta.icon;
return (
<li
key={inv.id}
className="flex items-center gap-4 px-4 py-3"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">
{inv.label || 'Untitled invite'}
</span>
<span
className={`inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[11px] font-medium ${inviteRoleMeta.className}`}
>
<InviteRoleIcon className="size-3" />
{inviteRoleMeta.label}
</span>
</div>
<p className="mt-0.5 text-xs text-muted-foreground">
Created {fmtDate(inv.created_at)} · {fmtExpiresIn(inv.expires_at)}
</p>
</div>
{/* Revoke: red default state, mirrors the
members-tab Remove button. Pre-polish version
read as a neutral secondary button until
hover. */}
<Button
variant="outline"
size="sm"
onClick={() => handleRevoke(inv)}
className="border-red-500/40 bg-red-500/10 text-red-300 hover:bg-red-500/20 hover:border-red-500/60 hover:text-red-200"
>
<MailX className="size-4" />
Revoke
</Button>
</li>
);
})}
</ul>
</CardContent>
</Card>
)}
</div>
</RequireRole>
<InviteMemberDialog
open={inviteOpen}
onOpenChange={setInviteOpen}
onCreated={loadEverything}
/>
<Dialog
open={removingMember !== null}
onOpenChange={(open) => {
if (!open) setRemovingMember(null);
}}
>
<DialogContent className="bg-popover border-border sm:max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-popover-foreground">
<AlertTriangle className="size-4 text-amber-400" />
Remove member
</DialogTitle>
<DialogDescription className="text-muted-foreground">
Remove{' '}
<span className="font-medium text-muted-foreground">
{removingMember?.full_name || 'this teammate'}
</span>{' '}
from the account? They&apos;ll be signed out of this account
and given a fresh personal account on their next sign-in. Their
login isn&apos;t deleted.
</DialogDescription>
</DialogHeader>
<DialogFooter className="bg-popover border-border">
<Button
variant="outline"
onClick={() => setRemovingMember(null)}
className="border-border text-muted-foreground hover:bg-muted"
>
Cancel
</Button>
<Button
onClick={handleRemove}
disabled={!!pendingMemberAction}
className="bg-red-600 hover:bg-red-700 text-white"
>
{pendingMemberAction ? (
<>
<Loader2 className="size-4 animate-spin" />
Removing...
</>
) : (
'Remove member'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}