Permission and roles are completed
parent
04455c4a3b
commit
238cb38dc2
|
|
@ -40,7 +40,7 @@ class PermissionUpdate(BaseModel):
|
|||
is_active: bool | None = None
|
||||
|
||||
class RolePermissionTagsUpdate(BaseModel):
|
||||
"""The matrix sets an exact tag set on a role; nothing else is editable here."""
|
||||
"""`id` is a permissions.id from /permissions/fetch; permission_tags is the exact set."""
|
||||
id: int
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
|
|
@ -178,12 +178,11 @@ async def update_permission(
|
|||
async def update_role_permission_tags(
|
||||
payload: RolePermissionTagsUpdate,
|
||||
current_user: dict = Depends(require_permission(PermissionTag.RBAC_USERS_MANAGE)),
|
||||
record_id: int = Query(..., description="Role id whose permission matrix is being set"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=PermissionBundle(session=session)
|
||||
data=await service.set_role_tags(record_id,payload.model_dump())
|
||||
data=await service.set_role_tags(payload.model_dump())
|
||||
return JSONResponse(content={"data":data,"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -161,13 +161,41 @@ class Role:
|
|||
return await PermissionTags.count_permission_tags(self.session, search)
|
||||
|
||||
|
||||
|
||||
|
||||
class PermissionBundle:
|
||||
"""Sets the exact permission-tag set on one bundle, for the permission matrix.
|
||||
|
||||
`id` is a permissions.id from /permissions/fetch. Every role holding that bundle
|
||||
picks the change up on its next request, because Roles.resolve_tags runs live.
|
||||
"""
|
||||
|
||||
def __init__(self,session:AsyncSession):
|
||||
self.session=session
|
||||
|
||||
async def set_role_tags(self,record_id,payload):
|
||||
update_permissions=await Permissions.update_permission(self.session,int(payload.get("id")),payload)
|
||||
return update_permissions
|
||||
async def set_role_tags(self,payload):
|
||||
bundle=await Permissions.get_permission_by_id(self.session,int(payload.get("id")))
|
||||
if not bundle or bundle.is_deleted:
|
||||
raise HTTPException(status_code=404,detail="Permission bundle not found")
|
||||
tag_ids=sorted(set(payload.get("permission_tags") or []))
|
||||
# an unknown or inactive id resolves to nothing, so the box would silently
|
||||
# refuse to stay ticked. reject instead of granting less than was asked.
|
||||
found=await PermissionTags.get_permission_tags_by_ids(self.session,tag_ids)
|
||||
unknown=sorted(set(tag_ids)-{t.id for t in found})
|
||||
if unknown:
|
||||
raise HTTPException(status_code=422,detail=f"Unknown or inactive permission tag ids: {unknown}")
|
||||
# build fields explicitly: update_permission setattr's whatever it is given, so
|
||||
# passing the payload through would write name=None and hit the NOT NULL.
|
||||
fields={"permission_tags":tag_ids}
|
||||
if payload.get("name") is not None:
|
||||
name=payload["name"].strip()
|
||||
if bundle.is_system and name!=bundle.name:
|
||||
raise HTTPException(status_code=409,detail="System permission bundles cannot be renamed")
|
||||
clash=await Permissions.get_permission_by_name(self.session,name)
|
||||
if clash and clash.id!=bundle.id:
|
||||
raise HTTPException(status_code=409,detail="Permission bundle name already exists")
|
||||
fields["name"]=name
|
||||
if payload.get("description") is not None:
|
||||
fields["description"]=payload["description"]
|
||||
if payload.get("is_active") is not None:
|
||||
fields["is_active"]=payload["is_active"]
|
||||
await Permissions.update_permission(self.session,int(bundle.id),fields)
|
||||
return await Role(session=self.session).get_permission_by_id(int(bundle.id))
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@
|
|||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Belleza&family=Inter:wght@400;500;600;700;800&display=swap" />
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23004d43'/><g transform='translate(14 32) scale(0.72)'><path d='M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z' fill='%23ceff71'/></g></svg>" />
|
||||
<script type="module" crossorigin src="/assets/index-BQUbyQvt.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-CpVGHhXU.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CP8rR_Xd.css">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -29,3 +29,8 @@ export function updatePermission(recordId, body) {
|
|||
export function listPermissionTags() {
|
||||
return request('/permission-tags/fetch')
|
||||
}
|
||||
|
||||
/** Sets the exact tag set on one bundle. `id` comes from listPermissions(). */
|
||||
export function updatePermissionTags(body) {
|
||||
return request('/roles/permission-tags/update', { method: 'PUT', body })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
chrome; Access Control is the authoritative RBAC surface.
|
||||
============================================================ */
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import Modal from '../ui/Modal'
|
||||
|
|
@ -21,6 +21,15 @@ import * as rolesApi from '../api/roles'
|
|||
import * as usersApi from '../api/users'
|
||||
import * as orgSettingsApi from '../api/orgSettings'
|
||||
|
||||
/** "job_board" -> "Job Board". Slugs are the source of truth; this is display only. */
|
||||
function humaniseSlug(slug) {
|
||||
return String(slug || '')
|
||||
.split(/[_-]/)
|
||||
.filter(Boolean)
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
const TABS = [
|
||||
'General', 'Users', 'Permissions', 'Notifications',
|
||||
'Email Templates', 'Career Portal', 'Branding', 'Security', 'Appearance',
|
||||
|
|
@ -445,45 +454,337 @@ function AssignRoleModal({ user, users, onClose }) {
|
|||
)
|
||||
}
|
||||
|
||||
/* 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 modules = ['Jobs', 'Candidates', 'Interviews', 'Offers', 'Reports', 'Settings']
|
||||
const perms = ['View', 'Create', 'Edit', 'Delete']
|
||||
const { toast } = useToast()
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<div><h3>Permission Matrix</h3><span className="ch-sub">Recruiter role</span></div>
|
||||
<select className="select"><option>Recruiter</option><option>Hiring Manager</option><option>Administrator</option></select>
|
||||
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(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 (
|
||||
<div className="card"><div className="card-body">
|
||||
<EmptyState icon="clock" title="Loading permissions">Fetching the catalogue from the server…</EmptyState>
|
||||
</div></div>
|
||||
)
|
||||
}
|
||||
|
||||
if (bundlesQuery.isError || tagsQuery.isError) {
|
||||
return (
|
||||
<div className="card"><div className="card-body">
|
||||
<EmptyState icon="alert" title="Couldn’t load permissions">
|
||||
{friendlyAuthError(bundlesQuery.error ?? tagsQuery.error, 'The server did not return the permission catalogue.')}
|
||||
{' '}This tab needs the <code>rbac_users.view</code> permission.
|
||||
</EmptyState>
|
||||
</div></div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!bundle) {
|
||||
return (
|
||||
<div className="card"><div className="card-body">
|
||||
<EmptyState icon="lock" title="No permission bundles">Bundles are seeded server-side.</EmptyState>
|
||||
</div></div>
|
||||
)
|
||||
}
|
||||
|
||||
const holders = holdersByBundle.get(bundle.id) ?? []
|
||||
|
||||
const bundleRow = (b) => {
|
||||
const held = holdersByBundle.get(b.id) ?? []
|
||||
return (
|
||||
<div
|
||||
key={b.id}
|
||||
className={`role-item${b.id === bundle.id ? ' active' : ''}`}
|
||||
style={{ padding: '8px 12px 8px 22px', gap: 10 }}
|
||||
onClick={() => setBundleId(b.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setBundleId(b.id) }
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="fw-600 text-sm">{b.name}</div>
|
||||
<div className="cell-sub">
|
||||
{b.permission_tags?.length ?? 0} tags
|
||||
{held.length ? ` · ${held.length} role${held.length > 1 ? 's' : ''}` : ' · unused'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted text-sm" style={{ padding: '0 18px' }}>
|
||||
This is a simplified view. The authoritative matrix — 13 modules × 8 actions, resolved from the
|
||||
server — lives on <b>Access Control</b>.
|
||||
</p>
|
||||
<div className="table-wrap">
|
||||
<table className="data">
|
||||
<thead>
|
||||
<tr><th>Module</th>{perms.map((p) => <th style={{ textAlign: 'center' }} key={p}>{p}</th>)}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{modules.map((m) => (
|
||||
<tr key={m}>
|
||||
<td className="cell-primary">{m}</td>
|
||||
{perms.map((p) => (
|
||||
<td style={{ textAlign: 'center' }} key={p}>
|
||||
<label className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
defaultChecked={m !== 'Settings'}
|
||||
onChange={() => toast('Use Access Control to change permissions', 'info')}
|
||||
/>
|
||||
<span className="switch-track" />
|
||||
</label>
|
||||
</td>
|
||||
))}
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rbac-layout">
|
||||
<div className="card" style={{ alignSelf: 'start' }}>
|
||||
<div className="card-body" style={{ padding: 12 }}>
|
||||
<div className="nav-section-label" style={{ padding: '6px 8px' }}>
|
||||
Bundles · {bundles.length}
|
||||
</div>
|
||||
<div className="form-field" style={{ padding: '0 8px 10px' }}>
|
||||
<input
|
||||
placeholder="Filter bundles…"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
aria-label="Filter permission bundles"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="role-list">
|
||||
{/* Filtering flattens the tree — a hit inside a collapsed group would
|
||||
otherwise be invisible. */}
|
||||
{matches
|
||||
? (matches.length
|
||||
? matches.map(bundleRow)
|
||||
: <p className="text-muted text-sm" style={{ padding: '8px 10px' }}>No bundle matches that filter.</p>)
|
||||
: groups.map((g) => {
|
||||
const isOpen = open === g.key
|
||||
return (
|
||||
<div key={g.key}>
|
||||
<div
|
||||
className="role-item"
|
||||
onClick={() => setOpenKey(isOpen ? '' : g.key)}
|
||||
role="button"
|
||||
aria-expanded={isOpen}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setOpenKey(isOpen ? '' : g.key) }
|
||||
}}
|
||||
>
|
||||
<span className="role-badge" style={{ background: 'var(--bg-sunken)', color: 'var(--text-2)' }}>
|
||||
<Icon name={g.icon} />
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="fw-600 text-sm">{g.label}</div>
|
||||
<div className="cell-sub">{g.items.length} bundles</div>
|
||||
</div>
|
||||
<Icon name={isOpen ? 'chevron-down' : 'chevron-right'} />
|
||||
</div>
|
||||
{isOpen && g.items.map(bundleRow)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<div className="flex items-center gap-12">
|
||||
<span className="role-badge" style={{ background: 'var(--primary-soft)', color: 'var(--primary)' }}>
|
||||
<Icon name={MODULE_ICONS[activeKey] ?? 'layers'} />
|
||||
</span>
|
||||
<div>
|
||||
<h3>{bundle.name}</h3>
|
||||
<span className="ch-sub">{bundle.description || 'No description'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-8">
|
||||
{save.isPending && <span className="cell-sub">Saving…</span>}
|
||||
<span className="badge b-gray badge-plain">{granted.size} / {tags.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-body" style={{ paddingBottom: 0 }}>
|
||||
<p className="text-muted text-sm" style={{ marginBottom: 12 }}>
|
||||
<Icon name="lock" />{' '}
|
||||
{holders.length ? (
|
||||
<>
|
||||
Held by <b>{holders.length} role{holders.length > 1 ? 's' : ''}</b> — {holders.join(', ')}.
|
||||
Each gains or loses access on its next request.
|
||||
</>
|
||||
) : (
|
||||
<>No role holds this bundle, so edits here change nobody’s access yet.</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="table-wrap">
|
||||
<table className="rbac-matrix">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Module</th>
|
||||
{actions.map((a) => <th key={a}>{humaniseSlug(a)}</th>)}
|
||||
<th />
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{modules.map((m) => {
|
||||
const ids = actions.map((a) => byCell.get(`${m}.${a}`)).filter(Boolean)
|
||||
const on = ids.filter((id) => granted.has(id)).length
|
||||
return (
|
||||
<tr key={m}>
|
||||
<td>
|
||||
{humaniseSlug(m)}
|
||||
<div className="cell-sub">{on} of {ids.length}</div>
|
||||
</td>
|
||||
{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 <td key={a} className="text-muted">—</td>
|
||||
return (
|
||||
<td key={a}>
|
||||
<label className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={granted.has(tagId)}
|
||||
disabled={save.isPending}
|
||||
onChange={() => toggle(tagId)}
|
||||
aria-label={`${m}.${a}`}
|
||||
/>
|
||||
<span className="switch-track" />
|
||||
</label>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
disabled={save.isPending}
|
||||
onClick={() => setRow(m, on < ids.length)}
|
||||
>
|
||||
{on < ids.length ? 'All' : 'None'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue