Add self-registration, role management, and department mapping UI
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e9cd40c80a
commit
fa32ea84ee
8 changed files with 584 additions and 7 deletions
175
src/pages/AdminRoles.tsx
Normal file
175
src/pages/AdminRoles.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Sidebar } from '../components/Sidebar'
|
||||
import type { User, Role } from '../types'
|
||||
|
||||
export function AdminRoles({ user }: { user: User }) {
|
||||
const [roles, setRoles] = useState<Role[]>([])
|
||||
const [allApps, setAllApps] = useState<{ slug: string; name: string }[]>([])
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
|
||||
async function load() {
|
||||
const [r, a] = 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()),
|
||||
])
|
||||
setRoles(r)
|
||||
setAllApps(a)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', height: '100dvh' }}>
|
||||
<Sidebar user={user} />
|
||||
<main style={{ flex: 1, overflowY: 'auto', padding: '1.5rem 2rem' }}>
|
||||
<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(--surface)', borderRadius: '10px',
|
||||
padding: '1rem 1.25rem', border: `1px solid ${role.is_default ? 'var(--gold)' : 'var(--surface-2)'}`,
|
||||
}}>
|
||||
<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-muted)' }}>{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', gap: '0.4rem', flexWrap: 'wrap' }}>
|
||||
{allApps.map(app => {
|
||||
const has = role.app_slugs.includes(app.slug)
|
||||
return (
|
||||
<button key={app.slug} onClick={() => toggleApp(role.id, app.slug, has)} style={{
|
||||
background: has ? 'var(--navy)' : 'var(--surface-2)',
|
||||
border: `1px solid ${has ? 'var(--gold)' : 'var(--surface-2)'}`,
|
||||
color: has ? 'var(--gold)' : 'var(--text-muted)',
|
||||
borderRadius: '4px', padding: '0.2rem 0.6rem', fontSize: '0.75rem',
|
||||
}}>
|
||||
{app.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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(--surface)', borderRadius: '10px', padding: '1.25rem',
|
||||
border: '1px solid var(--gold)', marginBottom: '1rem',
|
||||
display: 'flex', flexDirection: 'column', gap: '0.75rem',
|
||||
}}>
|
||||
<h2 style={{ fontSize: '0.9rem', color: 'var(--gold)' }}>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(--navy-dark)', border: '1px solid var(--surface-2)',
|
||||
borderRadius: '6px', color: 'var(--text)', padding: '0.6rem 0.75rem', fontSize: '0.875rem',
|
||||
}
|
||||
const goldBtn: React.CSSProperties = {
|
||||
background: 'var(--gold)', color: 'var(--navy-dark)', border: 'none',
|
||||
borderRadius: '6px', padding: '0.5rem 1rem', fontSize: '0.875rem', fontWeight: 600,
|
||||
}
|
||||
const mutedBtn: React.CSSProperties = {
|
||||
background: 'var(--surface-2)', color: 'var(--text-muted)', border: '1px solid var(--surface-2)',
|
||||
borderRadius: '4px', padding: '0.25rem 0.6rem', fontSize: '0.75rem',
|
||||
}
|
||||
const dangerBtn: React.CSSProperties = {
|
||||
background: 'none', color: 'var(--danger)', border: '1px solid var(--danger)',
|
||||
borderRadius: '4px', padding: '0.25rem 0.6rem', fontSize: '0.75rem',
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue