New signups appear here after they confirm their email.
) : (
User
Role
Signed up
Actions
{pending.map((u) => (
{u.name}
{u.email}
{formatRole(u.role_name) || 'No role'}
{u.created_at ? fmtDate(u.created_at) : '—'}
))}
)}
{!canApprove && pending.length > 0 && (
Approving a user requires rbac_users.edit. You can see the queue but cannot approve.
)}
)
}
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 ${formatRole(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 (
>
}
>
)
}
/* Module slug -> rail icon. Unlisted modules fall back to 'lock'. */
const MODULE_ICONS = {
dashboard: 'dashboard', inbox: 'inbox', jobs: 'briefcase', candidates: 'users',
pipeline: 'pipeline', interviews: 'video', assessments: 'check-square', offers: 'offers',
reports: 'reports', analytics: 'analytics', job_board: 'grid', settings: 'settings',
rbac_users: 'shield', tasks: 'list',
}
const OTHER_GROUP = '__other__'
const normaliseName = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '_')
/**
* Permission Matrix — bundles grouped by module in the rail, one edited at a time.
*
* The rail groups on the module slugs from the tag catalogue, NOT on a split at the
* first underscore. Longest match wins, so `Job_Board_Read` files under job_board
* instead of colliding with `Jobs_*`. Two bundles (Candidate_SelfService,
* Interviewer_Assigned) are role-shaped and span 4-5 modules; a name-prefix rule
* would file them under a module their tags never touch, so they get an explicit
* Cross-module group rather than a wrong home.
*
* The grid stays one bundle at a time: all bundles x all tags is ~5000 cells with
* under 5% ticked, which reads as scattered dots rather than a matrix.
*/
function Permissions() {
const { toast } = useToast()
const qc = useQueryClient()
const [bundleId, setBundleId] = useState(null)
const [openKey, setOpenKey] = useState(null)
const [filter, setFilter] = useState('')
const bundlesQuery = useQuery({
queryKey: qk.roles.permissions(),
queryFn: () => rolesApi.listPermissions().then((r) => r.data ?? []),
})
const tagsQuery = useQuery({
queryKey: qk.roles.tags(),
queryFn: () => rolesApi.listPermissionTags().then((r) => r.data ?? []),
})
/* Roles are read only for blast radius: is_system is true on all 44 rows, so it
discriminates nothing. How many roles hold a bundle actually varies. */
const rolesQuery = useQuery({
queryKey: qk.roles.list(),
queryFn: () => rolesApi.listRoles().then((r) => r.data ?? []),
})
const bundles = bundlesQuery.data ?? []
const tags = tagsQuery.data ?? []
const roles = rolesQuery.data ?? []
const holdersByBundle = useMemo(() => {
const map = new Map()
for (const r of roles) {
for (const id of r.permissions ?? []) {
const list = map.get(Number(id)) ?? []
list.push(formatRole(r.role_name))
map.set(Number(id), list)
}
}
return map
}, [roles])
/* Axes in first-seen order, so the grid reads the way the catalogue does. */
const { modules, actions, byCell } = useMemo(() => {
const mods = []
const acts = []
const cells = new Map()
for (const t of tags) {
if (t.module && !mods.includes(t.module)) mods.push(t.module)
if (t.action && !acts.includes(t.action)) acts.push(t.action)
cells.set(`${t.module}.${t.action}`, t.id)
}
return { modules: mods, actions: acts, byCell: cells }
}, [tags])
const groups = useMemo(() => {
/* Longest slug first: `job_board` must beat `jobs` on Job_Board_Read. */
const ranked = [...modules].sort((a, b) => b.length - a.length)
const byKey = new Map()
const other = []
for (const b of bundles) {
const n = normaliseName(b.name)
const hit = ranked.find((s) => n === s || n.startsWith(`${s}_`))
if (!hit) { other.push(b); continue }
if (!byKey.has(hit)) byKey.set(hit, [])
byKey.get(hit).push(b)
}
/* Emit in module order so the rail matches the matrix row order. */
const out = modules.filter((m) => byKey.has(m)).map((m) => ({
key: m, label: humaniseSlug(m), icon: MODULE_ICONS[m] ?? 'lock', items: byKey.get(m),
}))
if (other.length) out.push({ key: OTHER_GROUP, label: 'Cross-module', icon: 'layers', items: other })
return out
}, [bundles, modules])
const matches = useMemo(() => {
const q = filter.trim().toLowerCase()
if (!q) return null
return bundles.filter(
(b) => b.name.toLowerCase().includes(q) || (b.description ?? '').toLowerCase().includes(q),
)
}, [bundles, filter])
const bundle = bundles.find((b) => b.id === bundleId) ?? (matches ?? bundles)[0] ?? bundles[0]
const activeKey = groups.find((g) => g.items.some((b) => b.id === bundle?.id))?.key
const open = openKey ?? activeKey
const granted = useMemo(() => new Set((bundle?.permission_tags ?? []).map(Number)), [bundle])
const save = useMutation({
mutationFn: (permission_tags) => rolesApi.updatePermissionTags({ id: bundle.id, permission_tags }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.roles.all() })
toast('Permissions updated', 'success')
},
onError: (err) => toast(friendlyAuthError(err, 'Could not update permissions.'), 'error'),
})
/* The endpoint replaces permission_tags wholesale — that is what makes unticking
actually revoke — so send the whole set, never a delta. */
const commit = (next) => save.mutate([...next])
const toggle = (tagId) => {
const next = new Set(granted)
if (next.has(tagId)) next.delete(tagId)
else next.add(tagId)
commit(next)
}
const setRow = (m, on) => {
const next = new Set(granted)
for (const a of actions) {
const id = byCell.get(`${m}.${a}`)
if (!id) continue
if (on) next.add(id)
else next.delete(id)
}
commit(next)
}
if (bundlesQuery.isPending || tagsQuery.isPending) {
return (
Fetching the catalogue from the server…
)
}
if (bundlesQuery.isError || tagsQuery.isError) {
return (
{friendlyAuthError(bundlesQuery.error ?? tagsQuery.error, 'The server did not return the permission catalogue.')}
{' '}This tab needs the rbac_users.view permission.
{/* Filtering flattens the tree — a hit inside a collapsed group would
otherwise be invisible. */}
{matches
? (matches.length
? matches.map(bundleRow)
:
{' '}
{holders.length ? (
<>
Held by {holders.length} role{holders.length > 1 ? 's' : ''} — {holders.join(', ')}.
Each gains or loses access on its next request.
>
) : (
<>No role holds this bundle, so edits here change nobody’s access yet.>
)}
{actions.map((a) => {
const tagId = byCell.get(`${m}.${a}`)
/* No tag, no cell. An unchecked box would imply a denial the
catalogue never expressed. */
if (!tagId) 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.