Implement admin users and roles management

- Add user CRUD: list, create (via Better Auth), update role/status, remove from port
- Add role CRUD: create, update permissions, delete with system role protection
- Full permissions matrix UI with accordion groups and per-action checkboxes
- Validators, services, API routes, and UI components following existing patterns

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 15:47:11 -04:00
parent a13d7503cc
commit f60159e91a
14 changed files with 1460 additions and 78 deletions

View File

@@ -0,0 +1,297 @@
'use client';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { apiFetch } from '@/lib/api/client';
/** Default permissions structure matching RolePermissions type */
const DEFAULT_PERMISSIONS: Record<string, Record<string, boolean>> = {
clients: { view: false, create: false, edit: false, delete: false, merge: false, export: false },
interests: {
view: false,
create: false,
edit: false,
delete: false,
change_stage: false,
generate_eoi: false,
export: false,
},
berths: { view: false, edit: false, import: false, manage_waiting_list: false },
documents: {
view: false,
create: false,
send_for_signing: false,
upload_signed: false,
delete: false,
},
expenses: {
view: false,
create: false,
edit: false,
delete: false,
export: false,
scan_receipt: false,
},
invoices: {
view: false,
create: false,
edit: false,
delete: false,
send: false,
record_payment: false,
export: false,
},
files: { view: false, upload: false, delete: false, manage_folders: false },
email: { view: false, send: false, configure_account: false },
reminders: {
view_own: false,
view_all: false,
create: false,
edit_own: false,
edit_all: false,
assign_others: false,
},
calendar: { connect: false, view_events: false },
reports: { view_dashboard: false, view_analytics: false, export: false },
document_templates: { view: false, generate: false, manage: false },
admin: {
manage_users: false,
view_audit_log: false,
manage_settings: false,
manage_webhooks: false,
manage_reports: false,
manage_custom_fields: false,
manage_forms: false,
manage_tags: false,
system_backup: false,
},
};
const GROUP_LABELS: Record<string, string> = {
clients: 'Clients',
interests: 'Interests / Pipeline',
berths: 'Berths',
documents: 'Documents',
expenses: 'Expenses',
invoices: 'Invoices',
files: 'Files',
email: 'Email',
reminders: 'Reminders',
calendar: 'Calendar',
reports: 'Reports',
document_templates: 'Document Templates',
admin: 'Administration',
};
function formatAction(action: string): string {
return action.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
interface RoleFormProps {
open: boolean;
onOpenChange: (open: boolean) => void;
role?: {
id: string;
name: string;
description: string | null;
isSystem: boolean;
permissions: Record<string, Record<string, boolean>>;
} | null;
onSuccess: () => void;
}
export function RoleForm({ open, onOpenChange, role, onSuccess }: RoleFormProps) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [permissions, setPermissions] = useState<Record<string, Record<string, boolean>>>(
structuredClone(DEFAULT_PERMISSIONS),
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const isEdit = !!role;
useEffect(() => {
if (open) {
if (role) {
setName(role.name);
setDescription(role.description ?? '');
// Merge role permissions over defaults to fill any missing keys
const merged = structuredClone(DEFAULT_PERMISSIONS);
for (const [group, actions] of Object.entries(role.permissions)) {
if (merged[group]) {
for (const [action, value] of Object.entries(actions as Record<string, boolean>)) {
merged[group]![action] = value;
}
}
}
setPermissions(merged);
} else {
setName('');
setDescription('');
setPermissions(structuredClone(DEFAULT_PERMISSIONS));
}
setError(null);
}
}, [open, role]);
function togglePermission(group: string, action: string) {
setPermissions((prev) => {
const next = structuredClone(prev);
next[group]![action] = !next[group]![action];
return next;
});
}
function toggleGroup(group: string, value: boolean) {
setPermissions((prev) => {
const next = structuredClone(prev);
for (const action of Object.keys(next[group]!)) {
next[group]![action] = value;
}
return next;
});
}
function isGroupAllChecked(group: string): boolean {
return Object.values(permissions[group]!).every(Boolean);
}
function isGroupPartial(group: string): boolean {
const vals = Object.values(permissions[group]!);
return vals.some(Boolean) && !vals.every(Boolean);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setLoading(true);
try {
if (isEdit) {
await apiFetch(`/api/v1/admin/roles/${role.id}`, {
method: 'PATCH',
body: { name, description: description || null, permissions },
});
} else {
await apiFetch('/api/v1/admin/roles', {
method: 'POST',
body: { name, description: description || undefined, permissions },
});
}
onSuccess();
onOpenChange(false);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Something went wrong';
setError(message);
} finally {
setLoading(false);
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-[500px] sm:max-w-[500px]">
<SheetHeader>
<SheetTitle>{isEdit ? 'Edit Role' : 'New Role'}</SheetTitle>
</SheetHeader>
<form onSubmit={handleSubmit} className="mt-6 flex flex-col h-[calc(100vh-140px)]">
<div className="space-y-4 mb-4">
<div className="space-y-2">
<Label htmlFor="role-name">Name</Label>
<Input
id="role-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Sales Manager"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="role-description">Description</Label>
<Textarea
id="role-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What this role is for..."
rows={2}
/>
</div>
</div>
<Label className="mb-2">Permissions</Label>
<ScrollArea className="flex-1 rounded-md border">
<Accordion type="multiple" className="px-3">
{Object.entries(permissions).map(([group, actions]) => (
<AccordionItem key={group} value={group}>
<AccordionTrigger className="text-sm">
<div className="flex items-center gap-3">
<Checkbox
checked={isGroupAllChecked(group)}
ref={(el) => {
if (el) {
(el as unknown as HTMLInputElement).indeterminate =
isGroupPartial(group);
}
}}
onCheckedChange={(checked) => toggleGroup(group, checked === true)}
onClick={(e) => e.stopPropagation()}
/>
<span>{GROUP_LABELS[group] ?? group}</span>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="grid grid-cols-2 gap-2 pl-8 pb-2">
{Object.entries(actions).map(([action, checked]) => (
<label
key={action}
className="flex items-center gap-2 text-sm cursor-pointer"
>
<Checkbox
checked={checked}
onCheckedChange={() => togglePermission(group, action)}
/>
{formatAction(action)}
</label>
))}
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</ScrollArea>
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
<SheetFooter className="mt-4">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancel
</Button>
<Button type="submit" disabled={loading || !name.trim()}>
{loading ? 'Saving...' : isEdit ? 'Save Changes' : 'Create Role'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}