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

View File

@@ -0,0 +1,176 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { type ColumnDef } from '@tanstack/react-table';
import { Pencil, Trash2, Plus, Lock } from 'lucide-react';
import { DataTable } from '@/components/shared/data-table';
import { PageHeader } from '@/components/shared/page-header';
import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { apiFetch } from '@/lib/api/client';
import { RoleForm } from './role-form';
interface Role {
id: string;
name: string;
description: string | null;
isSystem: boolean;
isGlobal: boolean;
permissions: Record<string, Record<string, boolean>>;
createdAt: string;
}
export function RoleList() {
const [roles, setRoles] = useState<Role[]>([]);
const [loading, setLoading] = useState(true);
const [formOpen, setFormOpen] = useState(false);
const [editingRole, setEditingRole] = useState<Role | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const fetchRoles = useCallback(async () => {
setLoading(true);
try {
const res = await apiFetch<{ data: Role[] }>('/api/v1/admin/roles');
setRoles(res.data);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void fetchRoles();
}, [fetchRoles]);
function handleNewRole() {
setEditingRole(null);
setFormOpen(true);
}
function handleEditRole(role: Role) {
setEditingRole(role);
setFormOpen(true);
}
async function handleDeleteRole(id: string) {
setDeletingId(id);
try {
await apiFetch(`/api/v1/admin/roles/${id}`, { method: 'DELETE' });
await fetchRoles();
} finally {
setDeletingId(null);
}
}
function countPermissions(perms: Record<string, Record<string, boolean>>): string {
let granted = 0;
let total = 0;
for (const group of Object.values(perms)) {
for (const val of Object.values(group)) {
total++;
if (val) granted++;
}
}
return `${granted}/${total}`;
}
const columns: ColumnDef<Role, unknown>[] = [
{
accessorKey: 'name',
header: 'Name',
cell: ({ row }) => (
<div className="flex items-center gap-2">
<span className="font-medium">{row.original.name}</span>
{row.original.isSystem && (
<Badge variant="outline" className="text-xs">
<Lock className="mr-1 h-3 w-3" />
System
</Badge>
)}
</div>
),
},
{
accessorKey: 'description',
header: 'Description',
cell: ({ row }) => (
<span className="text-muted-foreground text-sm">{row.original.description ?? '—'}</span>
),
},
{
id: 'permissions',
header: 'Permissions',
cell: ({ row }) => (
<Badge variant="secondary">{countPermissions(row.original.permissions)}</Badge>
),
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" onClick={() => handleEditRole(row.original)}>
<Pencil className="h-4 w-4" />
<span className="sr-only">Edit</span>
</Button>
{!row.original.isSystem && (
<ConfirmationDialog
trigger={
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Delete</span>
</Button>
}
title="Delete Role"
description={`Delete "${row.original.name}"? Users assigned to this role must be reassigned first.`}
confirmLabel="Delete"
onConfirm={() => handleDeleteRole(row.original.id)}
loading={deletingId === row.original.id}
/>
)}
</div>
),
enableSorting: false,
size: 80,
},
];
return (
<div>
<PageHeader
title="Role Management"
description="Manage roles and their permissions"
actions={
<Button onClick={handleNewRole}>
<Plus className="mr-1.5 h-4 w-4" />
New Role
</Button>
}
/>
<DataTable
columns={columns}
data={roles}
isLoading={loading}
getRowId={(row) => row.id}
emptyState={
<div className="text-center py-8">
<p className="text-muted-foreground">No roles defined.</p>
</div>
}
/>
<RoleForm
open={formOpen}
onOpenChange={setFormOpen}
role={editingRole}
onSuccess={fetchRoles}
/>
</div>
);
}

View File

@@ -0,0 +1,222 @@
'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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
import { apiFetch } from '@/lib/api/client';
interface Role {
id: string;
name: string;
}
interface UserFormProps {
open: boolean;
onOpenChange: (open: boolean) => void;
user?: {
userId: string;
displayName: string;
email: string;
phone: string | null;
isActive: boolean;
role: { id: string; name: string };
} | null;
onSuccess: () => void;
}
export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps) {
const [roles, setRoles] = useState<Role[]>([]);
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [displayName, setDisplayName] = useState('');
const [phone, setPhone] = useState('');
const [roleId, setRoleId] = useState('');
const [isActive, setIsActive] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const isEdit = !!user;
useEffect(() => {
if (open) {
void apiFetch<{ data: Role[] }>('/api/v1/admin/roles').then((res) => setRoles(res.data));
}
}, [open]);
useEffect(() => {
if (open) {
if (user) {
setName(user.displayName);
setEmail(user.email);
setDisplayName(user.displayName);
setPhone(user.phone ?? '');
setRoleId(user.role.id);
setIsActive(user.isActive);
setPassword('');
} else {
setName('');
setEmail('');
setDisplayName('');
setPhone('');
setRoleId('');
setIsActive(true);
setPassword('');
}
setError(null);
}
}, [open, user]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setLoading(true);
try {
if (isEdit) {
await apiFetch(`/api/v1/admin/users/${user.userId}`, {
method: 'PATCH',
body: {
displayName,
phone: phone || null,
roleId,
isActive,
},
});
} else {
await apiFetch('/api/v1/admin/users', {
method: 'POST',
body: {
name: name || displayName,
email,
password,
displayName,
phone: phone || undefined,
roleId,
},
});
}
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="overflow-y-auto">
<SheetHeader>
<SheetTitle>{isEdit ? 'Edit User' : 'New User'}</SheetTitle>
</SheetHeader>
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
{!isEdit && (
<>
<div className="space-y-2">
<Label htmlFor="user-email">Email</Label>
<Input
id="user-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="user@example.com"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="user-password">Password</Label>
<Input
id="user-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min 12 characters"
minLength={12}
required
/>
</div>
</>
)}
<div className="space-y-2">
<Label htmlFor="user-display-name">Display Name</Label>
<Input
id="user-display-name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="John Smith"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="user-phone">Phone</Label>
<Input
id="user-phone"
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="+1 555-0123"
/>
</div>
<div className="space-y-2">
<Label htmlFor="user-role">Role</Label>
<Select value={roleId} onValueChange={setRoleId} required>
<SelectTrigger id="user-role">
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
{roles.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{isEdit && (
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<Label htmlFor="user-active">Account Active</Label>
<p className="text-xs text-muted-foreground">Disabled users cannot sign in</p>
</div>
<Switch id="user-active" checked={isActive} onCheckedChange={setIsActive} />
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<SheetFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancel
</Button>
<Button type="submit" disabled={loading || !displayName.trim() || !roleId}>
{loading ? 'Saving...' : isEdit ? 'Save Changes' : 'Create User'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,173 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { type ColumnDef } from '@tanstack/react-table';
import { Pencil, Trash2, Plus, ShieldCheck, ShieldOff } from 'lucide-react';
import { DataTable } from '@/components/shared/data-table';
import { PageHeader } from '@/components/shared/page-header';
import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { apiFetch } from '@/lib/api/client';
import { UserForm } from './user-form';
interface UserRow {
userId: string;
displayName: string;
email: string;
phone: string | null;
isActive: boolean;
isSuperAdmin: boolean;
lastLoginAt: string | null;
role: { id: string; name: string };
assignedAt: string;
}
export function UserList() {
const [users, setUsers] = useState<UserRow[]>([]);
const [loading, setLoading] = useState(true);
const [formOpen, setFormOpen] = useState(false);
const [editingUser, setEditingUser] = useState<UserRow | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const fetchUsers = useCallback(async () => {
setLoading(true);
try {
const res = await apiFetch<{ data: UserRow[] }>('/api/v1/admin/users');
setUsers(res.data);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void fetchUsers();
}, [fetchUsers]);
function handleNewUser() {
setEditingUser(null);
setFormOpen(true);
}
function handleEditUser(user: UserRow) {
setEditingUser(user);
setFormOpen(true);
}
async function handleRemoveUser(userId: string) {
setDeletingId(userId);
try {
await apiFetch(`/api/v1/admin/users/${userId}`, { method: 'DELETE' });
await fetchUsers();
} finally {
setDeletingId(null);
}
}
const columns: ColumnDef<UserRow, unknown>[] = [
{
accessorKey: 'displayName',
header: 'Name',
cell: ({ row }) => (
<div className="flex flex-col">
<span className="font-medium">{row.original.displayName}</span>
<span className="text-xs text-muted-foreground">{row.original.email}</span>
</div>
),
},
{
accessorKey: 'role',
header: 'Role',
cell: ({ row }) => <Badge variant="secondary">{row.original.role.name}</Badge>,
},
{
accessorKey: 'isActive',
header: 'Status',
cell: ({ row }) =>
row.original.isActive ? (
<Badge variant="default" className="bg-green-600">
<ShieldCheck className="mr-1 h-3 w-3" />
Active
</Badge>
) : (
<Badge variant="destructive">
<ShieldOff className="mr-1 h-3 w-3" />
Disabled
</Badge>
),
},
{
accessorKey: 'lastLoginAt',
header: 'Last Login',
cell: ({ row }) =>
row.original.lastLoginAt
? new Date(row.original.lastLoginAt).toLocaleDateString()
: 'Never',
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" onClick={() => handleEditUser(row.original)}>
<Pencil className="h-4 w-4" />
<span className="sr-only">Edit</span>
</Button>
<ConfirmationDialog
trigger={
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive">
<Trash2 className="h-4 w-4" />
<span className="sr-only">Remove</span>
</Button>
}
title="Remove User"
description={`Remove "${row.original.displayName}" from this port? They will lose access but their account remains.`}
confirmLabel="Remove"
onConfirm={() => handleRemoveUser(row.original.userId)}
loading={deletingId === row.original.userId}
/>
</div>
),
enableSorting: false,
size: 80,
},
];
return (
<div>
<PageHeader
title="User Management"
description="Manage users and their roles for this port"
actions={
<Button onClick={handleNewUser}>
<Plus className="mr-1.5 h-4 w-4" />
New User
</Button>
}
/>
<DataTable
columns={columns}
data={users}
isLoading={loading}
getRowId={(row) => row.userId}
emptyState={
<div className="text-center py-8">
<p className="text-muted-foreground">No users assigned to this port.</p>
<Button variant="link" onClick={handleNewUser} className="mt-2">
Add the first user
</Button>
</div>
}
/>
<UserForm
open={formOpen}
onOpenChange={setFormOpen}
user={editingUser}
onSuccess={fetchUsers}
/>
</div>
);
}