portal/src/pages/AdminRoles.tsx
2026-07-21 09:15:12 +00:00

208 lines
9.5 KiB
TypeScript

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<Role[]>([])
const [allApps, setAllApps] = useState<{ slug: string; name: string }[]>([])
const [allCaps, setAllCaps] = useState<Capability[]>([])
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 (
<PageShell user={user}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem' }}>
<h1 style={{ fontSize: '1.2rem' }}>Roles</h1>
<button onClick={() => setShowCreate(v => !v)} style={goldBtn}>
{showCreate ? 'Cancel' : '+ New role'}
</button>
</div>
{showCreate && <CreateRoleForm onCreated={() => { setShowCreate(false); load() }} />}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{roles.map(role => (
<div key={role.id} style={{
background: 'var(--card-bg)', borderRadius: '10px',
padding: '1rem 1.25rem', border: `1px solid ${role.is_default ? 'var(--gold)' : 'var(--card-border)'}`,
boxShadow: 'var(--shadow-sm)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.75rem', flexWrap: 'wrap' }}>
<div style={{ flex: 1, minWidth: '140px' }}>
<div style={{ fontWeight: 600, fontSize: '0.9rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
{role.name}
{role.is_default && (
<span style={{ fontSize: '0.65rem', background: 'var(--gold)', color: 'var(--navy-dark)',
borderRadius: '4px', padding: '0.1rem 0.4rem', fontWeight: 700 }}>
DEFAULT
</span>
)}
</div>
{role.description && (
<div style={{ fontSize: '0.78rem', color: 'var(--text-mid)' }}>{role.description}</div>
)}
</div>
<div style={{ display: 'flex', gap: '0.4rem' }}>
{!role.is_default && (
<button onClick={() => setDefault(role.id)} style={mutedBtn}>
Set as default
</button>
)}
<button onClick={() => deleteRole(role.id)} style={dangerBtn}>
Delete
</button>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{allApps.map(app => {
const has = role.app_slugs.includes(app.slug)
const appCaps = allCaps.filter(c => c.app_slug === app.slug)
return (
<div key={app.slug} style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-start', flexWrap: 'wrap' }}>
<button onClick={() => toggleApp(role.id, app.slug, has)} style={{
background: has ? 'var(--navy)' : 'var(--body-bg)',
border: `1px solid ${has ? 'var(--navy)' : 'var(--card-border)'}`,
color: has ? '#fff' : 'var(--text-mid)',
borderRadius: '4px', padding: '0.2rem 0.6rem', fontSize: '0.75rem', cursor: 'pointer',
minWidth: '120px', textAlign: 'left', flexShrink: 0,
}}>
{app.name}
</button>
{/* Capability sub-toggles — only meaningful once the app is granted */}
{has && appCaps.length > 0 && (
<div style={{ display: 'flex', gap: '0.3rem', flexWrap: 'wrap', alignItems: 'center' }}>
{appCaps.map(cap => {
const capKey = `${cap.app_slug}:${cap.slug}`
const capHas = role.capabilities.includes(capKey)
return (
<button key={capKey} title={cap.description ?? ''}
onClick={() => toggleCapability(role.id, cap.app_slug, cap.slug, capHas)} style={{
background: capHas ? '#2d6a4f' : 'var(--body-bg)',
border: `1px solid ${capHas ? '#2d6a4f' : 'var(--card-border)'}`,
color: capHas ? '#fff' : 'var(--text-mid)',
borderRadius: '4px', padding: '0.15rem 0.5rem', fontSize: '0.7rem', cursor: 'pointer',
}}>
{capHas ? '✓ ' : ''}{cap.name}
</button>
)
})}
</div>
)}
</div>
)
})}
</div>
</div>
))}
</div>
</PageShell>
)
}
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 (
<form onSubmit={submit} style={{
background: 'var(--card-bg)', borderRadius: '10px', padding: '1.25rem',
border: '1px solid var(--card-border)', marginBottom: '1rem',
display: 'flex', flexDirection: 'column', gap: '0.75rem',
boxShadow: 'var(--shadow-sm)',
}}>
<h2 style={{ fontSize: '0.9rem', color: 'var(--navy)', fontWeight: 700 }}>New Role</h2>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<input placeholder="Role name" value={form.name} onChange={e => setName(e.target.value)}
required style={{ ...inp, flex: 1 }} />
<input placeholder="slug" value={form.slug} onChange={e => setForm(v => ({ ...v, slug: e.target.value }))}
required style={{ ...inp, flex: 1 }} />
<input placeholder="Description (optional)" value={form.description}
onChange={e => setForm(v => ({ ...v, description: e.target.value }))}
style={{ ...inp, flex: 2 }} />
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.875rem', cursor: 'pointer' }}>
<input type="checkbox" checked={form.is_default} onChange={e => setForm(v => ({ ...v, is_default: e.target.checked }))} />
Default role for self-registered employees
</label>
{error && <p style={{ color: 'var(--danger)', fontSize: '0.85rem' }}>{error}</p>}
<button type="submit" style={goldBtn}>Create role</button>
</form>
)
}
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',
}