/* ============================================================
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 (
)
}
function useOrgDraft(category, defaults) {
const query = useQuery({
queryKey: qk.orgSettings.list({ category }),
queryFn: async () => orgSettingsApi.toMap(await orgSettingsApi.list({ category })),
})
const [draft, setDraft] = useState(defaults)
useEffect(() => {
if (!query.data) return
setDraft((prev) => {
const next = { ...prev }
for (const key of Object.keys(defaults)) {
if (query.data[key] !== undefined) next[key] = query.data[key]
}
return next
})
}, [query.data]) // eslint-disable-line react-hooks/exhaustive-deps
function setField(key, value) {
setDraft((d) => ({ ...d, [key]: value }))
}
function toPayload() {
return Object.entries(draft).map(([key, value]) => ({ key, value, category }))
}
return { query, draft, setField, toPayload }
}
export default function Settings() {
const { toast } = useToast()
const { can } = usePermission()
const qc = useQueryClient()
const [tab, setTab] = useState('General')
const saveRef = useRef(null)
const save = useMutation({
mutationFn: async () => {
if (!saveRef.current) return null
return saveRef.current()
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.orgSettings.all() })
toast('Settings saved', 'success')
},
onError: (err) => toast(friendlyAuthError(err, 'Could not save settings.'), 'error'),
})
const canConfigure = can('settings.configure')
const showOrgSave = ORG_TABS.has(tab)
return (
Settings
Configure your workspace and team preferences
{showOrgSave && (
save.mutate()}
>
{save.isPending ? 'Saving…' : 'Save Changes'}
)}
({ key: t, label: t }))} />
{tab === 'General' &&
{ saveRef.current = fn }} />}
{tab === 'Users' && }
{tab === 'Roles' && }
{tab === 'Permissions' && }
{tab === 'Notifications' && { saveRef.current = fn }} />}
{tab === 'Email Templates' && }
{tab === 'Career Portal' && { saveRef.current = fn }} />}
{tab === 'Branding' && { saveRef.current = fn }} />}
{tab === 'Security' && { saveRef.current = fn }} />}
{tab === 'Appearance' && }
)
}
function General({ registerSave }) {
const defaults = {
'general.company_name': 'Utopia Brands Inc.',
'general.website': 'https://utopiabrands.com',
'general.industry': 'Consumer Goods',
'general.company_size': '201–500',
'general.timezone': '(GMT-08:00) Pacific Time',
'general.currency': 'USD ($)',
'general.auto_archive_stale_jobs': true,
'general.duplicate_detection': true,
}
const { query, draft, setField, toPayload } = useOrgDraft(CATEGORY_BY_TAB.General, defaults)
useEffect(() => {
registerSave?.(() => orgSettingsApi.update(toPayload()))
})
if (query.isPending) {
return Fetching organisation settings.
}
if (query.isError) {
return (
{friendlyAuthError(query.error, 'This tab needs settings.view.')}
)
}
return (
Organization Name
setField('general.company_name', e.target.value)} />
Company Website
setField('general.website', e.target.value)} />
Industry
setField('general.industry', e.target.value)}>
Consumer Goods Technology Retail
Company Size
setField('general.company_size', e.target.value)}>
201–500 51–200 500+
Default Time Zone
setField('general.timezone', e.target.value)}>
(GMT-08:00) Pacific Time
(GMT-05:00) Eastern Time
(GMT+00:00) UTC
Default Currency
setField('general.currency', e.target.value)}>
USD ($) EUR (€) GBP (£)
setField('general.auto_archive_stale_jobs', v)}
/>
setField('general.duplicate_detection', v)}
/>
)
}
/** Real data: GET /users/fetch (requires rbac_users.view). */
function Users() {
const { toast } = useToast()
const [editing, setEditing] = useState(null)
const usersQuery = useQuery({
queryKey: qk.users.list(),
queryFn: () => usersApi.list({ top: 50 }).then((r) => r.data ?? []),
})
const users = usersQuery.data ?? []
return (
Team Members
{usersQuery.isPending ? 'Loading…' : `${users.length} users`}
toast('Invite sent', 'success')}>
Invite User
{usersQuery.isError ? (
Couldn’t load users
{friendlyAuthError(usersQuery.error, 'The server did not return the user list.')}
{' '}This tab needs the rbac_users.view permission.
) : (
User Role Status Created
Actions
{users.map((u) => (
{u.role_name || 'No role'}
{u.is_active ? 'Active' : 'Pending'}
{u.created_at ? String(u.created_at).slice(0, 10) : '—'}
setEditing(u)}
aria-label={`Edit ${u.name}`}
>
))}
)}
{editing && (
setEditing(null)} />
)}
)
}
function AssignRoleModal({ user, users, onClose }) {
const { toast } = useToast()
const { can } = usePermission()
const qc = useQueryClient()
const form = useFormState({
user_id: String(user.id),
role_id: user.role_id == null ? '' : String(user.role_id),
})
const rolesQuery = useQuery({
queryKey: qk.roles.list(),
queryFn: () => rolesApi.listRoles().then((r) => r.data ?? []),
})
const roles = (rolesQuery.data ?? []).filter((r) => r.is_active)
const target = users.find((u) => String(u.id) === form.values.user_id) ?? user
const currentRoleId = target.role_id == null ? '' : String(target.role_id)
const clearing = form.values.role_id === ''
const dirty = form.values.role_id !== currentRoleId
function pickUser(id) {
const next = users.find((u) => String(u.id) === id)
form.setValues({
user_id: id,
role_id: next?.role_id == null ? '' : String(next.role_id),
})
}
const save = useMutation({
mutationFn: () =>
clearing
? usersApi.removeRole(form.values.user_id)
: usersApi.assignRole(form.values.user_id, Number(form.values.role_id)),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.users.all() })
const picked = roles.find((r) => String(r.id) === form.values.role_id)
toast(
clearing
? `Role removed from ${target.name}`
: `${target.name} is now ${picked?.role_name ?? 'assigned'}`,
'success',
)
onClose()
},
onError: (err) => toast(friendlyAuthError(err, 'Could not update the role.'), 'error'),
})
const busy = save.isPending
function submit() {
if (!dirty) {
onClose()
return
}
save.mutate()
}
return (
Cancel
{busy ? 'Saving…' : 'Save Changes'}
>
}
>
)
}
function Roles() {
const { toast } = useToast()
return (
Roles Define access levels — use Access Control for live RBAC
toast('Use Access Control to manage roles', 'info')}>
Add Role
{seedRoles.map((r) => (
toast(`Editing ${r.name} role`, 'info')}>
))}
)
}
function Permissions() {
const modules = ['Jobs', 'Candidates', 'Interviews', 'Offers', 'Reports', 'Settings']
const perms = ['View', 'Create', 'Edit', 'Delete']
const { toast } = useToast()
return (
Permission Matrix Recruiter role
Recruiter Hiring Manager Administrator
This is a simplified view. The authoritative matrix — 13 modules × 8 actions, resolved from the
server — lives on Access Control .
)
}
function Notifications({ registerSave }) {
const defaults = {
'notifications.email_new_applications': true,
'notifications.email_interview_reminders': true,
'notifications.email_offer_responses': true,
'notifications.email_weekly_digest': false,
'notifications.inapp_mentions': true,
'notifications.inapp_stage_changes': false,
'notifications.inapp_task_assignments': true,
}
const { query, draft, setField, toPayload } = useOrgDraft(CATEGORY_BY_TAB.Notifications, defaults)
useEffect(() => {
registerSave?.(() => orgSettingsApi.update(toPayload()))
})
if (query.isPending) {
return Fetching notification preferences.
}
if (query.isError) {
return (
{friendlyAuthError(query.error, 'This tab needs settings.view.')}
)
}
return (
Email Notifications
setField('notifications.email_new_applications', v)} />
setField('notifications.email_interview_reminders', v)} />
setField('notifications.email_offer_responses', v)} />
setField('notifications.email_weekly_digest', v)} />
In-App Notifications
setField('notifications.inapp_mentions', v)} />
setField('notifications.inapp_stage_changes', v)} />
setField('notifications.inapp_task_assignments', v)} />
)
}
function EmailTemplates() {
return (
Template CRUD exists on the backend but is out of scope for this wiring pass.
Use Access Control / org settings for other configuration.
)
}
function CareerPortal({ registerSave }) {
const defaults = {
'career_portal.url': 'https://careers.utopiabrands.com',
'career_portal.headline': 'Build the future with us',
'career_portal.cta': 'View Open Roles',
'career_portal.public_job_board': true,
'career_portal.one_click_apply': true,
'career_portal.show_salary': false,
'career_portal.enable_referrals': true,
}
const { query, draft, setField, toPayload } = useOrgDraft(CATEGORY_BY_TAB['Career Portal'], defaults)
useEffect(() => {
registerSave?.(() => orgSettingsApi.update(toPayload()))
})
if (query.isPending) {
return Fetching career portal settings.
}
if (query.isError) {
return (
{friendlyAuthError(query.error, 'This tab needs settings.view.')}
)
}
return (
setField('career_portal.public_job_board', v)} />
setField('career_portal.one_click_apply', v)} />
setField('career_portal.show_salary', v)} />
setField('career_portal.enable_referrals', v)} />
)
}
function Branding({ registerSave }) {
const colors = ['#004d43', '#ceff71', '#25e9a5', '#8e92ff', '#1a3134', '#eafff4']
const defaults = {
'branding.primary_color': '#004d43',
'branding.email_footer': 'Utopia Brands · San Francisco, CA',
'branding.support_email': 'talent@utopiabrands.com',
}
const { query, draft, setField, toPayload } = useOrgDraft(CATEGORY_BY_TAB.Branding, defaults)
useEffect(() => {
registerSave?.(() => orgSettingsApi.update(toPayload()))
})
if (query.isPending) {
return Fetching branding settings.
}
if (query.isError) {
return (
{friendlyAuthError(query.error, 'This tab needs settings.view.')}
)
}
return (
Brand Color Primary accent across the portal (persisted; not yet applied globally)
{colors.map((c) => (
setField('branding.primary_color', c)}
onKeyDown={(e) => { if (e.key === 'Enter') setField('branding.primary_color', c) }}
/>
))}
)
}
function Security({ registerSave }) {
const defaults = {
'security.two_factor_enabled': true,
'security.sso_enabled': false,
'security.ip_allowlist': false,
'security.audit_logging': true,
'security.session_timeout': '30 minutes',
'security.password_policy': 'Strong (12+ chars)',
'security.data_retention_months': '24 months',
}
const { query, draft, setField, toPayload } = useOrgDraft(CATEGORY_BY_TAB.Security, defaults)
useEffect(() => {
registerSave?.(() => orgSettingsApi.update(toPayload()))
})
if (query.isPending) {
return Fetching security settings.
}
if (query.isError) {
return (
{friendlyAuthError(query.error, 'This tab needs settings.view.')}
)
}
return (
Not yet enforced. These controls persist flags only — none of them is wired to
enforcement. The backend today has no 2FA, no SSO, no IP allowlist and no audit log.
Do not read the toggles below as a statement of what is switched on.
setField('security.two_factor_enabled', v)} />
setField('security.sso_enabled', v)} />
setField('security.ip_allowlist', v)} />
setField('security.audit_logging', v)} />
Session Timeout
setField('security.session_timeout', e.target.value)}>
30 minutes 1 hour 8 hours
Password Policy
setField('security.password_policy', e.target.value)}>
Strong (12+ chars) Medium (8+ chars)
Data Retention Auto-delete candidate data after set period
setField('security.data_retention_months', e.target.value)}>
24 months 12 months 36 months
)
}
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 (
)
}