Merge pull request #174 from ArnasDon/feat/account-sharing-members-tab

feat(settings): members tab + invite member dialog
This commit is contained in:
Arnas Donauskas 2026-05-31 17:24:07 +02:00 committed by GitHub
commit eeb0143e81
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 881 additions and 3 deletions

View File

@ -1,7 +1,14 @@
'use client'; 'use client';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { Settings, MessageSquare, Tag, User, Palette } from 'lucide-react'; import {
Settings,
MessageSquare,
Tag,
User,
Palette,
UsersRound,
} from 'lucide-react';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { WhatsAppConfig } from '@/components/settings/whatsapp-config'; import { WhatsAppConfig } from '@/components/settings/whatsapp-config';
import { TemplateManager } from '@/components/settings/template-manager'; import { TemplateManager } from '@/components/settings/template-manager';
@ -10,30 +17,55 @@ import { ProfileForm } from '@/components/settings/profile-form';
import { PasswordForm } from '@/components/settings/password-form'; import { PasswordForm } from '@/components/settings/password-form';
import { SessionsCard } from '@/components/settings/sessions-card'; import { SessionsCard } from '@/components/settings/sessions-card';
import { AppearancePanel } from '@/components/settings/appearance-panel'; import { AppearancePanel } from '@/components/settings/appearance-panel';
import { MembersTab } from '@/components/settings/members-tab';
import { useAuth } from '@/hooks/use-auth';
const TAB_VALUES = [ const BASE_TAB_VALUES = [
'profile', 'profile',
'whatsapp', 'whatsapp',
'templates', 'templates',
'tags', 'tags',
'appearance', 'appearance',
] as const; ] as const;
const FLAGGED_TAB_VALUES = ['members'] as const;
const TAB_VALUES = [...BASE_TAB_VALUES, ...FLAGGED_TAB_VALUES] as const;
type TabValue = (typeof TAB_VALUES)[number]; type TabValue = (typeof TAB_VALUES)[number];
function isTabValue(v: string | null): v is TabValue { function isTabValue(v: string | null): v is TabValue {
return !!v && (TAB_VALUES as readonly string[]).includes(v); return !!v && (TAB_VALUES as readonly string[]).includes(v);
} }
// Flag key matches what migration 011 introduced. The Members tab
// stays hidden until the user's profile.beta_features array contains
// this string; flip it via Supabase Studio:
// UPDATE profiles SET beta_features = beta_features || ARRAY['account_sharing']
// WHERE user_id = '<theirs>';
const ACCOUNT_SHARING_FLAG = 'account_sharing';
export default function SettingsPage() { export default function SettingsPage() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { profile, profileLoading } = useAuth();
const accountSharingEnabled =
!profileLoading &&
!!profile?.beta_features?.includes(ACCOUNT_SHARING_FLAG);
// The URL is the single source of truth for the active tab — no // The URL is the single source of truth for the active tab — no
// local state, no sync effect. A previous revision duplicated this // local state, no sync effect. A previous revision duplicated this
// into `useState` + a sync effect, which tripped React 19's // into `useState` + a sync effect, which tripped React 19's
// set-state-in-effect rule and was also redundant. // set-state-in-effect rule and was also redundant.
const queryTab = searchParams.get('tab'); const queryTab = searchParams.get('tab');
const tab: TabValue = isTabValue(queryTab) ? queryTab : 'profile'; const requestedTab: TabValue = isTabValue(queryTab) ? queryTab : 'profile';
// If a user lands on /settings?tab=members but the flag is off
// (e.g. their profile was reset, or they followed a stale link),
// fall back to the profile tab silently rather than rendering an
// empty TabsContent.
const tab: TabValue =
requestedTab === 'members' && !accountSharingEnabled
? 'profile'
: requestedTab;
const onChange = (next: TabValue) => { const onChange = (next: TabValue) => {
const params = new URLSearchParams(searchParams.toString()); const params = new URLSearchParams(searchParams.toString());
@ -88,6 +120,18 @@ export default function SettingsPage() {
<Palette className="size-4" /> <Palette className="size-4" />
Appearance Appearance
</TabsTrigger> </TabsTrigger>
{/* Members tab is feature-flagged. We render the trigger
only when the flag is enabled, so users without the
flag see the original 5-tab layout. */}
{accountSharingEnabled && (
<TabsTrigger
value="members"
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
>
<UsersRound className="size-4" />
Members
</TabsTrigger>
)}
</TabsList> </TabsList>
<TabsContent value="profile" className="space-y-6"> <TabsContent value="profile" className="space-y-6">
@ -111,6 +155,12 @@ export default function SettingsPage() {
<TabsContent value="appearance"> <TabsContent value="appearance">
<AppearancePanel /> <AppearancePanel />
</TabsContent> </TabsContent>
{accountSharingEnabled && (
<TabsContent value="members">
<MembersTab />
</TabsContent>
)}
</Tabs> </Tabs>
</div> </div>
); );

View File

@ -0,0 +1,323 @@
'use client';
// ============================================================
// InviteMemberDialog
//
// Two-step modal:
// 1. Form — role + expiry + optional label → POST creates the invite.
// 2. Result — the share URL, returned ONCE. Copy-to-clipboard, plus a
// "Send via WhatsApp" deep link that pre-fills wa.me with
// a friendly message containing the URL.
//
// The plaintext token is server-stored only as a SHA-256 hash, so once
// the result step is dismissed the link is gone forever — the dialog
// shouts this in copy.
// ============================================================
import { useState } from 'react';
import { toast } from 'sonner';
import { Copy, Loader2, MessageCircle, Sparkles } from 'lucide-react';
import { Button, buttonVariants } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
type InviteRole = 'admin' | 'agent' | 'viewer';
interface InviteMemberDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Called after a successful create so the parent re-fetches the
* pending-invitations list. */
onCreated: () => void;
}
const EXPIRY_OPTIONS: { value: string; label: string }[] = [
{ value: '1', label: '1 day' },
{ value: '7', label: '7 days' },
{ value: '30', label: '30 days' },
];
const ROLE_DESCRIPTIONS: Record<InviteRole, string> = {
admin:
'Can invite teammates, manage settings, send messages, and edit data.',
agent:
'Can use the inbox, contacts, broadcasts, automations, and flows. No settings or member access.',
viewer: 'Read-only access across every page. Cannot send or edit anything.',
};
interface CreatedInvite {
url: string;
role: InviteRole;
expiresInDays: number;
}
export function InviteMemberDialog({
open,
onOpenChange,
onCreated,
}: InviteMemberDialogProps) {
const [role, setRole] = useState<InviteRole>('agent');
const [expiry, setExpiry] = useState<string>('7');
const [label, setLabel] = useState('');
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<CreatedInvite | null>(null);
function reset() {
setRole('agent');
setExpiry('7');
setLabel('');
setResult(null);
setSubmitting(false);
}
async function handleCreate() {
setSubmitting(true);
try {
const res = await fetch('/api/account/invitations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
role,
expiresInDays: Number(expiry),
label: label.trim() || undefined,
}),
});
if (!res.ok) {
const payload = await res.json().catch(() => ({}));
toast.error(payload.error || 'Failed to create invitation');
return;
}
const data = (await res.json()) as {
url: string;
expiresInDays: number;
};
setResult({ url: data.url, role, expiresInDays: data.expiresInDays });
onCreated();
} catch (err) {
console.error('[InviteMemberDialog] create error:', err);
toast.error('Could not reach the server. Try again?');
} finally {
setSubmitting(false);
}
}
async function copyToClipboard() {
if (!result) return;
try {
await navigator.clipboard.writeText(result.url);
toast.success('Invite link copied');
} catch {
// Most likely "not in a secure context" — happens on http://
// local IPs. Surface the link in the toast so the admin can
// hand-copy it.
toast.error('Clipboard blocked — copy the link manually');
}
}
function whatsappShareUrl(url: string): string {
const message = `Join our wacrm account using this link (valid for ${result?.expiresInDays} days): ${url}`;
return `https://wa.me/?text=${encodeURIComponent(message)}`;
}
return (
<Dialog
open={open}
onOpenChange={(next) => {
// Reset state when the dialog closes — both for cancel and
// for dismissal after a successful create. The plaintext URL
// is intentionally NOT preserved across opens.
if (!next) reset();
onOpenChange(next);
}}
>
<DialogContent className="bg-slate-900 border-slate-700 sm:max-w-md">
{result ? (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-white">
<Sparkles className="size-4 text-primary" />
Invite created
</DialogTitle>
<DialogDescription className="text-slate-400">
Share this link with your new teammate. They&apos;ll be able
to sign up (or sign in) and join the account as{' '}
<span className="font-medium text-slate-300">{result.role}</span>
. The link is valid for{' '}
<span className="font-medium text-slate-300">
{result.expiresInDays} day{result.expiresInDays === 1 ? '' : 's'}
</span>
.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
<Label className="text-slate-300">Invite link</Label>
<div className="flex gap-2">
<Input
readOnly
value={result.url}
className="bg-slate-800 border-slate-700 text-white font-mono text-xs"
onFocus={(e) => e.currentTarget.select()}
/>
<Button
type="button"
onClick={copyToClipboard}
className="bg-primary hover:bg-primary/90 text-primary-foreground shrink-0"
>
<Copy className="size-4" />
Copy
</Button>
</div>
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
<strong className="font-medium">Save this link now.</strong>{' '}
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.
</div>
{/* Anchor styled with `buttonVariants` rather than wrapping
in <Button asChild>. The wacrm Button is the Base UI
ButtonPrimitive — it has no Radix-style asChild slot.
Direct anchor preserves right-click "Open in new tab"
behaviour too. */}
<a
href={whatsappShareUrl(result.url)}
target="_blank"
rel="noreferrer noopener"
className={buttonVariants({
variant: 'outline',
className:
'w-full border-slate-700 text-slate-300 hover:bg-slate-800',
})}
>
<MessageCircle className="size-4" />
Send via WhatsApp
</a>
</div>
<DialogFooter className="bg-slate-900 border-slate-700">
<Button
onClick={() => onOpenChange(false)}
className="bg-primary hover:bg-primary/90 text-primary-foreground"
>
Done
</Button>
</DialogFooter>
</>
) : (
<>
<DialogHeader>
<DialogTitle className="text-white">Invite a teammate</DialogTitle>
<DialogDescription className="text-slate-400">
Generate a one-time invite link. Share it via WhatsApp,
Slack, or any channel you like — no email service required.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label className="text-slate-300">Role</Label>
<Select
value={role}
onValueChange={(v) => v && setRole(v as InviteRole)}
>
<SelectTrigger className="w-full bg-slate-800 border-slate-700 text-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="agent">Agent</SelectItem>
<SelectItem value="viewer">Viewer</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-slate-500">
{ROLE_DESCRIPTIONS[role]}
</p>
</div>
<div className="space-y-2">
<Label className="text-slate-300">Link valid for</Label>
<Select
value={expiry}
onValueChange={(v) => v && setExpiry(v)}
>
<SelectTrigger className="w-full bg-slate-800 border-slate-700 text-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
{EXPIRY_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-slate-300">
Label{' '}
<span className="text-xs text-slate-500">(optional)</span>
</Label>
<Input
placeholder="e.g. Sara — support team"
value={label}
onChange={(e) => setLabel(e.target.value)}
maxLength={80}
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
/>
<p className="text-xs text-slate-500">
Helps you remember who you sent the link to in the pending
list below.
</p>
</div>
</div>
<DialogFooter className="bg-slate-900 border-slate-700">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="border-slate-700 text-slate-300 hover:bg-slate-800"
>
Cancel
</Button>
<Button
onClick={handleCreate}
disabled={submitting}
className="bg-primary hover:bg-primary/90 text-primary-foreground"
>
{submitting ? (
<>
<Loader2 className="size-4 animate-spin" />
Creating...
</>
) : (
'Generate link'
)}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
}

View File

@ -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 `<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' },
];
const ROLE_ICONS: Record<AccountRole, typeof Crown> = {
owner: Crown,
admin: Shield,
agent: UserCog,
viewer: UserIcon,
};
const ROLE_LABELS: Record<AccountRole, string> = {
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<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;
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 (
<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-white">Account members</h2>
<p className="text-sm text-slate-400">
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-slate-900 border-slate-700 ring-0 ring-transparent">
<CardContent className="p-0">
<ul className="divide-y divide-slate-800">
{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 (
<li
key={member.user_id}
className="flex items-center gap-4 px-4 py-3"
>
<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-white">
{member.full_name || 'Unnamed'}
</span>
{isSelf && (
<Badge className="bg-slate-800 text-slate-300 border-slate-700 text-[10px] uppercase tracking-wide">
You
</Badge>
)}
</div>
{member.email && (
<p className="truncate text-xs text-slate-500">
{member.email}
</p>
)}
</div>
<div className="hidden sm:block text-right text-xs text-slate-500">
Joined {fmtDate(member.joined_at)}
</div>
{/* 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-slate-800 border-slate-700 text-slate-200"
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 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;
never on yourself. */}
{canManageMembers && !isOwnerRow && !isSelf && (
<Button
variant="outline"
size="sm"
onClick={() => setRemovingMember(member)}
disabled={isBusy}
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" />
</Button>
)}
</li>
);
})}
</ul>
</CardContent>
</Card>
{/* Pending invitations — admin+ only */}
<RequireRole min="admin">
<div>
<div className="mb-3 flex items-center gap-2">
<UsersRound className="size-4 text-slate-400" />
<h3 className="text-sm font-semibold text-white">
Pending invitations
</h3>
<Badge className="bg-slate-800 text-slate-400 border-slate-700">
{invitations.length}
</Badge>
</div>
{invitations.length === 0 ? (
<Card className="bg-slate-900 border-slate-700 ring-0 ring-transparent">
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
<Mail className="size-6 text-slate-600" />
<p className="mt-2 text-sm text-slate-400">
No pending invitations.
</p>
<p className="mt-1 text-xs text-slate-500">
Click <span className="text-slate-300">Invite member</span>{' '}
above to generate a shareable link.
</p>
</CardContent>
</Card>
) : (
<Card className="bg-slate-900 border-slate-700 ring-0 ring-transparent">
<CardContent className="p-0">
<ul className="divide-y divide-slate-800">
{invitations.map((inv) => (
<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-white">
{inv.label || 'Untitled invite'}
</span>
<span className="inline-flex items-center gap-1.5 rounded-md border border-slate-700 bg-slate-800 px-2 py-0.5 text-[11px] font-medium text-slate-300">
{ROLE_LABELS[inv.role]}
</span>
</div>
<p className="mt-0.5 text-xs text-slate-500">
Created {fmtDate(inv.created_at)} · {fmtExpiresIn(inv.expires_at)}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => handleRevoke(inv)}
className="border-slate-700 text-slate-300 hover:bg-red-500/10 hover:border-red-500/40 hover:text-red-300"
>
<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-slate-900 border-slate-700 sm:max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-white">
<AlertTriangle className="size-4 text-amber-400" />
Remove member
</DialogTitle>
<DialogDescription className="text-slate-400">
Remove{' '}
<span className="font-medium text-slate-300">
{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-slate-900 border-slate-700">
<Button
variant="outline"
onClick={() => setRemovingMember(null)}
className="border-slate-700 text-slate-300 hover:bg-slate-800"
>
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>
);
}