'use client'; import { useEffect, useState } from 'react'; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from '@/components/ui/accordion'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { ScrollArea } from '@/components/ui/scroll-area'; import { apiFetch } from '@/lib/api/client'; import { formatEnum } from '@/lib/constants'; import { WarningCallout } from '@/components/ui/warning-callout'; import { cn } from '@/lib/utils'; /** * Three-state per-user permission editor. * * For every leaf in RolePermissions we render an Inherit / Grant / Deny * toggle. "Inherit" leaves the leaf out of the user_permission_overrides * map so the role + port-role-override baseline wins. "Grant" / "Deny" * write `true` / `false` and override the baseline. * * Baseline comes from the GET endpoint which already merges role + port- * role override + residential toggle, so the inherit-state label matches * what `withAuth` would resolve to today. */ const GROUP_LABELS: Record = { clients: 'Clients', interests: 'Interests / Pipeline', berths: 'Berths', documents: 'Documents', expenses: 'Expenses', invoices: 'Invoices', payments: 'Payments', files: 'Files', email: 'Email', reminders: 'Reminders', calendar: 'Calendar', reports: 'Reports', document_templates: 'Document Templates', yachts: 'Yachts', companies: 'Companies', memberships: 'Company Memberships', reservations: 'Reservations', admin: 'Administration', residential_clients: 'Residential Clients', residential_interests: 'Residential Interests', }; // Mirrors RolePermissions in src/lib/db/schema/users.ts — used as the // canonical leaf list so the matrix shows every action even when the // baseline JSON omits a key (older roles, partial overrides). const PERMISSION_LEAVES: Record = { clients: ['view', 'create', 'edit', 'delete', 'merge', 'export'], interests: [ 'view', 'create', 'edit', 'delete', 'change_stage', 'override_stage', 'generate_eoi', 'export', ], berths: ['view', 'edit', 'import', 'manage_waiting_list', 'update_prices'], documents: [ 'view', 'create', 'edit', 'send_for_signing', 'upload_signed', 'delete', 'manage_folders', ], expenses: ['view', 'create', 'edit', 'delete', 'export', 'scan_receipt'], invoices: ['view', 'create', 'edit', 'delete', 'send', 'record_payment', 'export'], payments: ['view', 'record', 'delete'], files: ['view', 'upload', 'edit', 'delete', 'manage_folders'], email: ['view', 'send', 'configure_account'], reminders: ['view_own', 'view_all', 'create', 'edit_own', 'edit_all', 'assign_others'], calendar: ['connect', 'view_events'], reports: ['view_dashboard', 'view_analytics', 'export'], document_templates: ['view', 'generate', 'manage'], yachts: ['view', 'create', 'edit', 'delete', 'transfer'], companies: ['view', 'create', 'edit', 'delete'], memberships: ['view', 'manage'], reservations: ['view', 'create', 'activate', 'cancel'], admin: [ 'manage_users', 'view_audit_log', 'manage_settings', 'manage_webhooks', 'manage_reports', 'manage_custom_fields', 'manage_forms', 'manage_tags', 'system_backup', 'permanently_delete_clients', ], residential_clients: ['view', 'create', 'edit', 'delete'], residential_interests: ['view', 'create', 'edit', 'delete', 'change_stage'], }; function formatAction(action: string): string { return formatEnum(action); } type Overrides = Record>; type Baseline = Record> | null; interface PermissionMatrixResponse { data: { baseline: Baseline; overrides: Overrides; isSuperAdmin: boolean; }; } interface UserPermissionMatrixProps { userId: string; } export function UserPermissionMatrix({ userId }: UserPermissionMatrixProps) { const [baseline, setBaseline] = useState(null); const [overrides, setOverrides] = useState({}); // Tracked so future revisions can surface a dirty-state indicator; the // ui-ux audit recommended one. Setter is wired now to capture the // server-canonical baseline post-save. const [, setOriginalOverrides] = useState({}); const [isSuperAdmin, setIsSuperAdmin] = useState(false); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [message, setMessage] = useState(null); useEffect(() => { let cancelled = false; void (async () => { setLoading(true); try { const res = await apiFetch( `/api/v1/admin/users/${userId}/permission-overrides`, ); if (cancelled) return; setBaseline(res.data.baseline); const fetched = res.data.overrides ?? {}; setOverrides(fetched); setOriginalOverrides(fetched); setIsSuperAdmin(res.data.isSuperAdmin); } finally { if (!cancelled) setLoading(false); } })(); return () => { cancelled = true; }; }, [userId]); function getState(resource: string, action: string): 'inherit' | 'grant' | 'deny' { const v = overrides[resource]?.[action]; if (v === true) return 'grant'; if (v === false) return 'deny'; return 'inherit'; } function setState(resource: string, action: string, next: 'inherit' | 'grant' | 'deny') { setOverrides((prev) => { const copy: Overrides = { ...prev, [resource]: { ...(prev[resource] ?? {}) } }; if (next === 'inherit') { delete copy[resource]![action]; if (Object.keys(copy[resource]!).length === 0) delete copy[resource]; } else { copy[resource]![action] = next === 'grant'; } return copy; }); } function baselineFor(resource: string, action: string): boolean { return baseline?.[resource]?.[action] === true; } async function save() { setSaving(true); setMessage(null); try { await apiFetch(`/api/v1/admin/users/${userId}/permission-overrides`, { method: 'PUT', body: { overrides }, }); setOriginalOverrides(overrides); setMessage('Overrides saved.'); } catch (err: unknown) { setMessage(err instanceof Error ? err.message : 'Failed to save overrides'); } finally { setSaving(false); } } if (loading) { return (
Loading permissions…
); } if (isSuperAdmin) { return (
Super-admin users bypass per-port permission checks. Overrides don't apply here - revoke the super-admin flag on the Profile tab first.
); } if (!baseline) { return (
This user isn't assigned to this port, so the role baseline isn't resolvable. Assign them a role on the Profile tab before editing per-user permissions.
); } return (
Permission overrides save on the button below, separately from the Profile & role tab. Switching tabs or closing the drawer without clicking{' '} Save overrides drops your changes.

Each toggle defaults to Inherit (role + port override decide). Switch to Grant or Deny to force the value for this user only.

{Object.entries(PERMISSION_LEAVES).map(([resource, leaves]) => ( {GROUP_LABELS[resource] ?? resource}
{leaves.map((action) => { const state = getState(resource, action); const inherited = baselineFor(resource, action); return (

Inherits: {inherited ? 'granted' : 'denied'}

{(['inherit', 'grant', 'deny'] as const).map((opt) => ( ))}
); })}
))}
{message && {message}}
); }