HR-ATS-Portal/frontend/src/screens/Settings.jsx

1222 lines
48 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/* ============================================================
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 (
<div className="setting-row">
<div className="setting-info"><h4>{title}</h4><p>{desc}</p></div>
<label className="switch">
<input
type="checkbox"
checked={Boolean(checked)}
disabled={disabled}
onChange={(e) => onChange?.(e.target.checked)}
/>
<span className="switch-track" />
</label>
</div>
)
}
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 (
<div className="page">
<PageHeader
title="Settings"
sub="Configure your workspace and team preferences"
actions={showOrgSave && (
<button
className="btn btn-primary"
disabled={!canConfigure || save.isPending}
onClick={() => save.mutate()}
>
<Icon name="check" /> {save.isPending ? 'Saving…' : 'Save Changes'}
</button>
)}
/>
<Tabs
value={tab}
onChange={setTab}
tabs={TABS.map((t) => ({
key: t,
label: t,
count: t === 'Approvals' && pendingCount ? pendingCount : undefined,
}))}
/>
<div className="tab-pane active">
{tab === 'General' && <General registerSave={(fn) => { saveRef.current = fn }} />}
{tab === 'Users' && <Users />}
{tab === 'Approvals' && <Approvals />}
{tab === 'Permissions' && <Permissions />}
{tab === 'Notifications' && <Notifications registerSave={(fn) => { saveRef.current = fn }} />}
{tab === 'Email Templates' && <EmailTemplates />}
{tab === 'Career Portal' && <CareerPortal registerSave={(fn) => { saveRef.current = fn }} />}
{tab === 'Branding' && <Branding registerSave={(fn) => { saveRef.current = fn }} />}
{tab === 'Security' && <Security registerSave={(fn) => { saveRef.current = fn }} />}
{tab === 'Appearance' && <Appearance />}
</div>
</div>
)
}
/* 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': '201500',
'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 <div className="card"><div className="card-body"><EmptyState icon="settings" title="Loading…">Fetching organisation settings.</EmptyState></div></div>
}
if (query.isError) {
return (
<div className="card"><div className="card-body">
<EmptyState icon="settings" title="Couldnt load settings">
{friendlyAuthError(query.error, 'This tab needs settings.view.')}
</EmptyState>
</div></div>
)
}
return (
<div className="card">
<div className="card-body">
<div className="form-grid">
<div className="form-field">
<label>Organization Name</label>
<input value={draft['general.company_name'] ?? ''} onChange={(e) => setField('general.company_name', e.target.value)} />
</div>
<div className="form-field">
<label>Company Website</label>
<input value={draft['general.website'] ?? ''} onChange={(e) => setField('general.website', e.target.value)} />
</div>
<div className="form-field">
<label>Industry</label>
<select value={draft['general.industry'] ?? ''} onChange={(e) => setField('general.industry', e.target.value)}>
<option>Consumer Goods</option><option>Technology</option><option>Retail</option>
</select>
</div>
<div className="form-field">
<label>Company Size</label>
<select value={draft['general.company_size'] ?? ''} onChange={(e) => setField('general.company_size', e.target.value)}>
<option>201500</option><option>51200</option><option>500+</option>
</select>
</div>
<div className="form-field">
<label>Default Time Zone</label>
<select value={draft['general.timezone'] ?? ''} onChange={(e) => setField('general.timezone', e.target.value)}>
{TIMEZONES.map((tz) => <option key={tz}>{tz}</option>)}
</select>
</div>
<div className="form-field">
<label>Default Currency</label>
<select value={draft['general.currency'] ?? ''} onChange={(e) => setField('general.currency', e.target.value)}>
{CURRENCIES.map((c) => <option key={c}>{c}</option>)}
</select>
</div>
</div>
<div className="divider" />
<ToggleRow
title="Auto-archive stale jobs"
desc="Automatically close requisitions inactive for 90 days"
checked={draft['general.auto_archive_stale_jobs']}
onChange={(v) => setField('general.auto_archive_stale_jobs', v)}
/>
<ToggleRow
title="Duplicate detection"
desc="Flag candidates that already exist in the system"
checked={draft['general.duplicate_detection']}
onChange={(v) => setField('general.duplicate_detection', v)}
/>
</div>
</div>
)
}
/** 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 (
<div className="card">
<div className="card-head">
<div>
<h3>Team Members</h3>
<span className="ch-sub">
{usersQuery.isPending ? 'Loading…' : `${users.length} users`}
</span>
</div>
{/* Hidden: Invite User — restore this block to show the button again.
<button className="btn btn-primary btn-sm" onClick={() => toast('Invite sent', 'success')}>
<Icon name="plus" /> Invite User
</button>
*/}
</div>
{usersQuery.isError ? (
<div className="card-body">
<EmptyState icon="alert" title="Couldnt load users">
{friendlyAuthError(usersQuery.error, 'The server did not return the user list.')}
{' '}This tab needs the <code>rbac_users.view</code> permission.
</EmptyState>
</div>
) : (
<div className="table-wrap">
<table className="data">
<thead>
<tr>
<th>User</th><th>Role</th><th>Status</th><th>Created</th>
<th style={{ textAlign: 'right' }}>Actions</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td>
<div className="user-cell">
<Avatar name={u.name} />
<div className="min-w-0">
<div className="cell-primary">{u.name}</div>
<div className="cell-sub" title={u.email || undefined}>{u.email}</div>
</div>
</div>
</td>
<td><Badge className="b-indigo">{formatRole(u.role_name) || 'No role'}</Badge></td>
<td>
<Badge className={!u.is_active ? undefined : u.is_approved ? 'b-green' : 'b-amber'}>
{!u.is_active ? 'Pending' : u.is_approved ? 'Active' : 'Awaiting approval'}
</Badge>
</td>
<td className="text-muted">{u.created_at ? fmtDate(u.created_at) : '—'}</td>
<td style={{ textAlign: 'right' }}>
<div className="row-actions">
<button
className="act-btn"
onClick={() => setEditing(u)}
aria-label={`Edit ${u.name}`}
>
<Icon name="edit" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{editing && (
<AssignRoleModal user={editing} users={users} onClose={() => setEditing(null)} />
)}
</div>
)
}
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 (
<div className="card">
<div className="card-head">
<div>
<h3>Pending Approvals</h3>
<span className="ch-sub">
{pendingQuery.isPending
? 'Loading…'
: `${pending.length} waiting for approval`}
</span>
</div>
</div>
{pendingQuery.isError ? (
<div className="card-body">
<EmptyState icon="alert" title="Couldnt load approvals">
{friendlyAuthError(pendingQuery.error, 'The server did not return pending users.')}
</EmptyState>
</div>
) : !pendingQuery.isPending && pending.length === 0 ? (
<div className="card-body">
<EmptyState icon="check" title="No pending approvals">
New signups appear here after they confirm their email.
</EmptyState>
</div>
) : (
<div className="table-wrap">
<table className="data">
<thead>
<tr>
<th>User</th>
<th>Role</th>
<th>Signed up</th>
<th style={{ textAlign: 'right' }}>Actions</th>
</tr>
</thead>
<tbody>
{pending.map((u) => (
<tr key={u.id}>
<td>
<div className="user-cell">
<Avatar name={u.name} />
<div className="min-w-0">
<div className="cell-primary">{u.name}</div>
<div className="cell-sub" title={u.email || undefined}>{u.email}</div>
</div>
</div>
</td>
<td><Badge className="b-indigo">{formatRole(u.role_name) || 'No role'}</Badge></td>
<td className="text-muted">{u.created_at ? fmtDate(u.created_at) : '—'}</td>
<td style={{ textAlign: 'right' }}>
<button
className="btn btn-primary btn-sm"
disabled={!canApprove || approve.isPending}
onClick={() => approve.mutate(u.id)}
>
<Icon name="check" /> {approve.isPending && approve.variables === u.id ? 'Approving…' : 'Approve'}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{!canApprove && pending.length > 0 && (
<div className="card-body">
<div className="alert alert-danger">
Approving a user requires <code>rbac_users.edit</code>. You can see the queue but cannot approve.
</div>
</div>
)}
</div>
)
}
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 (
<Modal
title="Assign Role"
subtitle={`Change which role ${target.name} holds`}
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose} disabled={busy}>Cancel</button>
<button
className="btn btn-primary"
onClick={submit}
disabled={busy || rolesQuery.isPending}
>
<Icon name="check" /> {busy ? 'Saving…' : 'Save Changes'}
</button>
</>
}
>
<form noValidate onSubmit={(e) => { e.preventDefault(); submit() }}>
<div className="form-grid">
<div className="form-field col-span-2">
<label htmlFor="ar-user">User</label>
<select
id="ar-user"
value={form.values.user_id}
onChange={(e) => pickUser(e.target.value)}
disabled={busy}
>
{users.map((u) => (
<option key={u.id} value={u.id}>{u.name} {u.email}</option>
))}
</select>
</div>
<div className="form-field col-span-2">
<label htmlFor="ar-role">Role</label>
<select
id="ar-role"
className={rolesQuery.isError ? 'err' : ''}
value={form.values.role_id}
onChange={(e) => form.setField('role_id', e.target.value)}
disabled={busy || rolesQuery.isPending || rolesQuery.isError}
>
<option value="">No role</option>
{roles.map((r) => (
<option key={r.id} value={r.id}>{formatRole(r.role_name)}</option>
))}
</select>
<FieldError>
{rolesQuery.isError
? friendlyAuthError(rolesQuery.error, 'Could not load the role list.')
: null}
</FieldError>
{rolesQuery.isPending && <span className="lr-sub">Loading roles</span>}
{clearing && currentRoleId !== '' && (
<span className="lr-sub">
Saving will clear this users role. They keep no permissions until reassigned.
</span>
)}
</div>
</div>
{!can('rbac_users.manage') && (
<div className="alert alert-danger" style={{ marginTop: 14 }}>
Your account does not hold <code>rbac_users.manage</code>, which the server requires on
top of <code>rbac_users.edit</code> to change a role. Saving will be rejected.
</div>
)}
</form>
</Modal>
)
}
/* 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 (
<div className="card"><div className="card-body">
<EmptyState icon="clock" title="Loading permissions">Fetching the catalogue from the server</EmptyState>
</div></div>
)
}
if (bundlesQuery.isError || tagsQuery.isError) {
return (
<div className="card"><div className="card-body">
<EmptyState icon="alert" title="Couldnt load permissions">
{friendlyAuthError(bundlesQuery.error ?? tagsQuery.error, 'The server did not return the permission catalogue.')}
{' '}This tab needs the <code>rbac_users.view</code> permission.
</EmptyState>
</div></div>
)
}
if (!bundle) {
return (
<div className="card"><div className="card-body">
<EmptyState icon="lock" title="No permission bundles">Bundles are seeded server-side.</EmptyState>
</div></div>
)
}
const holders = holdersByBundle.get(bundle.id) ?? []
const bundleRow = (b) => {
const held = holdersByBundle.get(b.id) ?? []
return (
<div
key={b.id}
className={`role-item${b.id === bundle.id ? ' active' : ''}`}
style={{ padding: '8px 12px 8px 22px', gap: 10 }}
onClick={() => setBundleId(b.id)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setBundleId(b.id) }
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="fw-600 text-sm">{b.name}</div>
<div className="cell-sub">
{b.permission_tags?.length ?? 0} tags
{held.length ? ` · ${held.length} role${held.length > 1 ? 's' : ''}` : ' · unused'}
</div>
</div>
</div>
)
}
return (
<div className="rbac-layout">
<div className="card" style={{ alignSelf: 'start' }}>
<div className="card-body" style={{ padding: 12 }}>
<div className="nav-section-label" style={{ padding: '6px 8px' }}>
Bundles · {bundles.length}
</div>
<div className="form-field" style={{ padding: '0 8px 10px' }}>
<input
placeholder="Filter bundles…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
aria-label="Filter permission bundles"
/>
</div>
<div className="role-list">
{/* Filtering flattens the tree — a hit inside a collapsed group would
otherwise be invisible. */}
{matches
? (matches.length
? matches.map(bundleRow)
: <p className="text-muted text-sm" style={{ padding: '8px 10px' }}>No bundle matches that filter.</p>)
: groups.map((g) => {
const isOpen = open === g.key
return (
<div key={g.key}>
<div
className="role-item"
onClick={() => 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) }
}}
>
<span className="role-badge" style={{ background: 'var(--bg-sunken)', color: 'var(--text-2)' }}>
<Icon name={g.icon} />
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="fw-600 text-sm">{g.label}</div>
<div className="cell-sub">{g.items.length} bundles</div>
</div>
<Icon name={isOpen ? 'chevron-down' : 'chevron-right'} />
</div>
{isOpen && g.items.map(bundleRow)}
</div>
)
})}
</div>
</div>
</div>
<div className="card">
<div className="card-head">
<div className="flex items-center gap-12">
<span className="role-badge" style={{ background: 'var(--primary-soft)', color: 'var(--primary)' }}>
<Icon name={MODULE_ICONS[activeKey] ?? 'layers'} />
</span>
<div>
<h3>{bundle.name}</h3>
<span className="ch-sub">{bundle.description || 'No description'}</span>
</div>
</div>
<div className="flex items-center gap-8">
{save.isPending && <span className="cell-sub">Saving</span>}
<span className="badge b-gray badge-plain">{granted.size} / {tags.length}</span>
</div>
</div>
<div className="card-body" style={{ paddingBottom: 0 }}>
<p className="text-muted text-sm" style={{ marginBottom: 12 }}>
<Icon name="lock" />{' '}
{holders.length ? (
<>
Held by <b>{holders.length} role{holders.length > 1 ? 's' : ''}</b> {holders.join(', ')}.
Each gains or loses access on its next request.
</>
) : (
<>No role holds this bundle, so edits here change nobodys access yet.</>
)}
</p>
</div>
<div className="table-wrap">
<table className="rbac-matrix">
<thead>
<tr>
<th>Module</th>
{actions.map((a) => <th key={a}>{humaniseSlug(a)}</th>)}
<th />
</tr>
</thead>
<tbody>
{modules.map((m) => {
const ids = actions.map((a) => byCell.get(`${m}.${a}`)).filter(Boolean)
const on = ids.filter((id) => granted.has(id)).length
return (
<tr key={m}>
<td>
{humaniseSlug(m)}
<div className="cell-sub">{on} of {ids.length}</div>
</td>
{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 <td key={a} className="text-muted"></td>
return (
<td key={a}>
<label className="switch">
<input
type="checkbox"
checked={granted.has(tagId)}
disabled={save.isPending}
onChange={() => toggle(tagId)}
aria-label={`${m}.${a}`}
/>
<span className="switch-track" />
</label>
</td>
)
})}
<td>
<button
className="btn btn-ghost btn-sm"
disabled={save.isPending}
onClick={() => setRow(m, on < ids.length)}
>
{on < ids.length ? 'All' : 'None'}
</button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
</div>
)
}
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 <div className="card"><div className="card-body"><EmptyState icon="bell" title="Loading…">Fetching notification preferences.</EmptyState></div></div>
}
if (query.isError) {
return (
<div className="card"><div className="card-body">
<EmptyState icon="bell" title="Couldnt load preferences">
{friendlyAuthError(query.error, 'This tab needs settings.view.')}
</EmptyState>
</div></div>
)
}
return (
<div className="card">
<div className="card-body">
<h3 className="form-section-title" style={{ marginTop: 0 }}>Email Notifications</h3>
<ToggleRow title="New applications" desc="Get notified when a candidate applies" checked={draft['notifications.email_new_applications']} onChange={(v) => setField('notifications.email_new_applications', v)} />
<ToggleRow title="Interview reminders" desc="Reminders 30 minutes before interviews" checked={draft['notifications.email_interview_reminders']} onChange={(v) => setField('notifications.email_interview_reminders', v)} />
<ToggleRow title="Offer responses" desc="When candidates accept or decline offers" checked={draft['notifications.email_offer_responses']} onChange={(v) => setField('notifications.email_offer_responses', v)} />
<ToggleRow title="Weekly digest" desc="A summary of hiring activity every Monday" checked={draft['notifications.email_weekly_digest']} onChange={(v) => setField('notifications.email_weekly_digest', v)} />
<h3 className="form-section-title">In-App Notifications</h3>
<ToggleRow title="Mentions" desc="When a teammate @mentions you" checked={draft['notifications.inapp_mentions']} onChange={(v) => setField('notifications.inapp_mentions', v)} />
<ToggleRow title="Stage changes" desc="When a candidate moves stages" checked={draft['notifications.inapp_stage_changes']} onChange={(v) => setField('notifications.inapp_stage_changes', v)} />
<ToggleRow title="Task assignments" desc="When you are assigned a task" checked={draft['notifications.inapp_task_assignments']} onChange={(v) => setField('notifications.inapp_task_assignments', v)} />
</div>
</div>
)
}
function EmailTemplates() {
return (
<div className="card">
<div className="card-body">
<EmptyState icon="mail" title="Email templates not wired">
Template CRUD exists on the backend but is out of scope for this wiring pass.
Use Access Control / org settings for other configuration.
</EmptyState>
</div>
</div>
)
}
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 <div className="card"><div className="card-body"><EmptyState icon="settings" title="Loading…">Fetching career portal settings.</EmptyState></div></div>
}
if (query.isError) {
return (
<div className="card"><div className="card-body">
<EmptyState icon="settings" title="Couldnt load settings">
{friendlyAuthError(query.error, 'This tab needs settings.view.')}
</EmptyState>
</div></div>
)
}
return (
<div className="card">
<div className="card-body">
<div className="form-grid">
<div className="form-field col-span-2">
<label>Careers Page URL</label>
<input value={draft['career_portal.url'] ?? ''} onChange={(e) => setField('career_portal.url', e.target.value)} />
</div>
<div className="form-field">
<label>Page Headline</label>
<input value={draft['career_portal.headline'] ?? ''} onChange={(e) => setField('career_portal.headline', e.target.value)} />
</div>
<div className="form-field">
<label>Primary CTA Text</label>
<input value={draft['career_portal.cta'] ?? ''} onChange={(e) => setField('career_portal.cta', e.target.value)} />
</div>
</div>
<div className="divider" />
<ToggleRow title="Public job board" desc="Make open roles visible to the public" checked={draft['career_portal.public_job_board']} onChange={(v) => setField('career_portal.public_job_board', v)} />
<ToggleRow title="Allow one-click apply" desc="Let candidates apply with LinkedIn" checked={draft['career_portal.one_click_apply']} onChange={(v) => setField('career_portal.one_click_apply', v)} />
<ToggleRow title="Show salary ranges" desc="Display compensation on job listings" checked={draft['career_portal.show_salary']} onChange={(v) => setField('career_portal.show_salary', v)} />
<ToggleRow title="Enable referrals" desc="Employees can refer candidates" checked={draft['career_portal.enable_referrals']} onChange={(v) => setField('career_portal.enable_referrals', v)} />
</div>
</div>
)
}
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 <div className="card"><div className="card-body"><EmptyState icon="settings" title="Loading…">Fetching branding settings.</EmptyState></div></div>
}
if (query.isError) {
return (
<div className="card"><div className="card-body">
<EmptyState icon="settings" title="Couldnt load settings">
{friendlyAuthError(query.error, 'This tab needs settings.view.')}
</EmptyState>
</div></div>
)
}
return (
<div className="card">
<div className="card-body">
<div className="setting-row">
<div className="setting-info"><h4>Brand Color</h4><p>Primary accent across the portal (persisted; not yet applied globally)</p></div>
<div className="flex items-center gap-8">
{colors.map((c) => (
<span
key={c}
role="button"
tabIndex={0}
style={{
width: 28, height: 28, borderRadius: 8, background: c, cursor: 'pointer',
border: draft['branding.primary_color'] === c ? '2px solid var(--primary)' : '2px solid var(--border)',
}}
onClick={() => setField('branding.primary_color', c)}
onKeyDown={(e) => { if (e.key === 'Enter') setField('branding.primary_color', c) }}
/>
))}
</div>
</div>
<div className="form-grid" style={{ marginTop: 16 }}>
<div className="form-field">
<label>Email Footer</label>
<input value={draft['branding.email_footer'] ?? ''} onChange={(e) => setField('branding.email_footer', e.target.value)} />
</div>
<div className="form-field">
<label>Support Email</label>
<input value={draft['branding.support_email'] ?? ''} onChange={(e) => setField('branding.support_email', e.target.value)} />
</div>
</div>
</div>
</div>
)
}
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 <div className="card"><div className="card-body"><EmptyState icon="shield" title="Loading…">Fetching security settings.</EmptyState></div></div>
}
if (query.isError) {
return (
<div className="card"><div className="card-body">
<EmptyState icon="shield" title="Couldnt load settings">
{friendlyAuthError(query.error, 'This tab needs settings.view.')}
</EmptyState>
</div></div>
)
}
return (
<div className="card">
<div className="card-body">
<div className="alert alert-danger" style={{ marginBottom: 16 }}>
<b>Not yet enforced.</b> 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.
</div>
<ToggleRow title="Two-factor authentication" desc="Require 2FA for all team members" checked={draft['security.two_factor_enabled']} onChange={(v) => setField('security.two_factor_enabled', v)} />
<ToggleRow title="Single Sign-On (SSO)" desc="Enable SAML-based SSO login" checked={draft['security.sso_enabled']} onChange={(v) => setField('security.sso_enabled', v)} />
<ToggleRow title="IP allowlist" desc="Restrict access to approved IP ranges" checked={draft['security.ip_allowlist']} onChange={(v) => setField('security.ip_allowlist', v)} />
<ToggleRow title="Audit logging" desc="Track all data access and changes" checked={draft['security.audit_logging']} onChange={(v) => setField('security.audit_logging', v)} />
<div className="form-grid" style={{ marginTop: 16 }}>
<div className="form-field">
<label>Session Timeout</label>
<select value={draft['security.session_timeout'] ?? ''} onChange={(e) => setField('security.session_timeout', e.target.value)}>
<option>30 minutes</option><option>1 hour</option><option>8 hours</option>
</select>
</div>
<div className="form-field">
<label>Password Policy</label>
<select value={draft['security.password_policy'] ?? ''} onChange={(e) => setField('security.password_policy', e.target.value)}>
<option>Strong (12+ chars)</option><option>Medium (8+ chars)</option>
</select>
</div>
</div>
<div className="divider" />
<div className="setting-row">
<div className="setting-info"><h4>Data Retention</h4><p>Auto-delete candidate data after set period</p></div>
<select className="select" value={draft['security.data_retention_months'] ?? ''} onChange={(e) => setField('security.data_retention_months', e.target.value)}>
<option>24 months</option><option>12 months</option><option>36 months</option>
</select>
</div>
</div>
</div>
)
}
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 (
<div className="card">
<div className="card-body">
<h3 className="form-section-title" style={{ marginTop: 0 }}>Theme</h3>
<div className="grid g-3" style={{ marginBottom: 8 }}>
<div className="card theme-opt" style={{ cursor: 'pointer', overflow: 'hidden' }} onClick={() => pick('light')}>
<div style={{ height: 80, background: '#f1f7f4', borderBottom: '1px solid var(--border)', display: 'flex' }}>
<div style={{ width: '30%', background: '#1a3134' }} />
<div style={{ flex: 1, padding: 10 }}>
<div style={{ height: 8, background: '#fff', borderRadius: 4, marginBottom: 6 }} />
<div style={{ height: 8, width: '45%', background: '#004d43', borderRadius: 4 }} />
</div>
</div>
<div className="card-body" style={{ padding: 12 }}>
<div className="fw-600">Light</div><div className="lr-sub">Clean and bright</div>
</div>
</div>
<div className="card theme-opt" style={{ cursor: 'pointer', overflow: 'hidden' }} onClick={() => pick('dark')}>
<div style={{ height: 80, background: '#0e1d1f', borderBottom: '1px solid var(--border)', display: 'flex' }}>
<div style={{ width: '30%', background: '#0a1618' }} />
<div style={{ flex: 1, padding: 10 }}>
<div style={{ height: 8, background: '#24403f', borderRadius: 4, marginBottom: 6 }} />
<div style={{ height: 8, width: '45%', background: '#ceff71', borderRadius: 4 }} />
</div>
</div>
<div className="card-body" style={{ padding: 12 }}>
<div className="fw-600">Dark</div><div className="lr-sub">Easy on the eyes</div>
</div>
</div>
<div className="card theme-opt" style={{ cursor: 'pointer', overflow: 'hidden' }} onClick={() => pick('system')}>
<div style={{ height: 80, background: 'linear-gradient(90deg,#f1f7f4 50%,#0e1d1f 50%)', borderBottom: '1px solid var(--border)' }} />
<div className="card-body" style={{ padding: 12 }}>
<div className="fw-600">System</div><div className="lr-sub">Match OS setting</div>
</div>
</div>
</div>
</div>
</div>
)
}