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 ( -rbac_users.view permission.
+ - This is a simplified view. The authoritative matrix — 13 modules × 8 actions, resolved from the - server — lives on Access Control. -
-| Module | {perms.map((p) =>{p} | )}|||||||
|---|---|---|---|---|---|---|---|---|
| {m} | - {perms.map((p) => ( -- - | - ))} + ) + } + + return ( +
| Module | + {actions.map((a) =>{humaniseSlug(a)} | )} +
|---|