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
14
src/App.tsx
14
src/App.tsx
|
|
@ -1,9 +1,12 @@
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { AuthGate } from './components/AuthGate'
|
import { AuthGate } from './components/AuthGate'
|
||||||
import { Login } from './pages/Login'
|
import { Login } from './pages/Login'
|
||||||
|
import { Register } from './pages/Register'
|
||||||
import { Dashboard } from './pages/Dashboard'
|
import { Dashboard } from './pages/Dashboard'
|
||||||
import { AppFrame } from './pages/AppFrame'
|
import { AppFrame } from './pages/AppFrame'
|
||||||
import { AdminUsers } from './pages/AdminUsers'
|
import { AdminUsers } from './pages/AdminUsers'
|
||||||
|
import { AdminRoles } from './pages/AdminRoles'
|
||||||
|
import { AdminDepartments } from './pages/AdminDepartments'
|
||||||
import { AdminMonitor } from './pages/AdminMonitor'
|
import { AdminMonitor } from './pages/AdminMonitor'
|
||||||
import { AdminSettings } from './pages/AdminSettings'
|
import { AdminSettings } from './pages/AdminSettings'
|
||||||
|
|
||||||
|
|
@ -36,16 +39,19 @@ export default function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/register" element={<Register />} />
|
||||||
<Route path="/*" element={
|
<Route path="/*" element={
|
||||||
<AuthGate>
|
<AuthGate>
|
||||||
{user => (
|
{user => (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard user={user} />} />
|
<Route path="/" element={<Dashboard user={user} />} />
|
||||||
<Route path="/app/:slug" element={<AppFrame user={user} />} />
|
<Route path="/app/:slug" element={<AppFrame user={user} />} />
|
||||||
<Route path="/admin/users" element={user.is_admin ? <AdminUsers user={user} /> : <Navigate to="/" />} />
|
<Route path="/admin/users" element={user.is_admin ? <AdminUsers user={user} /> : <Navigate to="/" />} />
|
||||||
<Route path="/admin/monitor" element={user.is_admin ? <AdminMonitor user={user} /> : <Navigate to="/" />} />
|
<Route path="/admin/roles" element={user.is_admin ? <AdminRoles user={user} /> : <Navigate to="/" />} />
|
||||||
<Route path="/admin/settings" element={user.is_admin ? <AdminSettings user={user} /> : <Navigate to="/" />} />
|
<Route path="/admin/departments" element={user.is_admin ? <AdminDepartments user={user} /> : <Navigate to="/" />} />
|
||||||
|
<Route path="/admin/monitor" element={user.is_admin ? <AdminMonitor user={user} /> : <Navigate to="/" />} />
|
||||||
|
<Route path="/admin/settings" element={user.is_admin ? <AdminSettings user={user} /> : <Navigate to="/" />} />
|
||||||
<Route path="*" element={<Navigate to="/" />} />
|
<Route path="*" element={<Navigate to="/" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { NavLink, useNavigate, useLocation } from 'react-router-dom'
|
import { NavLink, useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { LayoutGrid, Users, Activity, Settings, LogOut, ChevronDown } from 'lucide-react'
|
import { LayoutGrid, Users, Activity, Settings, LogOut, ChevronDown, ShieldCheck, Building2 } from 'lucide-react'
|
||||||
import { AppIcon } from './AppIcon'
|
import { AppIcon } from './AppIcon'
|
||||||
import type { User, App } from '../types'
|
import type { User, App } from '../types'
|
||||||
|
|
||||||
|
|
@ -123,6 +123,14 @@ export function Sidebar({ user, activeSlug }: Props) {
|
||||||
<Users size={14} strokeWidth={1.75} />
|
<Users size={14} strokeWidth={1.75} />
|
||||||
Users
|
Users
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink to="/admin/roles" style={({ isActive }) => navItem(isActive)}>
|
||||||
|
<ShieldCheck size={14} strokeWidth={1.75} />
|
||||||
|
Roles
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/admin/departments" style={({ isActive }) => navItem(isActive)}>
|
||||||
|
<Building2 size={14} strokeWidth={1.75} />
|
||||||
|
Departments
|
||||||
|
</NavLink>
|
||||||
<NavLink to="/admin/monitor" style={({ isActive }) => navItem(isActive)}>
|
<NavLink to="/admin/monitor" style={({ isActive }) => navItem(isActive)}>
|
||||||
<Activity size={14} strokeWidth={1.75} />
|
<Activity size={14} strokeWidth={1.75} />
|
||||||
Monitor
|
Monitor
|
||||||
|
|
|
||||||
162
src/pages/AdminDepartments.tsx
Normal file
162
src/pages/AdminDepartments.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Sidebar } from '../components/Sidebar'
|
||||||
|
import type { User, Role } from '../types'
|
||||||
|
|
||||||
|
interface WfDept {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
staff_count: number
|
||||||
|
mapped_role_id: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminDepartments({ user }: { user: User }) {
|
||||||
|
const [depts, setDepts] = useState<WfDept[]>([])
|
||||||
|
const [roles, setRoles] = useState<Role[]>([])
|
||||||
|
const [mappings, setMappings] = useState<Record<string, number | null>>({})
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [syncing, setSyncing] = useState(false)
|
||||||
|
const [syncResult, setSyncResult] = useState<string | null>(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const [d, r] = await Promise.all([
|
||||||
|
fetch('/api/auth/admin/workforce/departments', { credentials: 'include' }).then(res => {
|
||||||
|
if (!res.ok) throw new Error('Could not fetch departments from Workforce')
|
||||||
|
return res.json()
|
||||||
|
}),
|
||||||
|
fetch('/api/auth/admin/roles', { credentials: 'include' }).then(r => r.json()),
|
||||||
|
])
|
||||||
|
setDepts(d)
|
||||||
|
setRoles(r)
|
||||||
|
const m: Record<string, number | null> = {}
|
||||||
|
for (const dept of d) m[dept.id] = dept.mapped_role_id
|
||||||
|
setMappings(m)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Failed to load')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [])
|
||||||
|
|
||||||
|
async function saveMappings() {
|
||||||
|
setSaving(true)
|
||||||
|
const body = Object.entries(mappings)
|
||||||
|
.filter(([, roleId]) => roleId !== null)
|
||||||
|
.map(([department_id, role_id]) => ({ department_id, role_id }))
|
||||||
|
await fetch('/api/auth/admin/workforce/department-mappings', {
|
||||||
|
method: 'PUT', credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSync() {
|
||||||
|
setSyncing(true)
|
||||||
|
setSyncResult(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/admin/workforce/run-sync', {
|
||||||
|
method: 'POST', credentials: 'include',
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok) {
|
||||||
|
setSyncResult(
|
||||||
|
`Sync complete: ${data.checked} checked, ${data.deactivated} deactivated, ` +
|
||||||
|
`${data.emailUpdated} email updates, ${data.rolesUpdated} role updates`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
setSyncResult(`Sync failed: ${data.error}`)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setSyncResult('Sync failed: connection error')
|
||||||
|
} finally {
|
||||||
|
setSyncing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: '0.5rem', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||||
|
<h1 style={{ fontSize: '1.2rem' }}>Workforce Departments</h1>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
|
<button onClick={runSync} disabled={syncing} style={mutedBtn}>
|
||||||
|
{syncing ? 'Syncing…' : 'Run full sync now'}
|
||||||
|
</button>
|
||||||
|
<button onClick={saveMappings} disabled={saving || loading} style={goldBtn}>
|
||||||
|
{saving ? 'Saving…' : 'Save mappings'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{syncResult && (
|
||||||
|
<div style={{ fontSize: '0.82rem', color: 'var(--text-muted)', marginBottom: '1rem',
|
||||||
|
background: 'var(--surface)', borderRadius: '6px', padding: '0.6rem 0.875rem',
|
||||||
|
border: '1px solid var(--surface-2)' }}>
|
||||||
|
{syncResult}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p style={{ fontSize: '0.82rem', color: 'var(--text-muted)', marginBottom: '1.25rem' }}>
|
||||||
|
Map Workforce departments to roles. Self-registering employees will automatically
|
||||||
|
receive the role mapped to their department.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && <p style={{ color: 'var(--danger)', fontSize: '0.85rem', marginBottom: '1rem' }}>{error}</p>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>Loading departments…</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
|
{depts.map(dept => (
|
||||||
|
<div key={dept.id} style={{
|
||||||
|
background: 'var(--surface)', borderRadius: '8px',
|
||||||
|
padding: '0.75rem 1rem', border: '1px solid var(--surface-2)',
|
||||||
|
display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap',
|
||||||
|
}}>
|
||||||
|
<div style={{ flex: 1, minWidth: '140px' }}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: '0.875rem' }}>{dept.name}</div>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
|
||||||
|
{dept.staff_count} staff
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={mappings[dept.id] ?? ''}
|
||||||
|
onChange={e => setMappings(m => ({
|
||||||
|
...m, [dept.id]: e.target.value ? Number(e.target.value) : null
|
||||||
|
}))}
|
||||||
|
style={{
|
||||||
|
background: 'var(--navy-dark)', border: '1px solid var(--surface-2)',
|
||||||
|
borderRadius: '6px', color: 'var(--text)', padding: '0.4rem 0.6rem',
|
||||||
|
fontSize: '0.82rem', minWidth: '160px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">— No role —</option>
|
||||||
|
{roles.map(r => (
|
||||||
|
<option key={r.id} value={r.id}>{r.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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: '6px', padding: '0.5rem 1rem', fontSize: '0.875rem',
|
||||||
|
}
|
||||||
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',
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Sidebar } from '../components/Sidebar'
|
import { Sidebar } from '../components/Sidebar'
|
||||||
import type { User } from '../types'
|
import type { User, Role } from '../types'
|
||||||
|
|
||||||
interface ManagedUser {
|
interface ManagedUser {
|
||||||
id: number
|
id: number
|
||||||
|
|
@ -9,21 +9,26 @@ interface ManagedUser {
|
||||||
active: boolean
|
active: boolean
|
||||||
is_admin: boolean
|
is_admin: boolean
|
||||||
offsite_allowed: boolean
|
offsite_allowed: boolean
|
||||||
|
workforce_user_id: string | null
|
||||||
app_slugs: string[]
|
app_slugs: string[]
|
||||||
|
roles: { id: number; name: string; slug: string }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdminUsers({ user }: { user: User }) {
|
export function AdminUsers({ user }: { user: User }) {
|
||||||
const [users, setUsers] = useState<ManagedUser[]>([])
|
const [users, setUsers] = useState<ManagedUser[]>([])
|
||||||
const [allApps, setAllApps] = useState<{ slug: string; name: string }[]>([])
|
const [allApps, setAllApps] = useState<{ slug: string; name: string }[]>([])
|
||||||
|
const [allRoles, setAllRoles] = useState<Role[]>([])
|
||||||
const [showCreate, setShowCreate] = useState(false)
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const [u, a] = await Promise.all([
|
const [u, a, r] = await Promise.all([
|
||||||
fetch('/api/auth/admin/users', { credentials: 'include' }).then(r => r.json()),
|
fetch('/api/auth/admin/users', { credentials: 'include' }).then(r => r.json()),
|
||||||
fetch('/api/auth/admin/apps', { credentials: 'include' }).then(r => r.json()),
|
fetch('/api/auth/admin/apps', { credentials: 'include' }).then(r => r.json()),
|
||||||
|
fetch('/api/auth/admin/roles', { credentials: 'include' }).then(r => r.json()),
|
||||||
])
|
])
|
||||||
setUsers(u)
|
setUsers(u)
|
||||||
setAllApps(a)
|
setAllApps(a)
|
||||||
|
setAllRoles(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { load() }, [])
|
useEffect(() => { load() }, [])
|
||||||
|
|
@ -44,6 +49,27 @@ export function AdminUsers({ user }: { user: User }) {
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function addRole(userId: number, roleId: number) {
|
||||||
|
await fetch(`/api/auth/admin/users/${userId}/roles/${roleId}`, {
|
||||||
|
method: 'POST', credentials: 'include',
|
||||||
|
})
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeRole(userId: number, roleId: number) {
|
||||||
|
await fetch(`/api/auth/admin/users/${userId}/roles/${roleId}`, {
|
||||||
|
method: 'DELETE', credentials: 'include',
|
||||||
|
})
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncWfRoles(userId: number) {
|
||||||
|
await fetch(`/api/auth/admin/workforce/sync-user/${userId}`, {
|
||||||
|
method: 'POST', credentials: 'include',
|
||||||
|
})
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', height: '100dvh' }}>
|
<div style={{ display: 'flex', height: '100dvh' }}>
|
||||||
<Sidebar user={user} />
|
<Sidebar user={user} />
|
||||||
|
|
@ -75,6 +101,7 @@ export function AdminUsers({ user }: { user: User }) {
|
||||||
<Toggle label="Offsite" on={u.offsite_allowed} onClick={() => toggle(u.id, 'offsite_allowed', u.offsite_allowed)} />
|
<Toggle label="Offsite" on={u.offsite_allowed} onClick={() => toggle(u.id, 'offsite_allowed', u.offsite_allowed)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* App grants */}
|
||||||
<div style={{ marginTop: '0.75rem', display: 'flex', gap: '0.4rem', flexWrap: 'wrap' }}>
|
<div style={{ marginTop: '0.75rem', display: 'flex', gap: '0.4rem', flexWrap: 'wrap' }}>
|
||||||
{allApps.map(app => {
|
{allApps.map(app => {
|
||||||
const has = u.app_slugs.includes(app.slug)
|
const has = u.app_slugs.includes(app.slug)
|
||||||
|
|
@ -90,6 +117,43 @@ export function AdminUsers({ user }: { user: User }) {
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Roles */}
|
||||||
|
<div style={{ marginTop: '0.5rem', display: 'flex', gap: '0.4rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<span style={{ fontSize: '0.72rem', color: 'var(--text-muted)', marginRight: '0.15rem' }}>Roles:</span>
|
||||||
|
{u.roles.map(r => (
|
||||||
|
<button key={r.id} onClick={() => removeRole(u.id, r.id)} title="Click to remove" style={{
|
||||||
|
background: 'var(--navy)', border: '1px solid var(--surface-2)',
|
||||||
|
color: 'var(--text-muted)', borderRadius: '4px',
|
||||||
|
padding: '0.15rem 0.5rem', fontSize: '0.72rem', cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
{r.name} ×
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<select
|
||||||
|
value=""
|
||||||
|
onChange={e => { if (e.target.value) addRole(u.id, Number(e.target.value)) }}
|
||||||
|
style={{
|
||||||
|
background: 'var(--surface-2)', border: '1px solid var(--surface-2)',
|
||||||
|
borderRadius: '4px', color: 'var(--text-muted)',
|
||||||
|
padding: '0.15rem 0.4rem', fontSize: '0.72rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">+ Add role</option>
|
||||||
|
{allRoles.filter(r => !u.roles.find(ur => ur.id === r.id)).map(r => (
|
||||||
|
<option key={r.id} value={r.id}>{r.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{u.workforce_user_id && (
|
||||||
|
<button onClick={() => syncWfRoles(u.id)} style={{
|
||||||
|
background: 'none', border: '1px solid var(--surface-2)',
|
||||||
|
color: 'var(--text-muted)', borderRadius: '4px',
|
||||||
|
padding: '0.15rem 0.5rem', fontSize: '0.72rem', cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
Sync WF roles
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,12 @@ export function Login() {
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<p style={{ textAlign: 'center', marginTop: '1rem', fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
||||||
|
New employee?{' '}
|
||||||
|
<a href="/register" style={{ color: 'var(--gold)', textDecoration: 'none' }}>
|
||||||
|
Register with your Workforce email
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
147
src/pages/Register.tsx
Normal file
147
src/pages/Register.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
type Step = 'email' | 'pin'
|
||||||
|
|
||||||
|
export function Register() {
|
||||||
|
const [step, setStep] = useState<Step>('email')
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [pin, setPin] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
async function submitEmail(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await fetch('/api/auth/register', {
|
||||||
|
method: 'POST', credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
})
|
||||||
|
// Always advance — we never reveal whether the email was found
|
||||||
|
setStep('pin')
|
||||||
|
} catch {
|
||||||
|
setError('Connection error — please try again')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitPin(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/register/verify', {
|
||||||
|
method: 'POST', credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, pin, password }),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
navigate('/', { replace: true })
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
setError(data.error || 'Verification failed')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Connection error — please try again')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: '100dvh', display: 'flex', alignItems: 'center',
|
||||||
|
justifyContent: 'center', padding: '1.5rem', background: 'var(--navy-dark)' }}>
|
||||||
|
<div style={{ width: '100%', maxWidth: '360px' }}>
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: '2rem' }}>
|
||||||
|
<div style={{ fontSize: '2rem', marginBottom: '0.5rem' }}>🏨</div>
|
||||||
|
<h1 style={{ fontSize: '1.5rem', color: 'var(--gold)', letterSpacing: '0.05em' }}>MANAGE</h1>
|
||||||
|
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginTop: '0.25rem' }}>
|
||||||
|
{import.meta.env.VITE_HOTEL_NAME}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ background: 'var(--surface)', borderRadius: '12px',
|
||||||
|
padding: '1.75rem', border: '1px solid var(--surface-2)' }}>
|
||||||
|
|
||||||
|
{step === 'email' ? (
|
||||||
|
<>
|
||||||
|
<h2 style={{ fontSize: '0.95rem', color: 'var(--text)', marginBottom: '1rem' }}>
|
||||||
|
Staff registration
|
||||||
|
</h2>
|
||||||
|
<form onSubmit={submitEmail} style={{ display: 'flex', flexDirection: 'column', gap: '0.875rem' }}>
|
||||||
|
<p style={{ fontSize: '0.82rem', color: 'var(--text-muted)', margin: 0 }}>
|
||||||
|
Enter your Workforce email address. If you're registered with Workforce,
|
||||||
|
we'll send you a verification code.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="email" value={email} onChange={e => setEmail(e.target.value)}
|
||||||
|
placeholder="Your Workforce email" required autoComplete="email"
|
||||||
|
style={input}
|
||||||
|
/>
|
||||||
|
{error && <p style={{ color: 'var(--danger)', fontSize: '0.85rem', textAlign: 'center' }}>{error}</p>}
|
||||||
|
<button type="submit" disabled={loading} style={loading ? disabledBtn : goldBtn}>
|
||||||
|
{loading ? 'Sending…' : 'Send verification code'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h2 style={{ fontSize: '0.95rem', color: 'var(--text)', marginBottom: '1rem' }}>
|
||||||
|
Verify your identity
|
||||||
|
</h2>
|
||||||
|
<form onSubmit={submitPin} style={{ display: 'flex', flexDirection: 'column', gap: '0.875rem' }}>
|
||||||
|
<p style={{ fontSize: '0.82rem', color: 'var(--text-muted)', margin: 0 }}>
|
||||||
|
If <strong style={{ color: 'var(--text)' }}>{email}</strong> is registered with
|
||||||
|
Workforce, a 6-digit code has been sent to that address.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="text" value={pin} onChange={e => setPin(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||||
|
placeholder="6-digit code" required inputMode="numeric" maxLength={6}
|
||||||
|
style={{ ...input, letterSpacing: '0.25em', textAlign: 'center', fontSize: '1.25rem' }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password" value={password} onChange={e => setPassword(e.target.value)}
|
||||||
|
placeholder="Choose a password (min 8 characters)" required autoComplete="new-password"
|
||||||
|
style={input}
|
||||||
|
/>
|
||||||
|
{error && <p style={{ color: 'var(--danger)', fontSize: '0.85rem', textAlign: 'center' }}>{error}</p>}
|
||||||
|
<button type="submit" disabled={loading} style={loading ? disabledBtn : goldBtn}>
|
||||||
|
{loading ? 'Creating account…' : 'Create account'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => { setStep('email'); setPin(''); setPassword(''); setError('') }}
|
||||||
|
style={{ background: 'none', border: 'none', color: 'var(--text-muted)',
|
||||||
|
fontSize: '0.8rem', cursor: 'pointer', textDecoration: 'underline' }}>
|
||||||
|
Use a different email
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style={{ textAlign: 'center', marginTop: '1rem', fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
||||||
|
Already have an account?{' '}
|
||||||
|
<a href="/login" style={{ color: 'var(--gold)', textDecoration: 'none' }}>Sign in</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const input: React.CSSProperties = {
|
||||||
|
background: 'var(--navy-dark)', border: '1px solid var(--surface-2)',
|
||||||
|
borderRadius: '8px', color: 'var(--text)', padding: '0.7rem 0.875rem',
|
||||||
|
fontSize: '1rem', width: '100%', outline: 'none',
|
||||||
|
}
|
||||||
|
const goldBtn: React.CSSProperties = {
|
||||||
|
background: 'var(--gold)', color: 'var(--navy-dark)', border: 'none',
|
||||||
|
borderRadius: '8px', padding: '0.75rem', fontSize: '1rem', fontWeight: 700,
|
||||||
|
}
|
||||||
|
const disabledBtn: React.CSSProperties = {
|
||||||
|
...goldBtn, background: 'var(--surface-2)', color: 'var(--text-muted)',
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,12 @@
|
||||||
|
export interface Role {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
description: string | null
|
||||||
|
is_default: boolean
|
||||||
|
app_slugs: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface App {
|
export interface App {
|
||||||
slug: string
|
slug: string
|
||||||
name: string
|
name: string
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue