From 0558af52c48bdeb3d916826467d2b30a8b6f2106 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Fri, 4 Sep 2026 16:10:56 +0500 Subject: [PATCH] . --- backend/inbox/models.py | 18 ++++- backend/job/app.py | 4 +- backend/job/candidate/models.py | 6 +- backend/job/candidate/views.py | 91 +++++++++++++++++++------ backend/job/job_post/models.py | 15 +++++ backend/role/app.py | 22 ++++++ backend/role/views.py | 40 +++++++++++ backend/users/permissions.py | 13 ++++ frontend/src/api/candidates.js | 82 ++-------------------- frontend/src/api/roles.js | 9 +++ frontend/src/auth/permissions.js | 14 ++++ frontend/src/screens/Candidates.jsx | 24 +++++-- frontend/src/screens/Rbac.jsx | 101 +++++++++++++++++++++++----- frontend/src/styles/styles.css | 4 +- 14 files changed, 321 insertions(+), 122 deletions(-) diff --git a/backend/inbox/models.py b/backend/inbox/models.py index f60d92f..25af7c2 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -214,8 +214,10 @@ class Inbox(SQLModel, table=True): return or_(Users.name.ilike(pattern), Users.email.ilike(pattern)) @classmethod - async def get_candidate_profile(cls,session:AsyncSession,user_id:uuid.UUID|None=None,limit:int=10,offset:int=0,search:str|None=None): + async def get_candidate_profile(cls,session:AsyncSession,user_id:uuid.UUID|None=None,limit:int=10,offset:int=0,search:str|None=None,job_post_ids=None): try: + if job_post_ids is not None and not list(job_post_ids): + return [] options=[selectinload(cls.messages)] if user_id: options.extend([ @@ -234,6 +236,11 @@ class Inbox(SQLModel, table=True): qry = qry.where(cls.user_id == user_id) if search: qry = qry.where(cls._candidate_search_filter(search)) + if job_post_ids is not None: + qry = ( + qry.join(Inbox_Messages, cls.message_id == Inbox_Messages.id) + .where(Inbox_Messages.assigned_job_post_id.in_(list(job_post_ids))) + ) # Most-recent-first is the list contract; id breaks ties so a page # boundary can't drop or repeat a row when created_at collides. qry = qry.order_by(cls.created_at.desc(), cls.id.desc()) @@ -247,9 +254,11 @@ class Inbox(SQLModel, table=True): raise HTTPException(status_code=500,detail=str(e)) @classmethod - async def count_candidate_profiles(cls,session:AsyncSession,user_id:uuid.UUID|None=None,search:str|None=None): + async def count_candidate_profiles(cls,session:AsyncSession,user_id:uuid.UUID|None=None,search:str|None=None,job_post_ids=None): """Result-set size for the same predicate get_candidate_profile pages over.""" try: + if job_post_ids is not None and not list(job_post_ids): + return 0 qry = ( select(func.count()) .select_from(cls) @@ -261,6 +270,11 @@ class Inbox(SQLModel, table=True): qry = qry.where(cls.user_id == user_id) if search: qry = qry.where(cls._candidate_search_filter(search)) + if job_post_ids is not None: + qry = ( + qry.join(Inbox_Messages, cls.message_id == Inbox_Messages.id) + .where(Inbox_Messages.assigned_job_post_id.in_(list(job_post_ids))) + ) result = await session.execute(qry) return result.scalar_one() except Exception as e: diff --git a/backend/job/app.py b/backend/job/app.py index feee8fc..3920218 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -957,7 +957,9 @@ async def fetch_candidate( ) - total=await service.count_candidates(user_id=user_id,search=search) if isinstance(data,list) else 1 + total=await service.count_candidates( + user_id=user_id,search=search,current_user=current_user,assigned_job_post_id=assigned_job_post_id, + ) if isinstance(data,list) else 1 return JSONResponse(content={"data":data,"total":total,"status_code":200}) except HTTPException: raise diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index 629893d..262dd25 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -390,16 +390,20 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): return result.scalars().first() @classmethod - async def list_for_talent_pool(cls, session: AsyncSession, *, limit=100, offset=0, search=None): + async def list_for_talent_pool(cls, session: AsyncSession, *, limit=100, offset=0, search=None, job_post_ids=None): """Newest applications with a user + job for Talent Pool (manual / form).""" from users.models import Users + if job_post_ids is not None and not list(job_post_ids): + return [] statement = ( select(cls) .join(Users, cls.user_id == Users.id) .where(cls.user_id.is_not(None), cls.job_post_id.is_not(None)) .order_by(cls.created_at.desc()) ) + if job_post_ids is not None: + statement = statement.where(cls.job_post_id.in_(list(job_post_ids))) if search: like = f"%{search.strip()}%" statement = statement.where( diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index 1322b17..8a7e1a8 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -32,7 +32,7 @@ from job.history.views import HistoryRecorder from job.notes.serializers import serialize_note from job.candidate.plugins import extract_candidate_email from users.models import Users -from users.permissions import is_hiring_manager +from users.permissions import is_hiring_manager,sees_all_candidates from employment_agent.plugins import parse_phone load_dotenv() @@ -42,6 +42,7 @@ MANUAL_UPLOAD_TO_ADDRESS=os.getenv( "MANUAL_UPLOAD_TO_ADDRESS","manual-cv-upload@hr-ats.local" ) MANAGER_SCOPE_DETAIL="You can only access candidates allocated to jobs opened from your requisitions" +CREATOR_SCOPE_DETAIL="You can only access candidates allocated to jobs you created" async def assigned_job_ids_for_user(session,user_id): @@ -77,15 +78,48 @@ async def job_id_for_application(session,inbox_id=None,manual_id=None): return None,None +async def owned_job_ids_for_candidate_scope(session,current_user): + """Job ids this user may see, or None when the list is unscoped. + + Hiring manager → requisition / assigned-manager jobs. + candidates.manage or admin → None (all applications). + Otherwise → job_posts.created_by = this user. Never role_id. + """ + if is_hiring_manager(current_user): + return await JobPosts.ids_for_manager(session,current_user.get("id")) + if sees_all_candidates(current_user): + return None + return await JobPosts.ids_for_creator(session,current_user.get("id")) + + +async def job_post_ids_for_candidate_list(session,current_user,assigned_job_post_id=None): + """None = unscoped list. [] = nothing visible. Else UUID list for the query.""" + owned=await owned_job_ids_for_candidate_scope(session,current_user) + requested=JobPosts._as_uuid(assigned_job_post_id) if assigned_job_post_id is not None else None + if owned is None: + return [requested] if requested else None + if requested is not None: + return [requested] if requested in set(owned) else [] + return list(owned) + + +def _scope_detail(current_user): + if is_hiring_manager(current_user): + return MANAGER_SCOPE_DETAIL + return CREATOR_SCOPE_DETAIL + + async def assert_manager_candidate_access( session,current_user,*,user_id=None,job_post_id=None,inbox_id=None,manual_id=None, ): - """Hiring managers may only touch applications on jobs they own.""" - if not is_hiring_manager(current_user): + """Row access: hiring-manager jobs, unscoped (manage/admin), or jobs this user created.""" + if not is_hiring_manager(current_user) and sees_all_candidates(current_user): return - owned=set(await JobPosts.ids_for_manager(session,current_user.get("id"))) + owned=await owned_job_ids_for_candidate_scope(session,current_user) + owned=set(owned or []) + detail=_scope_detail(current_user) if not owned: - raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL) + raise HTTPException(status_code=403,detail=detail) job_id=JobPosts._as_uuid(job_post_id) if job_post_id is not None else None uid=user_id if job_id is None and (inbox_id is not None or manual_id is not None): @@ -94,9 +128,9 @@ async def assert_manager_candidate_access( candidate_jobs=await assigned_job_ids_for_user(session,uid) if candidate_jobs & owned: return - raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL) + raise HTTPException(status_code=403,detail=detail) if job_id is None or job_id not in owned: - raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL) + raise HTTPException(status_code=403,detail=detail) async def parse_linkedin_url_from_cv(resume_text) -> str | None: @@ -789,21 +823,32 @@ class CandidateView: try: if not user_id and is_hiring_manager(current_user): raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL) - if user_id and is_hiring_manager(current_user): + if user_id: await assert_manager_candidate_access( self.session,current_user,user_id=user_id, ) detail=bool(user_id) - rows=await Inbox.get_candidate_profile(session=self.session,user_id=user_id,limit=limit,offset=offset,search=search) + list_job_ids=None + if not detail: + list_job_ids=await job_post_ids_for_candidate_list( + self.session,current_user,assigned_job_post_id=assigned_job_post_id, + ) + if list_job_ids is not None and not list_job_ids: + return [] + rows=await Inbox.get_candidate_profile( + session=self.session,user_id=user_id,limit=limit,offset=offset,search=search, + job_post_ids=list_job_ids, + ) if detail: records=rows if isinstance(rows,list) else ([rows] if rows else []) - if records and is_hiring_manager(current_user): - owned=set(await JobPosts.ids_for_manager(self.session,current_user.get("id"))) + owned=await owned_job_ids_for_candidate_scope(self.session,current_user) + if records and owned is not None: + owned_set=set(owned) kept=[] for rec in records: msg=getattr(rec,"messages",None) jid=getattr(msg,"assigned_job_post_id",None) if msg is not None else None - if jid and jid in owned: + if jid and jid in owned_set: kept.append(rec) if kept: return await self.attach_profile_detail(kept) @@ -815,10 +860,10 @@ class CandidateView: manual=await Manual_UPLOAD_CANDIDATE.get_by_user_id(self.session,user_id) if not manual: return [] - if is_hiring_manager(current_user): - owned=set(await JobPosts.ids_for_manager(self.session,current_user.get("id"))) - if not manual.job_post_id or manual.job_post_id not in owned: - raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL) + if owned is not None: + owned_set=set(owned) + if not manual.job_post_id or manual.job_post_id not in owned_set: + raise HTTPException(status_code=403,detail=_scope_detail(current_user)) user=await Users.get_user_by_id(self.session,user_id) job_post=None if manual.job_post_id: @@ -841,7 +886,7 @@ class CandidateView: if not isinstance(inbox_payloads,list): inbox_payloads=[inbox_payloads] if inbox_payloads else [] manual_rows=await Manual_UPLOAD_CANDIDATE.list_for_talent_pool( - self.session,limit=limit,offset=0,search=search, + self.session,limit=limit,offset=0,search=search,job_post_ids=list_job_ids, ) seen={p.get("user_id") for p in inbox_payloads if p.get("user_id")} manual_payloads=[] @@ -902,9 +947,16 @@ class CandidateView: except Exception as e: raise HTTPException(status_code=500,detail=str(e)) - async def count_candidates(self,user_id=None,search=None): + async def count_candidates(self,user_id=None,search=None,current_user=None,assigned_job_post_id=None): try: - return await Inbox.count_candidate_profiles(session=self.session,user_id=user_id,search=search) + job_post_ids=None + if not user_id: + job_post_ids=await job_post_ids_for_candidate_list( + self.session,current_user,assigned_job_post_id=assigned_job_post_id, + ) + return await Inbox.count_candidate_profiles( + session=self.session,user_id=user_id,search=search,job_post_ids=job_post_ids, + ) except HTTPException: raise except Exception as e: @@ -914,6 +966,7 @@ class CandidateView: try: if not user_id: raise HTTPException(status_code=400,detail="user_id is required") + await assert_manager_candidate_access(self.session,current_user,user_id=user_id) fields={k:v for k,v in (payload or {}).items() if k in ("favorite","rating") and v is not None} if not fields: raise HTTPException(status_code=400,detail="favorite or rating is required") diff --git a/backend/job/job_post/models.py b/backend/job/job_post/models.py index beb75aa..086d24e 100644 --- a/backend/job/job_post/models.py +++ b/backend/job/job_post/models.py @@ -481,6 +481,21 @@ class JobPosts(SQLModel, table=True): out.append(row_id) return out + @classmethod + async def ids_for_creator(cls, session: AsyncSession, user_id): + """Job posts this user created. Recruiter Candidates scope (when they + lack candidates.manage) follows job_posts.created_by, not role_id.""" + uid = cls._as_uuid(user_id) + if uid is None: + return [] + result = await session.execute( + select(cls.id).where( + cls.created_by == uid, + cls.is_deleted == False, # noqa: E712 + ) + ) + return list(result.scalars().all()) + @classmethod async def count_open_reqs_by_hiring_managers(cls, session: AsyncSession, user_ids): """Open requisitions per hiring manager, keyed by users.id.""" diff --git a/backend/role/app.py b/backend/role/app.py index e057734..14e4971 100644 --- a/backend/role/app.py +++ b/backend/role/app.py @@ -48,6 +48,11 @@ class RolePermissionTagsUpdate(BaseModel): is_active: bool | None = None +class RoleMatrixUpdate(BaseModel): + """Exact tag ids for one role. Saved onto that role's overlay bundle.""" + permission_tags: list[int] + + @router.get("/roles/fetch") async def fetch_roles( current_user: dict = Depends(require_permission(PermissionTag.RBAC_USERS_VIEW)), @@ -174,6 +179,23 @@ async def update_permission( except Exception as e: raise HTTPException(status_code=500,detail=str(e)) +@router.put("/roles/matrix/update") +async def update_role_matrix( + payload: RoleMatrixUpdate, + current_user: dict = Depends(require_permission(PermissionTag.RBAC_USERS_EDIT)), + record_id: int = Query(...), + session: AsyncSession = Depends(get_session), +): + try: + service=Role(session=session) + data=await service.set_role_matrix(record_id,payload.permission_tags) + return JSONResponse(content={"data":data,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + @router.put("/roles/permission-tags/update") async def update_role_permission_tags( payload: RolePermissionTagsUpdate, diff --git a/backend/role/views.py b/backend/role/views.py index 9109768..453eec1 100644 --- a/backend/role/views.py +++ b/backend/role/views.py @@ -81,6 +81,46 @@ class Role: updated = await Roles.update_role(self.session, int(record_id), fields) return await self._role_payload(updated) + async def set_role_matrix(self, record_id, permission_tags): + """Write the Access Control grid onto one overlay bundle for this role. + + Shared system bundles are not mutated. The role then points at that + overlay only, so a ticked cell is the grant and an unticked cell is not. + """ + role = await Roles.get_role_by_id(self.session, int(record_id)) + if not role or role.is_deleted: + raise HTTPException(status_code=404, detail="Role not found") + tag_ids = sorted({int(i) for i in (permission_tags or [])}) + 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}", + ) + overlay_name = f"role_{role.id}_matrix" + bundle = await Permissions.get_permission_by_name(self.session, overlay_name) + if bundle is None: + bundle = await Permissions.insert_permission( + self.session, + { + "name": overlay_name, + "description": f"Access Control matrix for {role.role_name}", + "permission_tags": tag_ids, + "is_system": False, + "is_active": True, + "is_deleted": False, + }, + ) + else: + await Permissions.update_permission( + self.session, int(bundle.id), {"permission_tags": tag_ids}, + ) + updated = await Roles.update_role( + self.session, int(record_id), {"permissions": [int(bundle.id)]}, + ) + return await self._role_payload(updated) + async def delete_role(self, record_id): role = await Roles.get_role_by_id(self.session, int(record_id)) if not role or role.is_deleted: diff --git a/backend/users/permissions.py b/backend/users/permissions.py index e420768..83e26bc 100644 --- a/backend/users/permissions.py +++ b/backend/users/permissions.py @@ -234,6 +234,19 @@ def is_admin(current_user: dict | None) -> bool: return PermissionTag.REQUISITIONS_MANAGE.value in granted +def sees_all_candidates(current_user: dict | None) -> bool: + """Unscoped Candidates list: admins, or any role granted candidates.manage. + + Absence of that tag (with candidates.view) scopes the list to jobs the + user created. Do not key this off role_id — a custom role must be able + to opt in through Access Control. + """ + if is_admin(current_user): + return True + granted = (current_user or {}).get("permissions") or [] + return PermissionTag.CANDIDATES_MANAGE.value in granted + + def has_permission( granted: set[str] | list[str] | tuple[str, ...], *required: PermissionTag, diff --git a/frontend/src/api/candidates.js b/frontend/src/api/candidates.js index b4d8c96..4544e42 100644 --- a/frontend/src/api/candidates.js +++ b/frontend/src/api/candidates.js @@ -1,16 +1,4 @@ -/* ============================================================ - candidates.js — candidate endpoints (backend/job/app.py). - Two data families share this module: - - ATS scoring (persisted `candidates` table): listJobs, listCandidates, - getCandidate, scoreUploads, scoreInbox, toCandidateView. - - Candidate profiles (inbox -> users -> roles join): list, getByUserId, - toRows. - - Same conventions as inbox.js: one named export per endpoint, no hooks, - camelCase params mapped to snake_case at the call boundary, and every - function returns the parsed {data, total, status_code} envelope. - ============================================================ */ import { downloadFile, fetchBlobUrl, request } from '../lib/apiClient' import { toDate } from '../lib/format' @@ -163,25 +151,7 @@ export function toCandidateView(row) { } } -/** - * Candidate USER accounts — `users` rows filtered by role, not the scored - * `candidates` table. Needs candidates.view. - * - * role_id 8 is the seeded `candidate` role (id 4 is hiring_manager, the signup - * default). We send it explicitly so a missing param cannot list the wrong people. - * - * This route: - * - it returns `{data, status_code}` with NO `total` on the list; use - * GET /candidate/fetch/users/count (once on page open) for the pager total; - * - `top`/`skip` page the list; the Candidates screen sends the user's page - * size as `top` and `(page-1)*top` as `skip`; - * - `assigned_job_post_id` keeps only users assigned to that job post - * (`inbox_messages.assigned_job_post_id`). Omit it for All Jobs. - * - it accepts a `search` query param but never forwards it to the service - * layer (`get_users(role_id=, top=, skip=)`), so searching is a no-op - * server-side. Filtering stays client-side on the fetched page until that - * is fixed. - */ + export function listCandidateUsers({ roleId = 8, top = 10, skip = 0, assignedJobPostId } = {}) { return request('/candidate/fetch/users', { params: { role_id: roleId, top, skip, assigned_job_post_id: assignedJobPostId }, @@ -195,14 +165,7 @@ export function countCandidateUsers({ roleId = 8, search, assignedJobPostId } = }) } -/** - * `users` row -> the row shape the Candidates table renders. - * - * A user account carries identity only. Everything the ATS produces - * (score, matched skills, critique, the job it was scored against) lives in the - * `candidates` table keyed by job_id + content hash, with no user_id to join on, so - * those fields are null here by construction rather than by omission. - */ + export function toCandidateUserView(row) { return { id: row.id, @@ -279,48 +242,19 @@ export function toApplicationListView(row) { } } -/** - * Candidate profiles — the `inbox -> users -> roles` join, restricted server-side - * to role_name == CANDIDATE (backend/inbox/models.py:get_candidate_profile). - * - * Permissioned with require_permission(CANDIDATES_VIEW), so a caller without the - * tag gets a 403. - * - * `search` is an ilike over users.name / users.email only — it does NOT reach - * the résumé text or the suggested job titles. - * - * `assignedJobPostId` maps to `assigned_job_post_id` and keeps only people - * assigned to that job. Empty / omitted is All Jobs. - */ + export function list({ search, limit, offset, assignedJobPostId } = {}) { return request('/candidate/fetch', { params: { search, limit, offset, assigned_job_post_id: assignedJobPostId }, }) } -/** - * One candidate by users.id. - * - * Passing user_id switches the endpoint into DETAIL mode - * (backend/job/candidate/views.py:get_candidate), which is a different and much - * larger payload than the list rows: résumé text, the AI match verdict, phone, - * education, source, documents, favorite/rating, and the four child collections - * — interviews, activity, feedback, notes — flattened across every inbox row the - * candidate owns. - * - * NOTE the asymmetric response: get_candidate_profile returns a BARE OBJECT - * rather than a one-element list when user_id matches exactly one row - * (backend/inbox/models.py:68-70). Callers must normalise — see toRows(). - */ + export function getByUserId(userId) { return request('/candidate/fetch', { params: { user_id: userId } }) } -/** - * Candidates allocated to jobs this hiring manager owns — requisitions they - * created (or are assigned on) → linked job posts → applications. - * Needs candidates.view. Server-scoped; recruiters should not use this. - */ + export function listForManager({ limit = 50, offset = 0 } = {}) { return request('/candidate/manager/fetch', { params: { limit, offset } }) } @@ -331,11 +265,7 @@ export function toRows(res) { return res?.data ? [res.data] : [] } -/** - * Assigned job-post ids only. `job_posts` / suggested_job_post_ids are - * matcher hints, not an assignment — the Candidates / Talent Pool job - * filter must not treat a suggestion as a link. - */ + export function jobIdsOf(row) { if (!row || typeof row !== 'object') return [] const ids = [] diff --git a/frontend/src/api/roles.js b/frontend/src/api/roles.js index b95cb10..605d6f1 100644 --- a/frontend/src/api/roles.js +++ b/frontend/src/api/roles.js @@ -34,3 +34,12 @@ export function listPermissionTags() { export function updatePermissionTags(body) { return request('/roles/permission-tags/update', { method: 'PUT', body }) } + +/** Access Control grid save — PUT onto a per-role overlay bundle. */ +export function updateRoleMatrix(recordId, permissionTags) { + return request('/roles/matrix/update', { + method: 'PUT', + params: { record_id: recordId }, + body: { permission_tags: permissionTags }, + }) +} diff --git a/frontend/src/auth/permissions.js b/frontend/src/auth/permissions.js index 08afc26..095a912 100644 --- a/frontend/src/auth/permissions.js +++ b/frontend/src/auth/permissions.js @@ -48,3 +48,17 @@ export function isHiringManager(user) { const name = (user?.role_name || '').trim().toLowerCase() return name === HIRING_MANAGER_ROLE || name === 'manager' } + +const ADMIN_ROLES = new Set(['system_administrator', 'hr_administrator', 'admin']) + +export function isAdmin(user) { + const name = (user?.role_name || '').trim().toLowerCase() + if (ADMIN_ROLES.has(name)) return true + return (user?.permissions || []).includes('requisitions.manage') +} + +/** Unscoped Candidates list — matches backend sees_all_candidates. */ +export function seesAllCandidates(user) { + if (isAdmin(user)) return true + return (user?.permissions || []).includes('candidates.manage') +} diff --git a/frontend/src/screens/Candidates.jsx b/frontend/src/screens/Candidates.jsx index a914631..e44c395 100644 --- a/frontend/src/screens/Candidates.jsx +++ b/frontend/src/screens/Candidates.jsx @@ -3,9 +3,11 @@ Recruiter rows come from GET /candidate/fetch (inbox + manual), one row per application, so score / stage / job / recruiter have a source. - Hiring managers use GET /candidate/manager/fetch (jobs on their - requisitions). Adding a candidate still goes through CV Import or the - Add Candidate modal — both run the CV through persisted ATS scoring. + Without candidates.manage the server scopes that list to jobs the user + created (job_posts.created_by). Hiring managers use GET + /candidate/manager/fetch (jobs on their requisitions). Adding a candidate + still goes through CV Import or the Add Candidate modal — both run the CV + through persisted ATS scoring. ============================================================ */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -18,7 +20,7 @@ import PageHeader from '../ui/PageHeader' import { Avatar, Badge, EmptyState, FieldError, Icon, ScoreChip, SkeletonRows } from '../ui/primitives' import { useToast } from '../ui/Toast' import { useAuth } from '../auth/AuthContext' -import { isHiringManager } from '../auth/permissions' +import { isHiringManager, seesAllCandidates } from '../auth/permissions' import CandidateProfile from './CandidateProfile' import { useJobTitles } from './ScoredCandidateProfile' import { qk } from '../lib/queryKeys' @@ -64,11 +66,12 @@ async function fetchCandidates({ limit = DEFAULT_PAGE_SIZE, offset = 0, search, } } -async function fetchJobs() { +async function fetchJobs({ createdBy } = {}) { const res = await candidatesApi.listJobs() const rows = Array.isArray(res?.data) ? res.data : [] return rows .filter((row) => row && row.id != null) + .filter((row) => !createdBy || String(row.created_by || '') === String(createdBy)) .map((row) => ({ id: String(row.id), title: typeof row.title === 'string' && row.title.trim() ? row.title : 'Untitled', @@ -291,7 +294,9 @@ function RecruiterCandidates() { const qc = useQueryClient() const location = useLocation() const navigate = useNavigate() + const { user } = useAuth() const updateCandidates = useSeedMutation('candidates') + const unscoped = seesAllCandidates(user) const [q, setQ] = useState('') const [jobId, setJobId] = useState('') @@ -325,7 +330,10 @@ function RecruiterCandidates() { assignedJobPostId: jobId || undefined, }), }) - const jobsQuery = useQuery({ queryKey: qk.jobPosts.list(), queryFn: fetchJobs }) + const jobsQuery = useQuery({ + queryKey: qk.jobPosts.list({ createdBy: unscoped ? 'all' : (user?.id ?? null) }), + queryFn: () => fetchJobs({ createdBy: unscoped ? undefined : user?.id }), + }) const candidates = useMemo(() => candidatesQuery.data?.rows ?? [], [candidatesQuery.data]) const jobsById = useMemo( () => Object.fromEntries((jobsQuery.data ?? []).map((j) => [j.id, j])), @@ -619,7 +627,9 @@ function RecruiterCandidates() { {candidates.length ? 'Try a different search, job, stage, or band filter.' - : 'Import a CV or add a candidate to see score, stage, and recruiter on this table.'} + : unscoped + ? 'Import a CV or add a candidate to see score, stage, and recruiter on this table.' + : 'Candidates appear here when they are allocated to a job you created.'} diff --git a/frontend/src/screens/Rbac.jsx b/frontend/src/screens/Rbac.jsx index 02c1fc5..75da20c 100644 --- a/frontend/src/screens/Rbac.jsx +++ b/frontend/src/screens/Rbac.jsx @@ -7,13 +7,11 @@ now come from GET /permission-tags/fetch, so a 105th tag appears here without a frontend change, and a renamed module cannot silently shift every column. - THE SAVE BUTTON IS STILL NOT A PER-CELL TOGGLE, AND THAT IS DELIBERATE. The - backend grants access through BUNDLES — `roles.permissions` is a list of - permission-bundle ids, and `effective_permissions` is the resolved union. An - arbitrary per-tag set is not expressible through PUT /roles/update, so the - matrix stays read-only and the editable thing is the bundle set, which is - what actually determines access. Editing bundles writes real permissions; - a per-cell grid would have to lie about what it saved. + Tick a cell to grant or revoke that module.action tag, then Save. Save + writes the exact set onto a per-role overlay bundle (PUT /roles/matrix/update) + so shared system bundles are not mutated. The Edit modal still assigns + named bundles; the next Save of this grid replaces the role's bundle list + with that overlay. Delete is soft server-side and refuses system roles (Role.delete_role), so the button is hidden on those rather than offered and rejected. @@ -26,6 +24,7 @@ import Modal from '../ui/Modal' import PageHeader from '../ui/PageHeader' import { Badge, EmptyState, FieldError, Icon } from '../ui/primitives' import { useToast } from '../ui/Toast' +import { useAuth } from '../auth/AuthContext' import { useFormState } from '../components/AuthLayout' import { qk } from '../lib/queryKeys' import { friendlyAuthError } from '../lib/errors' @@ -45,11 +44,14 @@ function humanise(slug) { export default function Rbac() { const { toast } = useToast() + const { can } = useAuth() const qc = useQueryClient() const [selectedId, setSelectedId] = useState(null) const [creating, setCreating] = useState(false) const [editing, setEditing] = useState(null) const [confirmDelete, setConfirmDelete] = useState(null) + const [draft, setDraft] = useState(() => new Set()) + const canEdit = can('rbac_users.edit') const rolesQuery = useQuery({ queryKey: qk.roles.list(), @@ -95,6 +97,38 @@ export default function Rbac() { const role = roles.find((r) => r.id === selectedId) ?? roles[0] const granted = useMemo(() => new Set(role?.effective_permissions ?? []), [role]) + const grantKey = (role?.effective_permissions ?? []).slice().sort().join('\0') + + useEffect(() => { + setDraft(new Set(role?.effective_permissions ?? [])) + }, [role?.id, grantKey]) + + const tagIdByName = useMemo(() => { + const map = new Map() + for (const t of tags) { + const name = t.tag_name || (t.module && t.action ? `${t.module}.${t.action}` : null) + if (name && t.id != null) map.set(name, t.id) + } + return map + }, [tags]) + + const dirty = useMemo(() => { + if (draft.size !== granted.size) return true + for (const tag of draft) { + if (!granted.has(tag)) return true + } + return false + }, [draft, granted]) + + const toggleTag = (tag) => { + if (!canEdit) return + setDraft((prev) => { + const next = new Set(prev) + if (next.has(tag)) next.delete(tag) + else next.add(tag) + return next + }) + } const invalidate = () => qc.invalidateQueries({ queryKey: qk.roles.all() }) @@ -129,6 +163,27 @@ export default function Rbac() { onError: (err) => toast(friendlyAuthError(err, 'Could not delete the role.'), 'error'), }) + const saveMatrix = useMutation({ + mutationFn: ({ id, permissionTags }) => rolesApi.updateRoleMatrix(id, permissionTags), + onSuccess: (res) => { + const next = res?.data?.effective_permissions + if (Array.isArray(next)) setDraft(new Set(next)) + invalidate() + toast('Permissions saved', 'success') + }, + onError: (err) => toast(friendlyAuthError(err, 'Could not save permissions.'), 'error'), + }) + + const saveRoleMatrix = () => { + if (!role || !canEdit) return + const permissionTags = [] + for (const name of draft) { + const id = tagIdByName.get(name) + if (id != null) permissionTags.push(id) + } + saveMatrix.mutate({ id: role.id, permissionTags }) + } + const totalTags = tags.length return ( @@ -226,12 +281,12 @@ export default function Rbac() {

- These are the role’s resolved permissions. Access is granted - through bundles + Tick a module action to grant it, then Save. Grants are stored on this + role’s overlay bundle {role.bundles?.length - ? ` — ${role.bundles.map((b) => b.name ?? b).join(', ')}` - : ' — none assigned yet'} - . Edit the bundle set to change what this role can do. + ? ` (currently ${role.bundles.map((b) => b.name ?? b).join(', ')})` + : ''} + . Shared system bundles are not rewritten.

{tagsQuery.isPending && ( @@ -245,6 +300,7 @@ export default function Rbac() { )} {tagsQuery.isSuccess && modules.length > 0 && ( + <>
@@ -262,16 +318,20 @@ export default function Rbac() { if (!tagSet.has(tag)) { return } - const on = granted.has(tag) + const on = draft.has(tag) return ( ) })} @@ -280,6 +340,17 @@ export default function Rbac() {
· - toggleTag(tag)} > - +
+
+ +
+ )}
diff --git a/frontend/src/styles/styles.css b/frontend/src/styles/styles.css index da77291..36a4365 100644 --- a/frontend/src/styles/styles.css +++ b/frontend/src/styles/styles.css @@ -1320,10 +1320,12 @@ canvas { width: 100%; max-width: 100%; display: block; } .rbac-matrix th:first-child { text-align: left; padding-left: 16px; } .rbac-matrix td { padding: 10px 8px; border-bottom: 1px solid var(--border); text-align: center; } .rbac-matrix td:first-child { text-align: left; padding-left: 16px; font-weight: 600; } -.perm-check { width: 22px; height: 22px; border-radius: 6px; border: 2px solid var(--border-strong); display: inline-grid; place-items: center; cursor: pointer; transition: .12s; } +.perm-check { width: 22px; height: 22px; border-radius: 6px; border: 2px solid var(--border-strong); display: inline-grid; place-items: center; cursor: pointer; transition: .12s; background: transparent; padding: 0; font: inherit; color: inherit; } .perm-check.on { background: var(--primary); border-color: var(--primary); color: var(--primary-fg); } .perm-check.on svg { width: 13px; height: 13px; } .perm-check:not(.on) svg { display: none; } +.perm-check:disabled { cursor: not-allowed; opacity: 0.55; } +.rbac-save { display: flex; justify-content: flex-end; margin-top: 16px; } /* AI Assistant chat */ .chat-wrap { display: flex; flex-direction: column; height: calc(100vh - 190px); height: calc(100dvh - 190px); }