820 lines
33 KiB
JavaScript
820 lines
33 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). Roles/Permissions remain
|
||
chrome; Access Control is the authoritative RBAC surface.
|
||
============================================================ */
|
||
|
||
import { useEffect, useRef, useState } from 'react'
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||
|
||
import Modal from '../ui/Modal'
|
||
import { Tabs } from '../ui/Tabs'
|
||
import { Avatar, Badge, EmptyState, FieldError, Icon } from '../ui/primitives'
|
||
import { useToast } from '../ui/Toast'
|
||
import { useTheme } from '../theme/ThemeProvider'
|
||
import { useFormState } from '../components/AuthLayout'
|
||
import { usePermission } from '../auth/AuthContext'
|
||
import { qk } from '../lib/queryKeys'
|
||
import { friendlyAuthError } from '../lib/errors'
|
||
import * as rolesApi from '../api/roles'
|
||
import * as usersApi from '../api/users'
|
||
import * as orgSettingsApi from '../api/orgSettings'
|
||
import { roles as seedRoles } from '../data/seed'
|
||
|
||
const TABS = [
|
||
'General', 'Users', 'Roles', 'Permissions', 'Notifications',
|
||
'Email Templates', 'Career Portal', 'Branding', 'Security', 'Appearance',
|
||
]
|
||
|
||
const ORG_TABS = new Set(['General', 'Notifications', 'Career Portal', 'Branding', 'Security'])
|
||
|
||
const CATEGORY_BY_TAB = {
|
||
General: 'general',
|
||
Notifications: 'notifications',
|
||
'Career Portal': 'career_portal',
|
||
Branding: 'branding',
|
||
Security: 'security',
|
||
}
|
||
|
||
function ToggleRow({ title, desc, checked, onChange, disabled }) {
|
||
return (
|
||
<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)
|
||
|
||
return (
|
||
<div className="page">
|
||
<div className="page-head">
|
||
<div>
|
||
<h1 className="page-title">Settings</h1>
|
||
<p className="page-sub">Configure your workspace and team preferences</p>
|
||
</div>
|
||
<div className="page-head-actions">
|
||
{showOrgSave && (
|
||
<button
|
||
className="btn btn-primary"
|
||
disabled={!canConfigure || save.isPending}
|
||
onClick={() => save.mutate()}
|
||
>
|
||
<Icon name="check" /> {save.isPending ? 'Saving…' : 'Save Changes'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<Tabs value={tab} onChange={setTab} tabs={TABS.map((t) => ({ key: t, label: t }))} />
|
||
|
||
<div className="tab-pane active">
|
||
{tab === 'General' && <General registerSave={(fn) => { saveRef.current = fn }} />}
|
||
{tab === 'Users' && <Users />}
|
||
{tab === 'Roles' && <Roles />}
|
||
{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>
|
||
)
|
||
}
|
||
|
||
function General({ registerSave }) {
|
||
const defaults = {
|
||
'general.company_name': 'Utopia Brands Inc.',
|
||
'general.website': 'https://utopiabrands.com',
|
||
'general.industry': 'Consumer Goods',
|
||
'general.company_size': '201–500',
|
||
'general.timezone': '(GMT-08:00) Pacific Time',
|
||
'general.currency': 'USD ($)',
|
||
'general.auto_archive_stale_jobs': true,
|
||
'general.duplicate_detection': true,
|
||
}
|
||
const { query, draft, setField, toPayload } = useOrgDraft(CATEGORY_BY_TAB.General, defaults)
|
||
|
||
useEffect(() => {
|
||
registerSave?.(() => orgSettingsApi.update(toPayload()))
|
||
})
|
||
|
||
if (query.isPending) {
|
||
return <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)}>
|
||
<option>(GMT-08:00) Pacific Time</option>
|
||
<option>(GMT-05:00) Eastern Time</option>
|
||
<option>(GMT+00:00) UTC</option>
|
||
</select>
|
||
</div>
|
||
<div className="form-field">
|
||
<label>Default Currency</label>
|
||
<select value={draft['general.currency'] ?? ''} onChange={(e) => setField('general.currency', e.target.value)}>
|
||
<option>USD ($)</option><option>EUR (€)</option><option>GBP (£)</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()
|
||
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>
|
||
<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">
|
||
<div className="empty-state">
|
||
<Icon name="alert" />
|
||
<h3>Couldn’t load users</h3>
|
||
<p>
|
||
{friendlyAuthError(usersQuery.error, 'The server did not return the user list.')}
|
||
{' '}This tab needs the <code>rbac_users.view</code> permission.
|
||
</p>
|
||
</div>
|
||
</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>
|
||
<div className="cell-primary">{u.name}</div>
|
||
<div className="cell-sub">{u.email}</div>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td><Badge className="b-indigo">{u.role_name || 'No role'}</Badge></td>
|
||
<td><Badge>{u.is_active ? 'Active' : 'Pending'}</Badge></td>
|
||
<td className="text-muted">{u.created_at ? String(u.created_at).slice(0, 10) : '—'}</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 AssignRoleModal({ user, users, onClose }) {
|
||
const { toast } = useToast()
|
||
const { can } = usePermission()
|
||
const qc = useQueryClient()
|
||
|
||
const form = useFormState({
|
||
user_id: String(user.id),
|
||
role_id: user.role_id == null ? '' : String(user.role_id),
|
||
})
|
||
|
||
const rolesQuery = useQuery({
|
||
queryKey: qk.roles.list(),
|
||
queryFn: () => rolesApi.listRoles().then((r) => r.data ?? []),
|
||
})
|
||
|
||
const roles = (rolesQuery.data ?? []).filter((r) => r.is_active)
|
||
|
||
const target = users.find((u) => String(u.id) === form.values.user_id) ?? user
|
||
const currentRoleId = target.role_id == null ? '' : String(target.role_id)
|
||
const clearing = form.values.role_id === ''
|
||
const dirty = form.values.role_id !== currentRoleId
|
||
|
||
function pickUser(id) {
|
||
const next = users.find((u) => String(u.id) === id)
|
||
form.setValues({
|
||
user_id: id,
|
||
role_id: next?.role_id == null ? '' : String(next.role_id),
|
||
})
|
||
}
|
||
|
||
const save = useMutation({
|
||
mutationFn: () =>
|
||
clearing
|
||
? usersApi.removeRole(form.values.user_id)
|
||
: usersApi.assignRole(form.values.user_id, Number(form.values.role_id)),
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: qk.users.all() })
|
||
const picked = roles.find((r) => String(r.id) === form.values.role_id)
|
||
toast(
|
||
clearing
|
||
? `Role removed from ${target.name}`
|
||
: `${target.name} is now ${picked?.role_name ?? 'assigned'}`,
|
||
'success',
|
||
)
|
||
onClose()
|
||
},
|
||
onError: (err) => toast(friendlyAuthError(err, 'Could not update the role.'), 'error'),
|
||
})
|
||
|
||
const busy = save.isPending
|
||
|
||
function submit() {
|
||
if (!dirty) {
|
||
onClose()
|
||
return
|
||
}
|
||
save.mutate()
|
||
}
|
||
|
||
return (
|
||
<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}>{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>
|
||
)
|
||
}
|
||
|
||
function Roles() {
|
||
const { toast } = useToast()
|
||
return (
|
||
<div className="card">
|
||
<div className="card-head">
|
||
<div><h3>Roles</h3><span className="ch-sub">Define access levels — use Access Control for live RBAC</span></div>
|
||
<button className="btn btn-primary btn-sm" onClick={() => toast('Use Access Control to manage roles', 'info')}>
|
||
<Icon name="plus" /> Add Role
|
||
</button>
|
||
</div>
|
||
<div className="card-body">
|
||
<div className="list-tight">
|
||
{seedRoles.map((r) => (
|
||
<div className="list-row" key={r.name}>
|
||
<span className="kpi-icn i-purple" style={{ width: 40, height: 40, borderRadius: 11 }}>
|
||
<Icon name="users" />
|
||
</span>
|
||
<div className="lr-main"><div className="lr-title">{r.name}</div><div className="lr-sub">{r.desc}</div></div>
|
||
<div className="lr-right"><div className="fw-600">{r.users} users</div><div className="lr-sub">{r.perms}</div></div>
|
||
<button className="act-btn" onClick={() => toast(`Editing ${r.name} role`, 'info')}>
|
||
<Icon name="edit" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function Permissions() {
|
||
const modules = ['Jobs', 'Candidates', 'Interviews', 'Offers', 'Reports', 'Settings']
|
||
const perms = ['View', 'Create', 'Edit', 'Delete']
|
||
const { toast } = useToast()
|
||
return (
|
||
<div className="card">
|
||
<div className="card-head">
|
||
<div><h3>Permission Matrix</h3><span className="ch-sub">Recruiter role</span></div>
|
||
<select className="select"><option>Recruiter</option><option>Hiring Manager</option><option>Administrator</option></select>
|
||
</div>
|
||
<p className="text-muted text-sm" style={{ padding: '0 18px' }}>
|
||
This is a simplified view. The authoritative matrix — 13 modules × 8 actions, resolved from the
|
||
server — lives on <b>Access Control</b>.
|
||
</p>
|
||
<div className="table-wrap">
|
||
<table className="data">
|
||
<thead>
|
||
<tr><th>Module</th>{perms.map((p) => <th style={{ textAlign: 'center' }} key={p}>{p}</th>)}</tr>
|
||
</thead>
|
||
<tbody>
|
||
{modules.map((m) => (
|
||
<tr key={m}>
|
||
<td className="cell-primary">{m}</td>
|
||
{perms.map((p) => (
|
||
<td style={{ textAlign: 'center' }} key={p}>
|
||
<label className="switch">
|
||
<input
|
||
type="checkbox"
|
||
defaultChecked={m !== 'Settings'}
|
||
onChange={() => toast('Use Access Control to change permissions', 'info')}
|
||
/>
|
||
<span className="switch-track" />
|
||
</label>
|
||
</td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</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">
|
||
<div className="form-section-title" style={{ marginTop: 0 }}>Email Notifications</div>
|
||
<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)} />
|
||
<div className="form-section-title">In-App Notifications</div>
|
||
<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">
|
||
<div className="form-section-title" style={{ marginTop: 0 }}>Theme</div>
|
||
<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>
|
||
)
|
||
}
|