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
Deploy to S3 / deploy (push) Successful in 31s
Details
commit
2400f0fef8
|
|
@ -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 { useAuth } from '../auth/AuthContext'
|
||||||
import { qk } from '../lib/queryKeys'
|
import { qk } from '../lib/queryKeys'
|
||||||
import { friendlyAuthError } from '../lib/errors'
|
import { friendlyAuthError } from '../lib/errors'
|
||||||
|
import { formatRole } from '../lib/format'
|
||||||
import * as notificationsApi from '../api/notifications'
|
import * as notificationsApi from '../api/notifications'
|
||||||
|
|
||||||
function initialsFromName(name) {
|
function initialsFromName(name) {
|
||||||
|
|
@ -55,7 +56,7 @@ export default function Topbar({ onOpenNav, searchRef }) {
|
||||||
|
|
||||||
const name = user?.name || 'Guest'
|
const name = user?.name || 'Guest'
|
||||||
const email = user?.email || ''
|
const email = user?.email || ''
|
||||||
const role = user?.role_name || user?.role || 'Member'
|
const role = formatRole(user?.role_name || user?.role) || 'Member'
|
||||||
|
|
||||||
function openNotif(n) {
|
function openNotif(n) {
|
||||||
if (n.unread) markOne.mutate(n.id)
|
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 { useJobTitles } from './ScoredCandidateProfile'
|
||||||
import { qk } from '../lib/queryKeys'
|
import { qk } from '../lib/queryKeys'
|
||||||
import { exportStyledXlsx } from '../lib/exportXlsx'
|
import { exportStyledXlsx } from '../lib/exportXlsx'
|
||||||
|
import { formatRole } from '../lib/format'
|
||||||
import { friendlyAuthError } from '../lib/errors'
|
import { friendlyAuthError } from '../lib/errors'
|
||||||
import * as candidatesApi from '../api/candidates'
|
import * as candidatesApi from '../api/candidates'
|
||||||
import * as jobPostsApi from '../api/jobPosts'
|
import * as jobPostsApi from '../api/jobPosts'
|
||||||
|
|
@ -506,7 +507,7 @@ function RecruiterCandidates() {
|
||||||
{ header: 'Account', key: 'account', width: 12 },
|
{ header: 'Account', key: 'account', width: 12 },
|
||||||
],
|
],
|
||||||
rows: rows.map((c) => ({
|
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() : '',
|
applied: c.applied ? c.applied.toLocaleDateString() : '',
|
||||||
source: c.source,
|
source: c.source,
|
||||||
account: c.isActive ? 'Active' : 'Unconfirmed',
|
account: c.isActive ? 'Active' : 'Unconfirmed',
|
||||||
|
|
@ -636,7 +637,7 @@ function RecruiterCandidates() {
|
||||||
<Badge className="b-gray" style={{ marginLeft: 6, fontSize: 10 }}>Form</Badge>
|
<Badge className="b-gray" style={{ marginLeft: 6, fontSize: 10 }}>Form</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="cell-sub">{c.roleName ?? '—'}</div>
|
<div className="cell-sub">{formatRole(c.roleName) || '—'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import { useAuth } from '../auth/AuthContext'
|
||||||
import { seedQuery, useSeedMutation } from '../data/seedQueries'
|
import { seedQuery, useSeedMutation } from '../data/seedQueries'
|
||||||
import { qk } from '../lib/queryKeys'
|
import { qk } from '../lib/queryKeys'
|
||||||
import { exportStyledXlsx } from '../lib/exportXlsx'
|
import { exportStyledXlsx } from '../lib/exportXlsx'
|
||||||
|
import { formatRole } from '../lib/format'
|
||||||
import { friendlyAuthError } from '../lib/errors'
|
import { friendlyAuthError } from '../lib/errors'
|
||||||
import * as inboxApi from '../api/inbox'
|
import * as inboxApi from '../api/inbox'
|
||||||
import * as sheetApi from '../api/sheet'
|
import * as sheetApi from '../api/sheet'
|
||||||
|
|
@ -1327,7 +1328,7 @@ export default function Inbox() {
|
||||||
<div className="ii-meta">
|
<div className="ii-meta">
|
||||||
<SourceChip item={i} /> <Badge>{i.processing}</Badge>
|
<SourceChip item={i} /> <Badge>{i.processing}</Badge>
|
||||||
{i.applicationStatus && i.applicationStatus !== 'CLOSED' && (
|
{i.applicationStatus && i.applicationStatus !== 'CLOSED' && (
|
||||||
<Badge>{i.applicationStatus}</Badge>
|
<Badge>{formatRole(i.applicationStatus)}</Badge>
|
||||||
)}
|
)}
|
||||||
{i.kind === 'form' && i.residingCity && (
|
{i.kind === 'form' && i.residingCity && (
|
||||||
<span className="cell-sub">{i.residingCity}</span>
|
<span className="cell-sub">{i.residingCity}</span>
|
||||||
|
|
@ -1924,7 +1925,7 @@ function ApplicationDetail({
|
||||||
<SourceChip item={i} /> <Badge>{i.processing}</Badge>{' '}
|
<SourceChip item={i} /> <Badge>{i.processing}</Badge>{' '}
|
||||||
{i.duplicate && <><Badge className="b-red">Duplicate</Badge>{' '}</>}
|
{i.duplicate && <><Badge className="b-red">Duplicate</Badge>{' '}</>}
|
||||||
{i.applicationStatus && i.applicationStatus !== 'CLOSED' && (
|
{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'}>
|
<Badge className={i.resumeStatus === 'Parsed' ? 'b-green' : i.resumeStatus === 'Failed' ? 'b-red' : 'b-amber'}>
|
||||||
{i.resumeStatus}
|
{i.resumeStatus}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import { useToast } from '../ui/Toast'
|
||||||
import { useFormState } from '../components/AuthLayout'
|
import { useFormState } from '../components/AuthLayout'
|
||||||
import { qk } from '../lib/queryKeys'
|
import { qk } from '../lib/queryKeys'
|
||||||
import { friendlyAuthError } from '../lib/errors'
|
import { friendlyAuthError } from '../lib/errors'
|
||||||
|
import { formatRole } from '../lib/format'
|
||||||
import * as rolesApi from '../api/roles'
|
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)']
|
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 ?? []),
|
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 tags = tagsQuery.data ?? []
|
||||||
const bundles = bundlesQuery.data ?? []
|
const bundles = bundlesQuery.data ?? []
|
||||||
|
|
||||||
|
|
@ -178,7 +181,7 @@ export default function Rbac() {
|
||||||
<Icon name="shield" />
|
<Icon name="shield" />
|
||||||
</span>
|
</span>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<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">
|
<div className="cell-sub">
|
||||||
{(r.effective_permissions?.length ?? 0)} permissions
|
{(r.effective_permissions?.length ?? 0)} permissions
|
||||||
{r.is_system ? ' · system' : ''}
|
{r.is_system ? ' · system' : ''}
|
||||||
|
|
@ -199,7 +202,7 @@ export default function Rbac() {
|
||||||
<Icon name="shield" />
|
<Icon name="shield" />
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<h3>{role.role_name}</h3>
|
<h3>{formatRole(role.role_name)}</h3>
|
||||||
<span className="ch-sub">{role.description || 'No description'}</span>
|
<span className="ch-sub">{role.description || 'No description'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -300,7 +303,7 @@ export default function Rbac() {
|
||||||
{editing && (
|
{editing && (
|
||||||
<RoleForm
|
<RoleForm
|
||||||
title="Edit Role"
|
title="Edit Role"
|
||||||
subtitle={editing.role_name}
|
subtitle={formatRole(editing.role_name)}
|
||||||
role={editing}
|
role={editing}
|
||||||
bundles={bundles}
|
bundles={bundles}
|
||||||
bundlesLoading={bundlesQuery.isPending}
|
bundlesLoading={bundlesQuery.isPending}
|
||||||
|
|
@ -313,7 +316,7 @@ export default function Rbac() {
|
||||||
{confirmDelete && (
|
{confirmDelete && (
|
||||||
<Modal
|
<Modal
|
||||||
title="Delete role"
|
title="Delete role"
|
||||||
subtitle={confirmDelete.role_name}
|
subtitle={formatRole(confirmDelete.role_name)}
|
||||||
onClose={() => setConfirmDelete(null)}
|
onClose={() => setConfirmDelete(null)}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
|
|
@ -331,7 +334,7 @@ export default function Rbac() {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<p>
|
<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.
|
account but loses every permission the role granted, so reassign them first.
|
||||||
</p>
|
</p>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { useFormState } from '../components/AuthLayout'
|
||||||
import { usePermission } from '../auth/AuthContext'
|
import { usePermission } from '../auth/AuthContext'
|
||||||
import { qk } from '../lib/queryKeys'
|
import { qk } from '../lib/queryKeys'
|
||||||
import { friendlyAuthError } from '../lib/errors'
|
import { friendlyAuthError } from '../lib/errors'
|
||||||
|
import { formatRole } from '../lib/format'
|
||||||
import * as rolesApi from '../api/roles'
|
import * as rolesApi from '../api/roles'
|
||||||
import * as usersApi from '../api/users'
|
import * as usersApi from '../api/users'
|
||||||
import * as orgSettingsApi from '../api/orgSettings'
|
import * as orgSettingsApi from '../api/orgSettings'
|
||||||
|
|
@ -324,7 +325,7 @@ function Users() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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>
|
<td>
|
||||||
<Badge className={!u.is_active ? undefined : u.is_approved ? 'b-green' : 'b-amber'}>
|
<Badge className={!u.is_active ? undefined : u.is_approved ? 'b-green' : 'b-amber'}>
|
||||||
{!u.is_active ? 'Pending' : u.is_approved ? 'Active' : 'Awaiting approval'}
|
{!u.is_active ? 'Pending' : u.is_approved ? 'Active' : 'Awaiting approval'}
|
||||||
|
|
@ -425,7 +426,7 @@ function Approvals() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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 className="text-muted">{u.created_at ? String(u.created_at).slice(0, 10) : '—'}</td>
|
||||||
<td style={{ textAlign: 'right' }}>
|
<td style={{ textAlign: 'right' }}>
|
||||||
<button
|
<button
|
||||||
|
|
@ -495,7 +496,7 @@ function AssignRoleModal({ user, users, onClose }) {
|
||||||
toast(
|
toast(
|
||||||
clearing
|
clearing
|
||||||
? `Role removed from ${target.name}`
|
? `Role removed from ${target.name}`
|
||||||
: `${target.name} is now ${picked?.role_name ?? 'assigned'}`,
|
: `${target.name} is now ${formatRole(picked?.role_name) || 'assigned'}`,
|
||||||
'success',
|
'success',
|
||||||
)
|
)
|
||||||
onClose()
|
onClose()
|
||||||
|
|
@ -558,7 +559,7 @@ function AssignRoleModal({ user, users, onClose }) {
|
||||||
>
|
>
|
||||||
<option value="">No role</option>
|
<option value="">No role</option>
|
||||||
{roles.map((r) => (
|
{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>
|
</select>
|
||||||
<FieldError>
|
<FieldError>
|
||||||
|
|
@ -641,7 +642,7 @@ function Permissions() {
|
||||||
for (const r of roles) {
|
for (const r of roles) {
|
||||||
for (const id of r.permissions ?? []) {
|
for (const id of r.permissions ?? []) {
|
||||||
const list = map.get(Number(id)) ?? []
|
const list = map.get(Number(id)) ?? []
|
||||||
list.push(r.role_name)
|
list.push(formatRole(r.role_name))
|
||||||
map.set(Number(id), list)
|
map.set(Number(id), list)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue