From fa32ea84ee2612dc7d48055e28b17f5d38e339af Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Thu, 2 Jul 2026 00:24:05 +0000 Subject: [PATCH] Add self-registration, role management, and department mapping UI Co-Authored-By: Claude Sonnet 4.6 --- src/App.tsx | 14 ++- src/components/Sidebar.tsx | 10 +- src/pages/AdminDepartments.tsx | 162 ++++++++++++++++++++++++++++++ src/pages/AdminRoles.tsx | 175 +++++++++++++++++++++++++++++++++ src/pages/AdminUsers.tsx | 68 ++++++++++++- src/pages/Login.tsx | 6 ++ src/pages/Register.tsx | 147 +++++++++++++++++++++++++++ src/types.ts | 9 ++ 8 files changed, 584 insertions(+), 7 deletions(-) create mode 100644 src/pages/AdminDepartments.tsx create mode 100644 src/pages/AdminRoles.tsx create mode 100644 src/pages/Register.tsx diff --git a/src/App.tsx b/src/App.tsx index d190248..b2b4eab 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,12 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { AuthGate } from './components/AuthGate' import { Login } from './pages/Login' +import { Register } from './pages/Register' import { Dashboard } from './pages/Dashboard' import { AppFrame } from './pages/AppFrame' import { AdminUsers } from './pages/AdminUsers' +import { AdminRoles } from './pages/AdminRoles' +import { AdminDepartments } from './pages/AdminDepartments' import { AdminMonitor } from './pages/AdminMonitor' import { AdminSettings } from './pages/AdminSettings' @@ -36,16 +39,19 @@ export default function App() { return ( - } /> + } /> + } /> {user => ( } /> } /> - : } /> - : } /> - : } /> + : } /> + : } /> + : } /> + : } /> + : } /> } /> )} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 1db749b..690547b 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' 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 type { User, App } from '../types' @@ -123,6 +123,14 @@ export function Sidebar({ user, activeSlug }: Props) { Users + navItem(isActive)}> + + Roles + + navItem(isActive)}> + + Departments + navItem(isActive)}> Monitor diff --git a/src/pages/AdminDepartments.tsx b/src/pages/AdminDepartments.tsx new file mode 100644 index 0000000..f15b52a --- /dev/null +++ b/src/pages/AdminDepartments.tsx @@ -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([]) + const [roles, setRoles] = useState([]) + const [mappings, setMappings] = useState>({}) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [syncing, setSyncing] = useState(false) + const [syncResult, setSyncResult] = useState(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 = {} + 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 ( +
+ +
+
+

Workforce Departments

+
+ + +
+
+ + {syncResult && ( +
+ {syncResult} +
+ )} + +

+ Map Workforce departments to roles. Self-registering employees will automatically + receive the role mapped to their department. +

+ + {error &&

{error}

} + + {loading ? ( +

Loading departments…

+ ) : ( +
+ {depts.map(dept => ( +
+
+
{dept.name}
+
+ {dept.staff_count} staff +
+
+ +
+ ))} +
+ )} +
+
+ ) +} + +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', +} diff --git a/src/pages/AdminRoles.tsx b/src/pages/AdminRoles.tsx new file mode 100644 index 0000000..0b32f52 --- /dev/null +++ b/src/pages/AdminRoles.tsx @@ -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([]) + 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 ( +
+ +
+
+

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) + 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(--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', +} diff --git a/src/pages/AdminUsers.tsx b/src/pages/AdminUsers.tsx index 37e38aa..5bca5ac 100644 --- a/src/pages/AdminUsers.tsx +++ b/src/pages/AdminUsers.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import { Sidebar } from '../components/Sidebar' -import type { User } from '../types' +import type { User, Role } from '../types' interface ManagedUser { id: number @@ -9,21 +9,26 @@ interface ManagedUser { active: boolean is_admin: boolean offsite_allowed: boolean + workforce_user_id: string | null app_slugs: string[] + roles: { id: number; name: string; slug: string }[] } export function AdminUsers({ user }: { user: User }) { const [users, setUsers] = useState([]) const [allApps, setAllApps] = useState<{ slug: string; name: string }[]>([]) + const [allRoles, setAllRoles] = useState([]) const [showCreate, setShowCreate] = useState(false) 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/apps', { credentials: 'include' }).then(r => r.json()), + fetch('/api/auth/admin/roles', { credentials: 'include' }).then(r => r.json()), ]) setUsers(u) setAllApps(a) + setAllRoles(r) } useEffect(() => { load() }, []) @@ -44,6 +49,27 @@ export function AdminUsers({ user }: { user: User }) { 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 (
@@ -75,6 +101,7 @@ export function AdminUsers({ user }: { user: User }) { toggle(u.id, 'offsite_allowed', u.offsite_allowed)} />
+ {/* App grants */}
{allApps.map(app => { const has = u.app_slugs.includes(app.slug) @@ -90,6 +117,43 @@ export function AdminUsers({ user }: { user: User }) { ) })}
+ + {/* Roles */} +
+ Roles: + {u.roles.map(r => ( + + ))} + + {u.workforce_user_id && ( + + )} +
))} diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 57abf5b..fa35f7a 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -59,6 +59,12 @@ export function Login() { +

+ New employee?{' '} + + Register with your Workforce email + +

) diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx new file mode 100644 index 0000000..9c042a7 --- /dev/null +++ b/src/pages/Register.tsx @@ -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('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 ( +
+
+
+
🏨
+

MANAGE

+

+ {import.meta.env.VITE_HOTEL_NAME} +

+
+ +
+ + {step === 'email' ? ( + <> +

+ Staff registration +

+
+

+ Enter your Workforce email address. If you're registered with Workforce, + we'll send you a verification code. +

+ setEmail(e.target.value)} + placeholder="Your Workforce email" required autoComplete="email" + style={input} + /> + {error &&

{error}

} + +
+ + ) : ( + <> +

+ Verify your identity +

+
+

+ If {email} is registered with + Workforce, a 6-digit code has been sent to that address. +

+ 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' }} + /> + setPassword(e.target.value)} + placeholder="Choose a password (min 8 characters)" required autoComplete="new-password" + style={input} + /> + {error &&

{error}

} + + +
+ + )} +
+ +

+ Already have an account?{' '} + Sign in +

+
+
+ ) +} + +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)', +} diff --git a/src/types.ts b/src/types.ts index c8f85da..23e31dc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 { slug: string name: string