/* ============================================================ Settings — org settings tabs persist via GET/PUT /org-settings/*. Users + Appearance stay as before. Email Templates stay decorative (explicitly out of Section C wiring scope). Roles/Permissions remain chrome; Access Control is the authoritative RBAC surface. ============================================================ */ import { useEffect, useRef, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import Modal from '../ui/Modal' import { Tabs } from '../ui/Tabs' import { Avatar, Badge, EmptyState, FieldError, Icon } from '../ui/primitives' import { useToast } from '../ui/Toast' import { useTheme } from '../theme/ThemeProvider' import { useFormState } from '../components/AuthLayout' import { usePermission } from '../auth/AuthContext' import { qk } from '../lib/queryKeys' import { friendlyAuthError } from '../lib/errors' import * as rolesApi from '../api/roles' import * as usersApi from '../api/users' import * as orgSettingsApi from '../api/orgSettings' import { roles as seedRoles } from '../data/seed' const TABS = [ 'General', 'Users', 'Roles', 'Permissions', 'Notifications', 'Email Templates', 'Career Portal', 'Branding', 'Security', 'Appearance', ] const ORG_TABS = new Set(['General', 'Notifications', 'Career Portal', 'Branding', 'Security']) const CATEGORY_BY_TAB = { General: 'general', Notifications: 'notifications', 'Career Portal': 'career_portal', Branding: 'branding', Security: 'security', } function ToggleRow({ title, desc, checked, onChange, disabled }) { return (

{title}

{desc}

) } function useOrgDraft(category, defaults) { const query = useQuery({ queryKey: qk.orgSettings.list({ category }), queryFn: async () => orgSettingsApi.toMap(await orgSettingsApi.list({ category })), }) const [draft, setDraft] = useState(defaults) useEffect(() => { if (!query.data) return setDraft((prev) => { const next = { ...prev } for (const key of Object.keys(defaults)) { if (query.data[key] !== undefined) next[key] = query.data[key] } return next }) }, [query.data]) // eslint-disable-line react-hooks/exhaustive-deps function setField(key, value) { setDraft((d) => ({ ...d, [key]: value })) } function toPayload() { return Object.entries(draft).map(([key, value]) => ({ key, value, category })) } return { query, draft, setField, toPayload } } export default function Settings() { const { toast } = useToast() const { can } = usePermission() const qc = useQueryClient() const [tab, setTab] = useState('General') const saveRef = useRef(null) const save = useMutation({ mutationFn: async () => { if (!saveRef.current) return null return saveRef.current() }, onSuccess: () => { qc.invalidateQueries({ queryKey: qk.orgSettings.all() }) toast('Settings saved', 'success') }, onError: (err) => toast(friendlyAuthError(err, 'Could not save settings.'), 'error'), }) const canConfigure = can('settings.configure') const showOrgSave = ORG_TABS.has(tab) return (

Settings

Configure your workspace and team preferences

{showOrgSave && ( )}
({ key: t, label: t }))} />
{tab === 'General' && { saveRef.current = fn }} />} {tab === 'Users' && } {tab === 'Roles' && } {tab === 'Permissions' && } {tab === 'Notifications' && { saveRef.current = fn }} />} {tab === 'Email Templates' && } {tab === 'Career Portal' && { saveRef.current = fn }} />} {tab === 'Branding' && { saveRef.current = fn }} />} {tab === 'Security' && { saveRef.current = fn }} />} {tab === 'Appearance' && }
) } function General({ registerSave }) { const defaults = { 'general.company_name': 'Utopia Brands Inc.', 'general.website': 'https://utopiabrands.com', 'general.industry': 'Consumer Goods', 'general.company_size': '201–500', 'general.timezone': '(GMT-08:00) Pacific Time', 'general.currency': 'USD ($)', 'general.auto_archive_stale_jobs': true, 'general.duplicate_detection': true, } const { query, draft, setField, toPayload } = useOrgDraft(CATEGORY_BY_TAB.General, defaults) useEffect(() => { registerSave?.(() => orgSettingsApi.update(toPayload())) }) if (query.isPending) { return
Fetching organisation settings.
} if (query.isError) { return (
{friendlyAuthError(query.error, 'This tab needs settings.view.')}
) } return (
setField('general.company_name', e.target.value)} />
setField('general.website', e.target.value)} />
setField('general.auto_archive_stale_jobs', v)} /> setField('general.duplicate_detection', v)} />
) } /** Real data: GET /users/fetch (requires rbac_users.view). */ function Users() { const { toast } = useToast() const [editing, setEditing] = useState(null) const usersQuery = useQuery({ queryKey: qk.users.list(), queryFn: () => usersApi.list({ top: 50 }).then((r) => r.data ?? []), }) const users = usersQuery.data ?? [] return (

Team Members

{usersQuery.isPending ? 'Loading…' : `${users.length} users`}
{usersQuery.isError ? (

Couldn’t load users

{friendlyAuthError(usersQuery.error, 'The server did not return the user list.')} {' '}This tab needs the rbac_users.view permission.

) : (
{users.map((u) => ( ))}
UserRoleStatusCreated Actions
{u.name}
{u.email}
{u.role_name || 'No role'} {u.is_active ? 'Active' : 'Pending'} {u.created_at ? String(u.created_at).slice(0, 10) : '—'}
)} {editing && ( setEditing(null)} /> )}
) } function AssignRoleModal({ user, users, onClose }) { const { toast } = useToast() const { can } = usePermission() const qc = useQueryClient() const form = useFormState({ user_id: String(user.id), role_id: user.role_id == null ? '' : String(user.role_id), }) const rolesQuery = useQuery({ queryKey: qk.roles.list(), queryFn: () => rolesApi.listRoles().then((r) => r.data ?? []), }) const roles = (rolesQuery.data ?? []).filter((r) => r.is_active) const target = users.find((u) => String(u.id) === form.values.user_id) ?? user const currentRoleId = target.role_id == null ? '' : String(target.role_id) const clearing = form.values.role_id === '' const dirty = form.values.role_id !== currentRoleId function pickUser(id) { const next = users.find((u) => String(u.id) === id) form.setValues({ user_id: id, role_id: next?.role_id == null ? '' : String(next.role_id), }) } const save = useMutation({ mutationFn: () => clearing ? usersApi.removeRole(form.values.user_id) : usersApi.assignRole(form.values.user_id, Number(form.values.role_id)), onSuccess: () => { qc.invalidateQueries({ queryKey: qk.users.all() }) const picked = roles.find((r) => String(r.id) === form.values.role_id) toast( clearing ? `Role removed from ${target.name}` : `${target.name} is now ${picked?.role_name ?? 'assigned'}`, 'success', ) onClose() }, onError: (err) => toast(friendlyAuthError(err, 'Could not update the role.'), 'error'), }) const busy = save.isPending function submit() { if (!dirty) { onClose() return } save.mutate() } return ( } >
{ e.preventDefault(); submit() }}>
{rolesQuery.isError ? friendlyAuthError(rolesQuery.error, 'Could not load the role list.') : null} {rolesQuery.isPending && Loading roles…} {clearing && currentRoleId !== '' && ( Saving will clear this user’s role. They keep no permissions until reassigned. )}
{!can('rbac_users.manage') && (
Your account does not hold rbac_users.manage, which the server requires on top of rbac_users.edit to change a role. Saving will be rejected.
)}
) } function Roles() { const { toast } = useToast() return (

Roles

Define access levels — use Access Control for live RBAC
{seedRoles.map((r) => (
{r.name}
{r.desc}
{r.users} users
{r.perms}
))}
) } function Permissions() { const modules = ['Jobs', 'Candidates', 'Interviews', 'Offers', 'Reports', 'Settings'] const perms = ['View', 'Create', 'Edit', 'Delete'] const { toast } = useToast() return (

Permission Matrix

Recruiter role

This is a simplified view. The authoritative matrix — 13 modules × 8 actions, resolved from the server — lives on Access Control.

{perms.map((p) => )} {modules.map((m) => ( {perms.map((p) => ( ))} ))}
Module{p}
{m}
) } function Notifications({ registerSave }) { const defaults = { 'notifications.email_new_applications': true, 'notifications.email_interview_reminders': true, 'notifications.email_offer_responses': true, 'notifications.email_weekly_digest': false, 'notifications.inapp_mentions': true, 'notifications.inapp_stage_changes': false, 'notifications.inapp_task_assignments': true, } const { query, draft, setField, toPayload } = useOrgDraft(CATEGORY_BY_TAB.Notifications, defaults) useEffect(() => { registerSave?.(() => orgSettingsApi.update(toPayload())) }) if (query.isPending) { return
Fetching notification preferences.
} if (query.isError) { return (
{friendlyAuthError(query.error, 'This tab needs settings.view.')}
) } return (
Email Notifications
setField('notifications.email_new_applications', v)} /> setField('notifications.email_interview_reminders', v)} /> setField('notifications.email_offer_responses', v)} /> setField('notifications.email_weekly_digest', v)} />
In-App Notifications
setField('notifications.inapp_mentions', v)} /> setField('notifications.inapp_stage_changes', v)} /> setField('notifications.inapp_task_assignments', v)} />
) } function EmailTemplates() { return (
Template CRUD exists on the backend but is out of scope for this wiring pass. Use Access Control / org settings for other configuration.
) } function CareerPortal({ registerSave }) { const defaults = { 'career_portal.url': 'https://careers.utopiabrands.com', 'career_portal.headline': 'Build the future with us', 'career_portal.cta': 'View Open Roles', 'career_portal.public_job_board': true, 'career_portal.one_click_apply': true, 'career_portal.show_salary': false, 'career_portal.enable_referrals': true, } const { query, draft, setField, toPayload } = useOrgDraft(CATEGORY_BY_TAB['Career Portal'], defaults) useEffect(() => { registerSave?.(() => orgSettingsApi.update(toPayload())) }) if (query.isPending) { return
Fetching career portal settings.
} if (query.isError) { return (
{friendlyAuthError(query.error, 'This tab needs settings.view.')}
) } return (
setField('career_portal.url', e.target.value)} />
setField('career_portal.headline', e.target.value)} />
setField('career_portal.cta', e.target.value)} />
setField('career_portal.public_job_board', v)} /> setField('career_portal.one_click_apply', v)} /> setField('career_portal.show_salary', v)} /> setField('career_portal.enable_referrals', v)} />
) } function Branding({ registerSave }) { const colors = ['#004d43', '#ceff71', '#25e9a5', '#8e92ff', '#1a3134', '#eafff4'] const defaults = { 'branding.primary_color': '#004d43', 'branding.email_footer': 'Utopia Brands · San Francisco, CA', 'branding.support_email': 'talent@utopiabrands.com', } const { query, draft, setField, toPayload } = useOrgDraft(CATEGORY_BY_TAB.Branding, defaults) useEffect(() => { registerSave?.(() => orgSettingsApi.update(toPayload())) }) if (query.isPending) { return
Fetching branding settings.
} if (query.isError) { return (
{friendlyAuthError(query.error, 'This tab needs settings.view.')}
) } return (

Brand Color

Primary accent across the portal (persisted; not yet applied globally)

{colors.map((c) => ( setField('branding.primary_color', c)} onKeyDown={(e) => { if (e.key === 'Enter') setField('branding.primary_color', c) }} /> ))}
setField('branding.email_footer', e.target.value)} />
setField('branding.support_email', e.target.value)} />
) } function Security({ registerSave }) { const defaults = { 'security.two_factor_enabled': true, 'security.sso_enabled': false, 'security.ip_allowlist': false, 'security.audit_logging': true, 'security.session_timeout': '30 minutes', 'security.password_policy': 'Strong (12+ chars)', 'security.data_retention_months': '24 months', } const { query, draft, setField, toPayload } = useOrgDraft(CATEGORY_BY_TAB.Security, defaults) useEffect(() => { registerSave?.(() => orgSettingsApi.update(toPayload())) }) if (query.isPending) { return
Fetching security settings.
} if (query.isError) { return (
{friendlyAuthError(query.error, 'This tab needs settings.view.')}
) } return (
Not yet enforced. These controls persist flags only — none of them is wired to enforcement. The backend today has no 2FA, no SSO, no IP allowlist and no audit log. Do not read the toggles below as a statement of what is switched on.
setField('security.two_factor_enabled', v)} /> setField('security.sso_enabled', v)} /> setField('security.ip_allowlist', v)} /> setField('security.audit_logging', v)} />

Data Retention

Auto-delete candidate data after set period

) } function Appearance() { const { toast } = useToast() const { setTheme, useSystemTheme } = useTheme() function pick(mode) { if (mode === 'system') useSystemTheme() else setTheme(mode) toast(`Theme updated to ${mode}`, 'success') } return (
Theme
pick('light')}>
Light
Clean and bright
pick('dark')}>
Dark
Easy on the eyes
pick('system')}>
System
Match OS setting
) }