Roles: consolidate the hand-made Manager role; Title Case role names
Migration 027 moves live members of the UI-created Manager role onto the seeded hiring_manager (which has carried the same manager_candidates bundle since 024) and soft-deletes it - guarded and idempotent, the seeded role is never matched. Access Control no longer lists candidate: it is the applicant account type every candidate user sits on, managed nowhere near a permission matrix. The row stays in the DB. New lib/format.js formatRole renders snake_case role names and ALL-CAPS status enums as Title Case everywhere users see them - Access Control, Settings user badges and role picker, the topbar profile, Candidates role column and export, and the inbox application-status badges (INTERVIEW -> Interview). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>pull/63/head
parent
b7700b766a
commit
e7752b94e6
|
|
@ -0,0 +1,29 @@
|
|||
-- 027: The hand-made Access Control role "Manager" duplicates the seeded
|
||||
-- hiring_manager, which has carried the manager_candidates bundle since 024.
|
||||
-- Consolidate: move its live members onto hiring_manager, then soft-delete
|
||||
-- it. Both steps are idempotent and guarded; the seeded hiring_manager row
|
||||
-- is never matched (name check excludes it, and it is is_system).
|
||||
UPDATE app.users u
|
||||
SET role_id = hm.id,
|
||||
updated_at = NOW()
|
||||
FROM app.roles hm
|
||||
WHERE hm.role_name = 'hiring_manager' AND hm.is_deleted = FALSE
|
||||
AND u.role_id IN (
|
||||
SELECT r.id FROM app.roles r
|
||||
WHERE lower(r.role_name) = 'manager'
|
||||
AND r.role_name <> 'hiring_manager'
|
||||
AND r.is_deleted = FALSE
|
||||
)
|
||||
AND COALESCE(u.is_deleted, FALSE) = FALSE;
|
||||
|
||||
UPDATE app.roles r
|
||||
SET is_deleted = TRUE,
|
||||
is_active = FALSE,
|
||||
updated_at = NOW()
|
||||
WHERE lower(r.role_name) = 'manager'
|
||||
AND r.role_name <> 'hiring_manager'
|
||||
AND r.is_deleted = FALSE
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM app.users u
|
||||
WHERE u.role_id = r.id AND COALESCE(u.is_deleted, FALSE) = FALSE
|
||||
);
|
||||
|
|
@ -10,6 +10,7 @@ import { useTheme } from '../theme/ThemeProvider'
|
|||
import { useAuth } from '../auth/AuthContext'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import { formatRole } from '../lib/format'
|
||||
import * as notificationsApi from '../api/notifications'
|
||||
|
||||
function initialsFromName(name) {
|
||||
|
|
@ -55,7 +56,7 @@ export default function Topbar({ onOpenNav, searchRef }) {
|
|||
|
||||
const name = user?.name || 'Guest'
|
||||
const email = user?.email || ''
|
||||
const role = user?.role_name || user?.role || 'Member'
|
||||
const role = formatRole(user?.role_name || user?.role) || 'Member'
|
||||
|
||||
function openNotif(n) {
|
||||
if (n.unread) markOne.mutate(n.id)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
/** Display formatting for identifiers stored snake_case in the DB. */
|
||||
|
||||
/** 'system_administrator' → 'System Administrator', 'INTERVIEW' → 'Interview'. */
|
||||
export function formatRole(name) {
|
||||
const s = String(name || '').trim()
|
||||
if (!s) return s
|
||||
return s
|
||||
.split(/[_\s]+/)
|
||||
.map((w) => (w ? w[0].toUpperCase() + w.slice(1).toLowerCase() : w))
|
||||
.join(' ')
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ import CandidateProfile from './CandidateProfile'
|
|||
import { useJobTitles } from './ScoredCandidateProfile'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { exportStyledXlsx } from '../lib/exportXlsx'
|
||||
import { formatRole } from '../lib/format'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as candidatesApi from '../api/candidates'
|
||||
import * as jobPostsApi from '../api/jobPosts'
|
||||
|
|
@ -460,7 +461,7 @@ function RecruiterCandidates() {
|
|||
{ header: 'Account', key: 'account', width: 12 },
|
||||
],
|
||||
rows: rows.map((c) => ({
|
||||
name: c.name, email: c.email, role: c.roleName,
|
||||
name: c.name, email: c.email, role: formatRole(c.roleName),
|
||||
applied: c.applied ? c.applied.toLocaleDateString() : '',
|
||||
source: c.source,
|
||||
account: c.isActive ? 'Active' : 'Unconfirmed',
|
||||
|
|
@ -574,7 +575,7 @@ function RecruiterCandidates() {
|
|||
<Badge className="b-gray" style={{ marginLeft: 6, fontSize: 10 }}>Form</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="cell-sub">{c.roleName ?? '—'}</div>
|
||||
<div className="cell-sub">{formatRole(c.roleName) || '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { useAuth } from '../auth/AuthContext'
|
|||
import { seedQuery, useSeedMutation } from '../data/seedQueries'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { exportStyledXlsx } from '../lib/exportXlsx'
|
||||
import { formatRole } from '../lib/format'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as inboxApi from '../api/inbox'
|
||||
import * as sheetApi from '../api/sheet'
|
||||
|
|
@ -1327,7 +1328,7 @@ export default function Inbox() {
|
|||
<div className="ii-meta">
|
||||
<SourceChip item={i} /> <Badge>{i.processing}</Badge>
|
||||
{i.applicationStatus && i.applicationStatus !== 'CLOSED' && (
|
||||
<Badge>{i.applicationStatus}</Badge>
|
||||
<Badge>{formatRole(i.applicationStatus)}</Badge>
|
||||
)}
|
||||
{i.kind === 'form' && i.residingCity && (
|
||||
<span className="cell-sub">{i.residingCity}</span>
|
||||
|
|
@ -1924,7 +1925,7 @@ function ApplicationDetail({
|
|||
<SourceChip item={i} /> <Badge>{i.processing}</Badge>{' '}
|
||||
{i.duplicate && <><Badge className="b-red">Duplicate</Badge>{' '}</>}
|
||||
{i.applicationStatus && i.applicationStatus !== 'CLOSED' && (
|
||||
<><Badge>{i.applicationStatus}</Badge>{' '}</>
|
||||
<><Badge>{formatRole(i.applicationStatus)}</Badge>{' '}</>
|
||||
)}
|
||||
<Badge className={i.resumeStatus === 'Parsed' ? 'b-green' : i.resumeStatus === 'Failed' ? 'b-red' : 'b-amber'}>
|
||||
{i.resumeStatus}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import { useToast } from '../ui/Toast'
|
|||
import { useFormState } from '../components/AuthLayout'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import { formatRole } from '../lib/format'
|
||||
import * as rolesApi from '../api/roles'
|
||||
|
||||
const ROLE_COLORS = ['var(--av-1)', 'var(--av-2)', 'var(--av-3)', 'var(--av-4)', 'var(--av-5)', 'var(--av-6)', 'var(--av-7)', 'var(--av-8)']
|
||||
|
|
@ -69,7 +70,9 @@ export default function Rbac() {
|
|||
queryFn: () => rolesApi.listPermissions().then((r) => r.data ?? []),
|
||||
})
|
||||
|
||||
const roles = rolesQuery.data ?? []
|
||||
// `candidate` is the applicant account type, not a staff role — it stays in
|
||||
// the DB (every candidate user sits on it) but is not managed on this screen.
|
||||
const roles = (rolesQuery.data ?? []).filter((r) => r.role_name !== 'candidate')
|
||||
const tags = tagsQuery.data ?? []
|
||||
const bundles = bundlesQuery.data ?? []
|
||||
|
||||
|
|
@ -178,7 +181,7 @@ export default function Rbac() {
|
|||
<Icon name="shield" />
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="fw-600 text-sm">{r.role_name}</div>
|
||||
<div className="fw-600 text-sm">{formatRole(r.role_name)}</div>
|
||||
<div className="cell-sub">
|
||||
{(r.effective_permissions?.length ?? 0)} permissions
|
||||
{r.is_system ? ' · system' : ''}
|
||||
|
|
@ -199,7 +202,7 @@ export default function Rbac() {
|
|||
<Icon name="shield" />
|
||||
</span>
|
||||
<div>
|
||||
<h3>{role.role_name}</h3>
|
||||
<h3>{formatRole(role.role_name)}</h3>
|
||||
<span className="ch-sub">{role.description || 'No description'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -300,7 +303,7 @@ export default function Rbac() {
|
|||
{editing && (
|
||||
<RoleForm
|
||||
title="Edit Role"
|
||||
subtitle={editing.role_name}
|
||||
subtitle={formatRole(editing.role_name)}
|
||||
role={editing}
|
||||
bundles={bundles}
|
||||
bundlesLoading={bundlesQuery.isPending}
|
||||
|
|
@ -313,7 +316,7 @@ export default function Rbac() {
|
|||
{confirmDelete && (
|
||||
<Modal
|
||||
title="Delete role"
|
||||
subtitle={confirmDelete.role_name}
|
||||
subtitle={formatRole(confirmDelete.role_name)}
|
||||
onClose={() => setConfirmDelete(null)}
|
||||
footer={
|
||||
<>
|
||||
|
|
@ -331,7 +334,7 @@ export default function Rbac() {
|
|||
}
|
||||
>
|
||||
<p>
|
||||
<b>{confirmDelete.role_name}</b> will be soft-deleted. Anyone currently holding it keeps the
|
||||
<b>{formatRole(confirmDelete.role_name)}</b> will be soft-deleted. Anyone currently holding it keeps the
|
||||
account but loses every permission the role granted, so reassign them first.
|
||||
</p>
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { useFormState } from '../components/AuthLayout'
|
|||
import { usePermission } from '../auth/AuthContext'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import { formatRole } from '../lib/format'
|
||||
import * as rolesApi from '../api/roles'
|
||||
import * as usersApi from '../api/users'
|
||||
import * as orgSettingsApi from '../api/orgSettings'
|
||||
|
|
@ -324,7 +325,7 @@ function Users() {
|
|||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><Badge className="b-indigo">{u.role_name || 'No role'}</Badge></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'}
|
||||
|
|
@ -425,7 +426,7 @@ function Approvals() {
|
|||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><Badge className="b-indigo">{u.role_name || 'No role'}</Badge></td>
|
||||
<td><Badge className="b-indigo">{formatRole(u.role_name) || 'No role'}</Badge></td>
|
||||
<td className="text-muted">{u.created_at ? String(u.created_at).slice(0, 10) : '—'}</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<button
|
||||
|
|
@ -495,7 +496,7 @@ function AssignRoleModal({ user, users, onClose }) {
|
|||
toast(
|
||||
clearing
|
||||
? `Role removed from ${target.name}`
|
||||
: `${target.name} is now ${picked?.role_name ?? 'assigned'}`,
|
||||
: `${target.name} is now ${formatRole(picked?.role_name) || 'assigned'}`,
|
||||
'success',
|
||||
)
|
||||
onClose()
|
||||
|
|
@ -558,7 +559,7 @@ function AssignRoleModal({ user, users, onClose }) {
|
|||
>
|
||||
<option value="">No role</option>
|
||||
{roles.map((r) => (
|
||||
<option key={r.id} value={r.id}>{r.role_name}</option>
|
||||
<option key={r.id} value={r.id}>{formatRole(r.role_name)}</option>
|
||||
))}
|
||||
</select>
|
||||
<FieldError>
|
||||
|
|
@ -641,7 +642,7 @@ function Permissions() {
|
|||
for (const r of roles) {
|
||||
for (const id of r.permissions ?? []) {
|
||||
const list = map.get(Number(id)) ?? []
|
||||
list.push(r.role_name)
|
||||
list.push(formatRole(r.role_name))
|
||||
map.set(Number(id), list)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue