Merge pull request 'Roles: consolidate the hand-made Manager role; Title Case role names' (#63) from Talha into main
Deploy to S3 / deploy (push) Successful in 31s Details

pull/64/head^2
talha.ahmed 2026-09-02 17:11:11 +00:00
commit 2400f0fef8
7 changed files with 63 additions and 16 deletions

View File

@ -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
);

View File

@ -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)

View File

@ -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(' ')
}

View File

@ -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'
@ -506,7 +507,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',
@ -636,7 +637,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>

View File

@ -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}

View File

@ -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>

View File

@ -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)
}
}