Permission and roles are completed

pull/17/head
ahmed.mujtaba 2026-08-18 15:20:30 +05:00
parent 04455c4a3b
commit 238cb38dc2
5 changed files with 378 additions and 45 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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="Couldnt 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 nobodys 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>
)