1222 lines
48 KiB
JavaScript
1222 lines
48 KiB
JavaScript
/* ============================================================
|
||
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': '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 <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="Couldn’t 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>201–500</option><option>51–200</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="Couldn’t 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="Couldn’t 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 user’s 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="Couldn’t 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 nobody’s 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="Couldn’t 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="Couldn’t 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="Couldn’t 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="Couldn’t 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>
|
||
)
|
||
}
|