/* ============================================================ 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). The Permissions tab remains chrome; Access Control is the authoritative RBAC surface. ============================================================ */ import { useEffect, useMemo, useRef, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import Modal from '../ui/Modal' import PageHeader from '../ui/PageHeader' 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 { formatRole, fmtDate } from '../lib/format' import * as rolesApi from '../api/roles' import * as usersApi from '../api/users' import * as orgSettingsApi from '../api/orgSettings' /** "job_board" -> "Job Board". Slugs are the source of truth; this is display only. */ function humaniseSlug(slug) { return String(slug || '') .split(/[_-]/) .filter(Boolean) .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) .join(' ') } const TABS = [ 'General', 'Users', 'Approvals', '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) const pendingQuery = useQuery({ queryKey: qk.users.pendingApprovals(), queryFn: () => usersApi.listPendingApprovals().then((r) => r.data ?? []), }) const pendingCount = pendingQuery.data?.length ?? 0 return (
save.mutate()} > {save.isPending ? 'Saving…' : 'Save Changes'} )} /> ({ key: t, label: t, count: t === 'Approvals' && pendingCount ? pendingCount : undefined, }))} />
{tab === 'General' && { saveRef.current = fn }} />} {tab === 'Users' && } {tab === 'Approvals' && } {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' && }
) } /* The option label IS the stored value (org_settings rows hold the full string), so existing saved values like "(GMT-08:00) Pacific Time" must stay verbatim. */ const TIMEZONES = [ '(GMT-08:00) Pacific Time', '(GMT-07:00) Mountain Time', '(GMT-06:00) Central Time', '(GMT-05:00) Eastern Time', '(GMT+00:00) UTC', '(GMT+00:00) London', '(GMT+01:00) Central European Time', '(GMT+03:00) Arabia Standard Time', '(GMT+04:00) Gulf Standard Time', '(GMT+05:00) Pakistan Standard Time', '(GMT+05:30) India Standard Time', '(GMT+08:00) Singapore Standard Time', '(GMT+09:00) Japan Standard Time', '(GMT+10:00) Australian Eastern Time', ] // Codes match the offer form's currency set (Offers.jsx). const CURRENCIES = ['USD ($)', 'EUR (€)', 'GBP (£)', 'PKR (₨)', 'AED (د.إ)'] 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+05:00) Pakistan Standard 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() // used by Invite User button above 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`}
{/* Hidden: Invite User — restore this block to show the button again. */}
{usersQuery.isError ? (
{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}
{formatRole(u.role_name) || 'No role'} {!u.is_active ? 'Pending' : u.is_approved ? 'Active' : 'Awaiting approval'} {u.created_at ? fmtDate(u.created_at) : '—'}
)} {editing && ( setEditing(null)} /> )}
) } function Approvals() { const { toast } = useToast() const { can } = usePermission() const qc = useQueryClient() const pendingQuery = useQuery({ queryKey: qk.users.pendingApprovals(), queryFn: () => usersApi.listPendingApprovals().then((r) => r.data ?? []), }) const pending = pendingQuery.data ?? [] const canApprove = can('rbac_users.edit') const approve = useMutation({ mutationFn: (id) => usersApi.approve(id), onSuccess: (_data, id) => { qc.invalidateQueries({ queryKey: qk.users.all() }) const name = pending.find((u) => String(u.id) === String(id))?.name toast(name ? `${name} has been approved` : 'User approved', 'success') }, onError: (err) => toast(friendlyAuthError(err, 'Could not approve this user.'), 'error'), }) return (

Pending Approvals

{pendingQuery.isPending ? 'Loading…' : `${pending.length} waiting for approval`}
{pendingQuery.isError ? (
{friendlyAuthError(pendingQuery.error, 'The server did not return pending users.')}
) : !pendingQuery.isPending && pending.length === 0 ? (
New signups appear here after they confirm their email.
) : (
{pending.map((u) => ( ))}
User Role Signed up Actions
{u.name}
{u.email}
{formatRole(u.role_name) || 'No role'} {u.created_at ? fmtDate(u.created_at) : '—'}
)} {!canApprove && pending.length > 0 && (
Approving a user requires rbac_users.edit. You can see the queue but cannot approve.
)}
) } 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 ${formatRole(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.
)}
) } /* Module slug -> rail icon. Unlisted modules fall back to 'lock'. */ const MODULE_ICONS = { dashboard: 'dashboard', inbox: 'inbox', jobs: 'briefcase', candidates: 'users', pipeline: 'pipeline', interviews: 'video', assessments: 'check-square', offers: 'offers', reports: 'reports', analytics: 'analytics', job_board: 'grid', settings: 'settings', rbac_users: 'shield', tasks: 'list', } const OTHER_GROUP = '__other__' const normaliseName = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '_') /** * Permission Matrix — bundles grouped by module in the rail, one edited at a time. * * The rail groups on the module slugs from the tag catalogue, NOT on a split at the * first underscore. Longest match wins, so `Job_Board_Read` files under job_board * instead of colliding with `Jobs_*`. Two bundles (Candidate_SelfService, * Interviewer_Assigned) are role-shaped and span 4-5 modules; a name-prefix rule * would file them under a module their tags never touch, so they get an explicit * Cross-module group rather than a wrong home. * * The grid stays one bundle at a time: all bundles x all tags is ~5000 cells with * under 5% ticked, which reads as scattered dots rather than a matrix. */ function Permissions() { const { toast } = useToast() const qc = useQueryClient() const [bundleId, setBundleId] = useState(null) const [openKey, setOpenKey] = useState(null) const [filter, setFilter] = useState('') const bundlesQuery = useQuery({ queryKey: qk.roles.permissions(), queryFn: () => rolesApi.listPermissions().then((r) => r.data ?? []), }) const tagsQuery = useQuery({ queryKey: qk.roles.tags(), queryFn: () => rolesApi.listPermissionTags().then((r) => r.data ?? []), }) /* Roles are read only for blast radius: is_system is true on all 44 rows, so it discriminates nothing. How many roles hold a bundle actually varies. */ const rolesQuery = useQuery({ queryKey: qk.roles.list(), queryFn: () => rolesApi.listRoles().then((r) => r.data ?? []), }) const bundles = bundlesQuery.data ?? [] const tags = tagsQuery.data ?? [] const roles = rolesQuery.data ?? [] const holdersByBundle = useMemo(() => { const map = new Map() for (const r of roles) { for (const id of r.permissions ?? []) { const list = map.get(Number(id)) ?? [] list.push(formatRole(r.role_name)) map.set(Number(id), list) } } return map }, [roles]) /* Axes in first-seen order, so the grid reads the way the catalogue does. */ const { modules, actions, byCell } = useMemo(() => { const mods = [] const acts = [] const cells = new Map() for (const t of tags) { if (t.module && !mods.includes(t.module)) mods.push(t.module) if (t.action && !acts.includes(t.action)) acts.push(t.action) cells.set(`${t.module}.${t.action}`, t.id) } return { modules: mods, actions: acts, byCell: cells } }, [tags]) const groups = useMemo(() => { /* Longest slug first: `job_board` must beat `jobs` on Job_Board_Read. */ const ranked = [...modules].sort((a, b) => b.length - a.length) const byKey = new Map() const other = [] for (const b of bundles) { const n = normaliseName(b.name) const hit = ranked.find((s) => n === s || n.startsWith(`${s}_`)) if (!hit) { other.push(b); continue } if (!byKey.has(hit)) byKey.set(hit, []) byKey.get(hit).push(b) } /* Emit in module order so the rail matches the matrix row order. */ const out = modules.filter((m) => byKey.has(m)).map((m) => ({ key: m, label: humaniseSlug(m), icon: MODULE_ICONS[m] ?? 'lock', items: byKey.get(m), })) if (other.length) out.push({ key: OTHER_GROUP, label: 'Cross-module', icon: 'layers', items: other }) return out }, [bundles, modules]) const matches = useMemo(() => { const q = filter.trim().toLowerCase() if (!q) return null return bundles.filter( (b) => b.name.toLowerCase().includes(q) || (b.description ?? '').toLowerCase().includes(q), ) }, [bundles, filter]) const bundle = bundles.find((b) => b.id === bundleId) ?? (matches ?? bundles)[0] ?? bundles[0] const activeKey = groups.find((g) => g.items.some((b) => b.id === bundle?.id))?.key const open = openKey ?? activeKey const granted = useMemo(() => new Set((bundle?.permission_tags ?? []).map(Number)), [bundle]) const save = useMutation({ mutationFn: (permission_tags) => rolesApi.updatePermissionTags({ id: bundle.id, permission_tags }), onSuccess: () => { qc.invalidateQueries({ queryKey: qk.roles.all() }) toast('Permissions updated', 'success') }, onError: (err) => toast(friendlyAuthError(err, 'Could not update permissions.'), 'error'), }) /* The endpoint replaces permission_tags wholesale — that is what makes unticking actually revoke — so send the whole set, never a delta. */ const commit = (next) => save.mutate([...next]) const toggle = (tagId) => { const next = new Set(granted) if (next.has(tagId)) next.delete(tagId) else next.add(tagId) commit(next) } const setRow = (m, on) => { const next = new Set(granted) for (const a of actions) { const id = byCell.get(`${m}.${a}`) if (!id) continue if (on) next.add(id) else next.delete(id) } commit(next) } if (bundlesQuery.isPending || tagsQuery.isPending) { return (
Fetching the catalogue from the server…
) } if (bundlesQuery.isError || tagsQuery.isError) { return (
{friendlyAuthError(bundlesQuery.error ?? tagsQuery.error, 'The server did not return the permission catalogue.')} {' '}This tab needs the rbac_users.view permission.
) } if (!bundle) { return (
Bundles are seeded server-side.
) } const holders = holdersByBundle.get(bundle.id) ?? [] const bundleRow = (b) => { const held = holdersByBundle.get(b.id) ?? [] return (
setBundleId(b.id)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setBundleId(b.id) } }} >
{b.name}
{b.permission_tags?.length ?? 0} tags {held.length ? ` · ${held.length} role${held.length > 1 ? 's' : ''}` : ' · unused'}
) } return (
Bundles · {bundles.length}
setFilter(e.target.value)} aria-label="Filter permission bundles" />
{/* Filtering flattens the tree — a hit inside a collapsed group would otherwise be invisible. */} {matches ? (matches.length ? matches.map(bundleRow) :

No bundle matches that filter.

) : groups.map((g) => { const isOpen = open === g.key return (
setOpenKey(isOpen ? '' : g.key)} role="button" aria-expanded={isOpen} tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setOpenKey(isOpen ? '' : g.key) } }} >
{g.label}
{g.items.length} bundles
{isOpen && g.items.map(bundleRow)}
) })}

{bundle.name}

{bundle.description || 'No description'}
{save.isPending && Saving…} {granted.size} / {tags.length}

{' '} {holders.length ? ( <> Held by {holders.length} role{holders.length > 1 ? 's' : ''} — {holders.join(', ')}. Each gains or loses access on its next request. ) : ( <>No role holds this bundle, so edits here change nobody’s access yet. )}

{actions.map((a) => )} {modules.map((m) => { const ids = actions.map((a) => byCell.get(`${m}.${a}`)).filter(Boolean) const on = ids.filter((id) => granted.has(id)).length return ( {actions.map((a) => { const tagId = byCell.get(`${m}.${a}`) /* No tag, no cell. An unchecked box would imply a denial the catalogue never expressed. */ if (!tagId) return return ( ) })} ) })}
Module{humaniseSlug(a)}
{humaniseSlug(m)}
{on} of {ids.length}
) } 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
) }