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';
|
'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>
|
||||||
);
|
);
|
||||||
|
|||||||
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