diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index 4c29725..91e7298 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -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 = ''; +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() { Appearance + {/* 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 && ( + + + Members + + )} @@ -111,6 +155,12 @@ export default function SettingsPage() { + + {accountSharingEnabled && ( + + + + )} ); diff --git a/src/components/settings/invite-member-dialog.tsx b/src/components/settings/invite-member-dialog.tsx new file mode 100644 index 0000000..d44f5a1 --- /dev/null +++ b/src/components/settings/invite-member-dialog.tsx @@ -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 = { + 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('agent'); + const [expiry, setExpiry] = useState('7'); + const [label, setLabel] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [result, setResult] = useState(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 ( + { + // 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); + }} + > + + {result ? ( + <> + + + + Invite created + + + Share this link with your new teammate. They'll be able + to sign up (or sign in) and join the account as{' '} + {result.role} + . The link is valid for{' '} + + {result.expiresInDays} day{result.expiresInDays === 1 ? '' : 's'} + + . + + + +
+ +
+ e.currentTarget.select()} + /> + +
+ +
+ Save this link now.{' '} + 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. +
+ + {/* Anchor styled with `buttonVariants` rather than wrapping + in
+ + + + + + ) : ( + <> + + Invite a teammate + + Generate a one-time invite link. Share it via WhatsApp, + Slack, or any channel you like — no email service required. + + + +
+
+ + +

+ {ROLE_DESCRIPTIONS[role]} +

+
+ +
+ + +
+ +
+ + setLabel(e.target.value)} + maxLength={80} + className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500" + /> +

+ Helps you remember who you sent the link to in the pending + list below. +

+
+
+ + + + + + + )} +
+
+ ); +} diff --git a/src/components/settings/members-tab.tsx b/src/components/settings/members-tab.tsx new file mode 100644 index 0000000..b549ac5 --- /dev/null +++ b/src/components/settings/members-tab.tsx @@ -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 `` / `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 = { + owner: Crown, + admin: Shield, + agent: UserCog, + viewer: UserIcon, +}; + +const ROLE_LABELS: Record = { + 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([]); + const [invitations, setInvitations] = useState([]); + const [loading, setLoading] = useState(true); + + const [inviteOpen, setInviteOpen] = useState(false); + const [removingMember, setRemovingMember] = useState(null); + const [pendingMemberAction, setPendingMemberAction] = useState( + 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 ( +
+ +
+ ); + } + + return ( +
+ {/* Header + invite button */} +
+
+

Account members

+

+ People with access to this account. Roles control what each + teammate can do. +

+
+ + + +
+ + {/* Roster */} + + +
    + {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 ( +
  • + + {member.avatar_url ? ( + + ) : null} + + {(member.full_name || member.email || 'U') + .charAt(0) + .toUpperCase()} + + + +
    +
    + + {member.full_name || 'Unnamed'} + + {isSelf && ( + + You + + )} +
    + {member.email && ( +

    + {member.email} +

    + )} +
    + +
    + Joined {fmtDate(member.joined_at)} +
    + + {/* 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 ? ( + + ) : ( + + + {ROLE_LABELS[member.role]} + + )} + + {/* Remove. Admin+ only; never on the owner row; + never on yourself. */} + {canManageMembers && !isOwnerRow && !isSelf && ( + + )} +
  • + ); + })} +
+
+
+ + {/* Pending invitations — admin+ only */} + +
+
+ +

+ Pending invitations +

+ + {invitations.length} + +
+ + {invitations.length === 0 ? ( + + + +

+ No pending invitations. +

+

+ Click Invite member{' '} + above to generate a shareable link. +

+
+
+ ) : ( + + +
    + {invitations.map((inv) => ( +
  • +
    +
    + + {inv.label || 'Untitled invite'} + + + {ROLE_LABELS[inv.role]} + +
    +

    + Created {fmtDate(inv.created_at)} · {fmtExpiresIn(inv.expires_at)} +

    +
    + + +
  • + ))} +
+
+
+ )} +
+
+ + + + { + if (!open) setRemovingMember(null); + }} + > + + + + + Remove member + + + Remove{' '} + + {removingMember?.full_name || 'this teammate'} + {' '} + 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. + + + + + + + + +
+ ); +}