mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
Merge pull request #174 from ArnasDon/feat/account-sharing-members-tab
feat(settings): members tab + invite member dialog
This commit is contained in:
commit
eeb0143e81
@ -1,7 +1,14 @@
|
||||
'use client';
|
||||
|
||||
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 { WhatsAppConfig } from '@/components/settings/whatsapp-config';
|
||||
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 { SessionsCard } from '@/components/settings/sessions-card';
|
||||
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',
|
||||
'whatsapp',
|
||||
'templates',
|
||||
'tags',
|
||||
'appearance',
|
||||
] 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];
|
||||
|
||||
function isTabValue(v: string | null): v is TabValue {
|
||||
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() {
|
||||
const router = useRouter();
|
||||
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
|
||||
// local state, no sync effect. A previous revision duplicated this
|
||||
// into `useState` + a sync effect, which tripped React 19's
|
||||
// set-state-in-effect rule and was also redundant.
|
||||
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 params = new URLSearchParams(searchParams.toString());
|
||||
@ -88,6 +120,18 @@ export default function SettingsPage() {
|
||||
<Palette className="size-4" />
|
||||
Appearance
|
||||
</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>
|
||||
|
||||
<TabsContent value="profile" className="space-y-6">
|
||||
@ -111,6 +155,12 @@ export default function SettingsPage() {
|
||||
<TabsContent value="appearance">
|
||||
<AppearancePanel />
|
||||
</TabsContent>
|
||||
|
||||
{accountSharingEnabled && (
|
||||
<TabsContent value="members">
|
||||
<MembersTab />
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
323
src/components/settings/invite-member-dialog.tsx
Normal file
323
src/components/settings/invite-member-dialog.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
505
src/components/settings/members-tab.tsx
Normal file
505
src/components/settings/members-tab.tsx
Normal 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'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-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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user