Add self-registration, role management, and department mapping UI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-02 00:24:05 +00:00
parent e9cd40c80a
commit fa32ea84ee
8 changed files with 584 additions and 7 deletions

View file

@ -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>
)} )}

View file

@ -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

View 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
View 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',
}

View file

@ -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>

View file

@ -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
View 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)',
}

View file

@ -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