import { useEffect, useState } from 'react' import { PageShell } from '../components/PageShell' import type { User, Role, Capability } from '../types' export function AdminRoles({ user }: { user: User }) { const [roles, setRoles] = useState([]) const [allApps, setAllApps] = useState<{ slug: string; name: string }[]>([]) const [allCaps, setAllCaps] = useState([]) const [showCreate, setShowCreate] = useState(false) async function load() { const [r, a, c] = await Promise.all([ fetch('/api/auth/admin/roles', { credentials: 'include' }).then(r => r.json()), fetch('/api/auth/admin/apps', { credentials: 'include' }).then(r => r.json()), fetch('/api/auth/admin/capabilities', { credentials: 'include' }).then(r => r.json()), ]) setRoles(r) setAllApps(a) setAllCaps(c) } useEffect(() => { load() }, []) async function setDefault(roleId: number) { await fetch(`/api/auth/admin/roles/${roleId}`, { method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ is_default: true }), }) load() } async function deleteRole(roleId: number) { if (!confirm('Delete this role? This will remove it from all users.')) return await fetch(`/api/auth/admin/roles/${roleId}`, { method: 'DELETE', credentials: 'include' }) load() } async function toggleApp(roleId: number, slug: string, has: boolean) { await fetch(`/api/auth/admin/roles/${roleId}/apps/${slug}`, { method: has ? 'DELETE' : 'POST', credentials: 'include', }) load() } async function toggleCapability(roleId: number, appSlug: string, capSlug: string, has: boolean) { await fetch(`/api/auth/admin/roles/${roleId}/capabilities/${appSlug}/${capSlug}`, { method: has ? 'DELETE' : 'POST', credentials: 'include', }) load() } return (

Roles

{showCreate && { setShowCreate(false); load() }} />}
{roles.map(role => (
{role.name} {role.is_default && ( DEFAULT )}
{role.description && (
{role.description}
)}
{!role.is_default && ( )}
{allApps.map(app => { const has = role.app_slugs.includes(app.slug) const appCaps = allCaps.filter(c => c.app_slug === app.slug) return (
{/* Capability sub-toggles — only meaningful once the app is granted */} {has && appCaps.length > 0 && (
{appCaps.map(cap => { const capKey = `${cap.app_slug}:${cap.slug}` const capHas = role.capabilities.includes(capKey) return ( ) })}
)}
) })}
))}
) } function CreateRoleForm({ onCreated }: { onCreated: () => void }) { const [form, setForm] = useState({ name: '', slug: '', description: '', is_default: false }) const [error, setError] = useState('') function autoSlug(name: string) { return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') } function setName(name: string) { setForm(v => ({ ...v, name, slug: autoSlug(name) })) } async function submit(e: React.FormEvent) { e.preventDefault() const res = await fetch('/api/auth/admin/roles', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form), }) if (res.ok) onCreated() else setError('Failed to create role') } return (

New Role

setName(e.target.value)} required style={{ ...inp, flex: 1 }} /> setForm(v => ({ ...v, slug: e.target.value }))} required style={{ ...inp, flex: 1 }} /> setForm(v => ({ ...v, description: e.target.value }))} style={{ ...inp, flex: 2 }} />
{error &&

{error}

}
) } const inp: React.CSSProperties = { background: 'var(--body-bg)', border: '1px solid var(--card-border)', borderRadius: '6px', color: 'var(--text-dark)', padding: '0.6rem 0.75rem', fontSize: '0.875rem', } const goldBtn: React.CSSProperties = { background: 'var(--navy)', color: '#fff', border: 'none', borderRadius: '6px', padding: '0.5rem 1rem', fontSize: '0.875rem', fontWeight: 600, cursor: 'pointer', } const mutedBtn: React.CSSProperties = { background: 'var(--body-bg)', color: 'var(--text-mid)', border: '1px solid var(--card-border)', borderRadius: '4px', padding: '0.25rem 0.6rem', fontSize: '0.75rem', cursor: 'pointer', } const dangerBtn: React.CSSProperties = { background: 'none', color: '#dc2626', border: '1px solid #dc2626', borderRadius: '4px', padding: '0.25rem 0.6rem', fontSize: '0.75rem', cursor: 'pointer', }