pull/68/head
ahmed.mujtaba 2026-09-04 16:10:56 +05:00
parent 6ebdd7c5ba
commit 0558af52c4
14 changed files with 321 additions and 122 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = []

View File

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

View File

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

View File

@ -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() {
<EmptyState title={candidates.length ? 'No matches' : 'No applications yet'}>
{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.'}
</EmptyState>
</td>
</tr>

View File

@ -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() {
<div className="card-body">
<p className="text-muted text-sm" style={{ marginBottom: 12 }}>
<Icon name="lock" /> These are the roles <b>resolved</b> permissions. Access is granted
through bundles
Tick a module action to grant it, then <b>Save</b>. Grants are stored on this
roles 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.
</p>
{tagsQuery.isPending && (
@ -245,6 +300,7 @@ export default function Rbac() {
</EmptyState>
)}
{tagsQuery.isSuccess && modules.length > 0 && (
<>
<div className="table-wrap">
<table className="rbac-matrix">
<thead>
@ -262,16 +318,20 @@ export default function Rbac() {
if (!tagSet.has(tag)) {
return <td key={action}><span className="text-muted">·</span></td>
}
const on = granted.has(tag)
const on = draft.has(tag)
return (
<td key={action}>
<span
<button
type="button"
className={`perm-check${on ? ' on' : ''}`}
title={tag}
disabled={!canEdit || saveMatrix.isPending}
aria-pressed={on}
aria-label={`${humanise(mod)} ${humanise(action)}: ${on ? 'granted' : 'not granted'}`}
onClick={() => toggleTag(tag)}
>
<Icon name="check" />
</span>
</button>
</td>
)
})}
@ -280,6 +340,17 @@ export default function Rbac() {
</tbody>
</table>
</div>
<div className="rbac-save">
<button
type="button"
className="btn btn-primary"
disabled={!canEdit || !dirty || saveMatrix.isPending}
onClick={saveRoleMatrix}
>
<Icon name="check" /> {saveMatrix.isPending ? 'Saving…' : 'Save'}
</button>
</div>
</>
)}
</div>
</>

View File

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