From 238cb38dc29e4ff5fde58b4d69eb2ec5f2fb273d Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Tue, 18 Aug 2026 15:20:30 +0500 Subject: [PATCH] Permission and roles are completed --- backend/role/app.py | 5 +- backend/role/views.py | 38 ++- frontend/dist/index.html | 2 +- frontend/src/api/roles.js | 5 + frontend/src/screens/Settings.jsx | 373 +++++++++++++++++++++++++++--- 5 files changed, 378 insertions(+), 45 deletions(-) diff --git a/backend/role/app.py b/backend/role/app.py index 890edc5..e057734 100644 --- a/backend/role/app.py +++ b/backend/role/app.py @@ -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 diff --git a/backend/role/views.py b/backend/role/views.py index 9b74813..9109768 100644 --- a/backend/role/views.py +++ b/backend/role/views.py @@ -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 \ No newline at end of file + 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)) diff --git a/frontend/dist/index.html b/frontend/dist/index.html index abea7f3..f973dc0 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -23,7 +23,7 @@ - + diff --git a/frontend/src/api/roles.js b/frontend/src/api/roles.js index e3d17db..b95cb10 100644 --- a/frontend/src/api/roles.js +++ b/frontend/src/api/roles.js @@ -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 }) +} diff --git a/frontend/src/screens/Settings.jsx b/frontend/src/screens/Settings.jsx index a982673..1b338f6 100644 --- a/frontend/src/screens/Settings.jsx +++ b/frontend/src/screens/Settings.jsx @@ -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 ( -
-
-

Permission Matrix

Recruiter role
- + 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 ( +
+ 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. + +
+ ) + } + + if (!bundle) { + return ( +
+ Bundles are seeded server-side. +
+ ) + } + + const holders = holdersByBundle.get(bundle.id) ?? [] + + const bundleRow = (b) => { + const held = holdersByBundle.get(b.id) ?? [] + return ( +
setBundleId(b.id)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setBundleId(b.id) } + }} + > +
+
{b.name}
+
+ {b.permission_tags?.length ?? 0} tags + {held.length ? ` · ${held.length} role${held.length > 1 ? 's' : ''}` : ' · unused'} +
+
-

- This is a simplified view. The authoritative matrix — 13 modules × 8 actions, resolved from the - server — lives on Access Control. -

-
- - - {perms.map((p) => )} - - - {modules.map((m) => ( - - - {perms.map((p) => ( - - ))} + ) + } + + return ( +
+
+
+
+ Bundles · {bundles.length} +
+
+ setFilter(e.target.value)} + aria-label="Filter permission bundles" + /> +
+ +
+ {/* Filtering flattens the tree — a hit inside a collapsed group would + otherwise be invisible. */} + {matches + ? (matches.length + ? matches.map(bundleRow) + :

No bundle matches that filter.

) + : groups.map((g) => { + const isOpen = open === g.key + return ( +
+
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) } + }} + > + + + +
+
{g.label}
+
{g.items.length} bundles
+
+ +
+ {isOpen && g.items.map(bundleRow)} +
+ ) + })} +
+
+
+ +
+
+
+ + + +
+

{bundle.name}

+ {bundle.description || 'No description'} +
+
+
+ {save.isPending && Saving…} + {granted.size} / {tags.length} +
+
+ +
+

+ {' '} + {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. + )} +

+
+ +
+
Module{p}
{m} - -
+ + + + {actions.map((a) => )} + - ))} - -
Module{humaniseSlug(a)}
+ + + {modules.map((m) => { + const ids = actions.map((a) => byCell.get(`${m}.${a}`)).filter(Boolean) + const on = ids.filter((id) => granted.has(id)).length + return ( + + + {humaniseSlug(m)} +
{on} of {ids.length}
+ + {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 — + return ( + + + + ) + })} + + + + + ) + })} + + +
)