// ui/src/components/settings/permissions/PermissionTemplatesPage.tsx
import { useState, useEffect, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2, RotateCcw, Save, ShieldCog, Search, Users } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { Input } from '@/components/ui/input';
import { toast } from '@/lib/toast';
import { Button } from '@/components/ui/button';
import { PermissionTree } from './PermissionTree';
import {
DEFAULT_PERMISSIONS_BY_ROLE, buildPermissionsMap, ALL_PERMISSION_KEYS,
PERMISSION_CATEGORIES, moduleIdsForCategory,
} from '@/lib/permissions';
import { getApiBaseUrl } from '@/lib/api-url';
import { getAuthHeaders } from '@/lib/serverComm';
import { qk } from '@/lib/queryKeys';
import { cn } from '@/lib/utils';
// Admin is shown but READ-ONLY: admins always receive the full plan-tier set
// (templates/overrides only control non-admin roles), so editing it is a
// privilege-escalation footgun. Doctor/Receptionist/Patient are editable.
const ROLES = [
{ id: 'admin', label: 'Admin', color: 'text-red-600 dark:text-red-400', editable: false },
{ id: 'doctor', label: 'Doctor', color: 'text-blue-600 dark:text-blue-400', editable: true },
{ id: 'receptionist', label: 'Receptionist', color: 'text-green-600 dark:text-green-400', editable: true },
{ id: 'patient', label: 'Patient', color: 'text-purple-600 dark:text-purple-400', editable: true },
] as const;
type RoleId = typeof ROLES[number]['id'];
const EDITABLE_ROLE_IDS = ROLES.filter(r => r.editable).map(r => r.id) as RoleId[];
async function fetchPermissionTemplates(): Promise<Record<string, Record<string, boolean>>> {
try {
const res = await fetch(`${getApiBaseUrl()}/api/v1/protected/clinic/permission-templates`, { headers: getAuthHeaders() });
if (!res.ok) throw new Error('Failed to fetch templates');
const data = await res.json() as { templates?: Record<string, Record<string, boolean>> };
if (!data?.templates || typeof data.templates !== 'object') throw new Error('Unexpected response shape');
return data.templates;
} catch {
const defaults: Record<string, Record<string, boolean>> = {};
for (const { id } of EDITABLE_ROLE_IDS.map(id => ({ id }))) {
defaults[id] = buildPermissionsMap(DEFAULT_PERMISSIONS_BY_ROLE[id] || []);
}
return defaults;
}
}
// Minimal staff row — reuse the clinic staff list so the members pane shares cache.
interface StaffRow { id: string; name?: string; firstName?: string; lastName?: string; email?: string; role?: string; status?: string; }
async function fetchStaff(): Promise<StaffRow[]> {
try {
const res = await fetch(`${getApiBaseUrl()}/api/v1/protected/clinic/staff`, { headers: getAuthHeaders() });
if (!res.ok) return [];
const data = await res.json() as { staff?: StaffRow[] } | StaffRow[];
return Array.isArray(data) ? data : (data.staff ?? []);
} catch { return []; }
}
export function PermissionTemplatesPage() {
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
const activeRole: RoleId = useMemo(() => {
const r = searchParams.get('role') as RoleId | null;
return r && ROLES.some(x => x.id === r) ? r : 'doctor';
}, [searchParams]);
const activeCat = useMemo(() => {
const c = searchParams.get('cat');
return c && PERMISSION_CATEGORIES.some(x => x.id === c) ? c : PERMISSION_CATEGORIES[0].id;
}, [searchParams]);
const setParam = (key: string, value: string) => setSearchParams(prev => {
const next = new URLSearchParams(prev); next.set(key, value); return next;
}, { replace: false });
const [search, setSearch] = useState('');
const [templates, setTemplates] = useState<Record<string, Record<string, boolean>>>({});
const templatesQuery = useQuery({ queryKey: qk.permissionTemplates.list(), queryFn: fetchPermissionTemplates });
const staffQuery = useQuery({ queryKey: ['staff', 'permission-members'], queryFn: fetchStaff });
const loading = templatesQuery.isLoading;
useEffect(() => { if (templatesQuery.data) setTemplates(templatesQuery.data); }, [templatesQuery.data]);
const roleMeta = ROLES.find(r => r.id === activeRole)!;
const isEditable = roleMeta.editable;
const handleChange = (key: string, value: boolean) => {
if (!isEditable) return;
setTemplates(prev => ({ ...prev, [activeRole]: { ...prev[activeRole], [key]: value } }));
};
const resetMutation = useMutation({
mutationFn: async (role: RoleId) => {
await fetch(`${getApiBaseUrl()}/api/v1/protected/clinic/permission-templates/${role}`, { method: 'DELETE', headers: getAuthHeaders() });
return role;
},
onSuccess: (role) => {
setTemplates(prev => ({ ...prev, [role]: buildPermissionsMap(DEFAULT_PERMISSIONS_BY_ROLE[role] || []) }));
toast.success(`${role} template reset to defaults`);
queryClient.invalidateQueries({ queryKey: qk.permissionTemplates.all() });
},
onError: () => toast.error('Failed to reset template'),
});
const saveMutation = useMutation({
mutationFn: async (role: RoleId) => {
const res = await fetch(`${getApiBaseUrl()}/api/v1/protected/clinic/permission-templates/${role}`, {
method: 'PUT', headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ permissions: templates[role] || {} }),
});
if (!res.ok) throw new Error('Failed to save template');
return role;
},
onSuccess: (role) => { toast.success(`${role} template saved`); queryClient.invalidateQueries({ queryKey: qk.permissionTemplates.all() }); },
onError: () => toast.error('Failed to save template'),
});
const saving = saveMutation.isPending;
const globalDefault = buildPermissionsMap(DEFAULT_PERMISSIONS_BY_ROLE[activeRole] || []);
// Admin baseline = full grant (admins always have everything).
const currentTemplate = !isEditable
? buildPermissionsMap(ALL_PERMISSION_KEYS)
: (templates[activeRole] || globalDefault);
const deviationCount = isEditable ? ALL_PERMISSION_KEYS.filter(k => currentTemplate[k] !== globalDefault[k]).length : 0;
const members = (staffQuery.data ?? []).filter(s => (s.role ?? '').toLowerCase() === activeRole);
const memberName = (s: StaffRow) => s.name || [s.firstName, s.lastName].filter(Boolean).join(' ') || s.email || 'Unknown';
if (loading) {
return (
<div className="space-y-3">
<div className="flex gap-2 mb-4">{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-9 w-24" />)}</div>
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 py-2"><Skeleton className="h-4 w-4" /><Skeleton className="h-4 w-48" /><Skeleton className="h-4 w-16 ml-auto" /></div>
))}
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center gap-2">
<ShieldCog className="h-5 w-5 text-primary" />
<div>
<h2 className="text-lg font-black tracking-tight">Roles & Permissions</h2>
<p className="text-sm text-muted-foreground">Set the default permissions each role gets. New staff invited with a role start from its template.</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-[260px_1fr] gap-6">
{/* LEFT: role rail + members */}
<div className="space-y-4">
<div className="rounded-2xl border border-border/40 p-2 space-y-1">
{ROLES.map(role => (
<button
key={role.id}
type="button"
onClick={() => setParam('role', role.id)}
className={cn(
'w-full text-left px-3 py-2 rounded-xl text-sm font-bold transition-colors flex items-center justify-between',
activeRole === role.id ? `bg-muted ${role.color}` : 'text-muted-foreground hover:bg-muted/50'
)}
>
<span>{role.label}</span>
{!role.editable && <span className="text-[9px] font-bold uppercase tracking-wider text-muted-foreground/60">read-only</span>}
</button>
))}
</div>
<div className="rounded-2xl border border-border/40 p-4 space-y-2">
<div className="flex items-center gap-2 text-muted-foreground">
<Users className="h-4 w-4" />
<span className="text-[10px] font-black uppercase tracking-[0.2em]">Members in role ({members.length})</span>
</div>
<div className="space-y-1 max-h-72 overflow-y-auto">
{members.length === 0 ? (
<p className="text-xs text-muted-foreground/70 py-2">No active members in this role.</p>
) : members.map(m => (
<div key={m.id} className="flex items-center justify-between text-xs py-1">
<span className="truncate">{memberName(m)}</span>
{m.status && m.status !== 'active' && (
<span className="text-[9px] font-bold uppercase text-orange-500 bg-orange-500/10 px-1.5 py-0.5 rounded-full">{m.status}</span>
)}
</div>
))}
</div>
</div>
</div>
{/* RIGHT: category tabs + search + tree */}
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-1.5">
{PERMISSION_CATEGORIES.map(cat => (
<button
key={cat.id}
type="button"
onClick={() => setParam('cat', cat.id)}
className={cn(
'px-3 py-1.5 rounded-xl text-xs font-bold border transition-colors',
activeCat === cat.id ? 'bg-primary/10 border-primary/30 text-primary' : 'border-border/40 text-muted-foreground hover:bg-muted/50'
)}
>
{cat.label}
</button>
))}
</div>
{isEditable && (
<div className="flex items-center gap-2 flex-shrink-0">
{deviationCount > 0 && (
<Button variant="outline" size="sm" onClick={() => resetMutation.mutate(activeRole)} disabled={saving} className="gap-1.5 text-orange-600 dark:text-orange-400 border-orange-200 hover:bg-orange-50">
<RotateCcw className="h-3.5 w-3.5" /> Reset
</Button>
)}
<Button size="sm" onClick={() => saveMutation.mutate(activeRole)} disabled={saving} className="gap-1.5">
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />} Save template
</Button>
</div>
)}
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground/50" />
<Input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search permissions in this category…" className="pl-9" />
</div>
{!isEditable && (
<p className="text-xs text-muted-foreground bg-muted/40 rounded-xl px-3 py-2">Admins always have full access — this template is read-only.</p>
)}
<PermissionTree
permissions={currentTemplate}
templateDefaults={isEditable ? globalDefault : currentTemplate}
onChange={handleChange}
readOnly={!isEditable}
moduleIds={moduleIdsForCategory(activeCat)}
searchQuery={search}
/>
</div>
</div>
</div>
);
}