mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
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>
593 lines
22 KiB
TypeScript
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'll be signed out of this account
|
|
and given a fresh personal account on their next sign-in. Their
|
|
login isn'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>
|
|
);
|
|
}
|