approved permission #70

Merged
ahmed.mujtaba merged 2 commits from SQS_BROKER into main 2026-09-04 13:00:19 +00:00
10 changed files with 240 additions and 52 deletions

View File

@ -736,6 +736,7 @@ async def fetch_job_posts(
skip=skip,
ids=id_list,
active_only=active_only,
current_user=current_user,
)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
@ -865,7 +866,7 @@ async def fetch_jobs(
data,total=await service.fetch_jobs(
search=search,department=department,requisition_status=requisition_status,
employment_type=employment_type,hiring_manager_id=hiring_manager_id,
top=top,skip=skip,active_only=active_only,
top=top,skip=skip,active_only=active_only,current_user=current_user,
)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
@ -891,7 +892,7 @@ async def export_jobs(
data,_=await service.fetch_jobs(
search=search,department=department,requisition_status=requisition_status,
employment_type=employment_type,hiring_manager_id=hiring_manager_id,
top=None,skip=0,active_only=active_only,
top=None,skip=0,active_only=active_only,current_user=current_user,
)
filename=f"jobs-export-{datetime.now(timezone.utc).strftime('%Y-%m-%d')}.xlsx"
return Response(
@ -947,18 +948,19 @@ async def fetch_candidate(
assigned_job_post_id:UUID=Query(None),
offset:int=Query(0,ge=0),
search:str=Query(None),
created_by:Optional[bool]=Query(False),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=CandidateView(session=session)
data=await service.get_candidate(
user_id=user_id,limit=limit,offset=offset,search=search,current_user=current_user,assigned_job_post_id=assigned_job_post_id,
user_id=user_id,limit=limit,offset=offset,search=search,current_user=current_user,assigned_job_post_id=assigned_job_post_id,created_by=created_by,
)
total=await service.count_candidates(
user_id=user_id,search=search,current_user=current_user,assigned_job_post_id=assigned_job_post_id,
user_id=user_id,search=search,current_user=current_user,assigned_job_post_id=assigned_job_post_id,created_by=created_by,
) if isinstance(data,list) else 1
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
@ -971,12 +973,13 @@ async def fetch_candidate(
async def update_candidate(
user_id:str=Query(...),
payload:CandidateUpdate=...,
created_by:Optional[bool]=Query(False),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
session: AsyncSession = Depends(get_session),
):
try:
service=CandidateView(session=session)
data=await service.update_candidate(user_id,payload.model_dump(exclude_unset=True),current_user)
data=await service.update_candidate(user_id,payload.model_dump(exclude_unset=True),current_user,created_by=created_by)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
@ -1074,12 +1077,13 @@ async def update_interview(
async def fetch_notes(
note_id:str=Query(None),
user_id:str=Query(None),
created_by:Optional[bool]=Query(False),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=Note(session=session)
data=await service.get_note(note_id=note_id,user_id=user_id,current_user=current_user)
data=await service.get_note(note_id=note_id,user_id=user_id,current_user=current_user,created_by=created_by)
total=1 if isinstance(data,dict) else len(data)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
@ -1091,12 +1095,13 @@ async def fetch_notes(
@router.post("/notes/create")
async def create_note(
payload:NoteCreate,
created_by:Optional[bool]=Query(False),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
session: AsyncSession = Depends(get_session),
):
try:
service=Note(session=session)
data=await service.create_note(payload.model_dump(exclude_unset=True),current_user)
data=await service.create_note(payload.model_dump(exclude_unset=True),current_user,created_by=created_by)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
@ -1108,12 +1113,13 @@ async def create_note(
async def update_note(
note_id:str=Query(...),
payload:NoteUpdate=...,
created_by:Optional[bool]=Query(False),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
session: AsyncSession = Depends(get_session),
):
try:
service=Note(session=session)
data=await service.update_note(note_id,payload.model_dump(exclude_unset=True),current_user)
data=await service.update_note(note_id,payload.model_dump(exclude_unset=True),current_user,created_by=created_by)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise

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,sees_all_candidates
from users.permissions import is_hiring_manager,sees_all_candidates,scopes_to_own_requisitions
from employment_agent.plugins import parse_phone
load_dotenv()
@ -78,23 +78,26 @@ 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):
async def owned_job_ids_for_candidate_scope(session,current_user,created_by=False):
"""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.
requisitions.configure (or hiring-manager portal) jobs on their requisitions
/ assigned hiring_manager_id. Recruiter assignment on the job does not hide
those candidates. candidates.manage or admin None (all applications).
Otherwise current_recruiter_id when set, else created_by. Never role_id.
created_by=True skips current_recruiter_id and matches job_posts.created_by
to the session user (ignored when the user is requisition-scoped).
"""
if is_hiring_manager(current_user):
if scopes_to_own_requisitions(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"))
return await JobPosts.ids_for_creator(session,current_user.get("id"),created_by=created_by)
async def job_post_ids_for_candidate_list(session,current_user,assigned_job_post_id=None):
async def job_post_ids_for_candidate_list(session,current_user,assigned_job_post_id=None,created_by=False):
"""None = unscoped list. [] = nothing visible. Else UUID list for the query."""
owned=await owned_job_ids_for_candidate_scope(session,current_user)
owned=await owned_job_ids_for_candidate_scope(session,current_user,created_by=created_by)
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
@ -104,18 +107,18 @@ async def job_post_ids_for_candidate_list(session,current_user,assigned_job_post
def _scope_detail(current_user):
if is_hiring_manager(current_user):
if scopes_to_own_requisitions(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,
session,current_user,*,user_id=None,job_post_id=None,inbox_id=None,manual_id=None,created_by=False,
):
"""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):
"""Row access: requisition-owned jobs, unscoped (manage/admin), or jobs this user created."""
if sees_all_candidates(current_user) and not scopes_to_own_requisitions(current_user):
return
owned=await owned_job_ids_for_candidate_scope(session,current_user)
owned=await owned_job_ids_for_candidate_scope(session,current_user,created_by=created_by)
owned=set(owned or [])
detail=_scope_detail(current_user)
if not owned:
@ -819,19 +822,19 @@ class CandidateView:
cap=max(1,int(limit or 50))
return merged[start:start+cap],total
async def get_candidate(self,user_id=None,limit=10,offset=0,search=None,current_user=None,assigned_job_post_id=None):
async def get_candidate(self,user_id=None,limit=10,offset=0,search=None,current_user=None,assigned_job_post_id=None,created_by=False):
try:
if not user_id and is_hiring_manager(current_user):
raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL)
if user_id:
await assert_manager_candidate_access(
self.session,current_user,user_id=user_id,
self.session,current_user,user_id=user_id,created_by=created_by,
)
detail=bool(user_id)
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,
self.session,current_user,assigned_job_post_id=assigned_job_post_id,created_by=created_by,
)
if list_job_ids is not None and not list_job_ids:
return []
@ -841,7 +844,7 @@ class CandidateView:
)
if detail:
records=rows if isinstance(rows,list) else ([rows] if rows else [])
owned=await owned_job_ids_for_candidate_scope(self.session,current_user)
owned=await owned_job_ids_for_candidate_scope(self.session,current_user,created_by=created_by)
if records and owned is not None:
owned_set=set(owned)
kept=[]
@ -947,12 +950,12 @@ class CandidateView:
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
async def count_candidates(self,user_id=None,search=None,current_user=None,assigned_job_post_id=None):
async def count_candidates(self,user_id=None,search=None,current_user=None,assigned_job_post_id=None,created_by=False):
try:
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,
self.session,current_user,assigned_job_post_id=assigned_job_post_id,created_by=created_by,
)
return await Inbox.count_candidate_profiles(
session=self.session,user_id=user_id,search=search,job_post_ids=job_post_ids,
@ -962,11 +965,11 @@ class CandidateView:
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
async def update_candidate(self,user_id,payload,current_user=None):
async def update_candidate(self,user_id,payload,current_user=None,created_by=False):
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)
await assert_manager_candidate_access(self.session,current_user,user_id=user_id,created_by=created_by)
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

@ -2,7 +2,7 @@ import uuid
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Optional
from sqlalchemy import DateTime, JSON, Index, String, case, cast, func, or_, union_all
from sqlalchemy import DateTime, JSON, Index, String, and_, case, cast, func, or_, union_all
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from sqlmodel import Field, Relationship, SQLModel, select
@ -209,6 +209,7 @@ class JobPosts(SQLModel, table=True):
requisition_status: str | None = None,
employment_type: str | None = None,
hiring_manager_id: uuid.UUID | None = None,
restrict_ids: list | None = None,
):
if ids:
rows = await cls.get_by_ids(session, ids, active_only=active_only)
@ -219,6 +220,15 @@ class JobPosts(SQLModel, table=True):
statement = statement.where(cls.is_active == True, cls.is_deleted == False) # noqa: E712
elif not include_deleted:
statement = statement.where(cls.is_deleted == False) # noqa: E712
if restrict_ids is not None:
uids = []
for raw in restrict_ids:
uid = raw if isinstance(raw, uuid.UUID) else cls._as_uuid(raw)
if uid is not None:
uids.append(uid)
if not uids:
return [], 0
statement = statement.where(cls.id.in_(uids))
if search:
like = f"%{search.strip()}%"
statement = statement.where(
@ -482,15 +492,27 @@ class JobPosts(SQLModel, table=True):
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."""
async def ids_for_creator(cls, session: AsyncSession, user_id, created_by=False):
"""Jobs this recruiter should see on Candidates (when they lack
candidates.manage). created_by=True created_by = session user only.
Otherwise: current_recruiter_id when set, else created_by."""
uid = cls._as_uuid(user_id)
if uid is None:
return []
if created_by:
result = await session.execute(
select(cls.id).where(
cls.created_by == uid,
cls.is_deleted == False, # noqa: E712
)
)
return list(result.scalars().all())
result = await session.execute(
select(cls.id).where(
cls.created_by == uid,
or_(
and_(cls.current_recruiter_id.is_not(None), cls.current_recruiter_id == uid),
and_(cls.current_recruiter_id.is_(None), cls.created_by == uid),
),
cls.is_deleted == False, # noqa: E712
)
)

View File

@ -223,7 +223,24 @@ class JobPost:
except (httpx.HTTPError,BufferError,RuntimeError) as e:
raise HTTPException(status_code=502,detail="Failed to list Buffer channels") from e
async def fetch_job_posts(self,search=None,top=None,skip=0,ids=None,active_only=True):
async def _restrict_ids_for_requisition_scope(self,current_user):
"""None = unscoped. Empty list = no jobs. Else owned job-post ids."""
from users.permissions import scopes_to_own_requisitions
if not scopes_to_own_requisitions(current_user):
return None
return await JobPosts.ids_for_manager(self.session,current_user.get("id") if current_user else None)
async def fetch_job_posts(self,search=None,top=None,skip=0,ids=None,active_only=True,current_user=None):
restrict=await self._restrict_ids_for_requisition_scope(current_user)
if restrict is not None:
owned={str(i) for i in restrict}
if ids:
ids=[i for i in ids if str(i) in owned]
if not ids:
return [],0
restrict=None
elif not restrict:
return [],0
rows,total=await JobPosts.fetch_job_posts(
self.session,
search=search,
@ -231,6 +248,7 @@ class JobPost:
skip=skip,
ids=ids,
active_only=active_only,
restrict_ids=restrict,
)
return [serialize_job_post(r) for r in rows],total
@ -274,7 +292,11 @@ class JobPost:
]
async def fetch_jobs(self,search=None,department=None,requisition_status=None,
employment_type=None,hiring_manager_id=None,top=None,skip=0,active_only=True):
employment_type=None,hiring_manager_id=None,top=None,skip=0,active_only=True,
current_user=None):
restrict=await self._restrict_ids_for_requisition_scope(current_user)
if restrict is not None and not restrict:
return [],0
hm_uid=None
if hiring_manager_id:
hm_uid=JobPosts._as_uuid(hiring_manager_id)
@ -284,6 +306,7 @@ class JobPost:
self.session,search=search,top=top,skip=skip,active_only=active_only,
department=department,requisition_status=requisition_status,
employment_type=employment_type,hiring_manager_id=hm_uid,
restrict_ids=restrict,
)
names=await Users.names_by_ids(
self.session,

View File

@ -15,13 +15,13 @@ class Note:
async def _load(self,record_id):
return await Notes.get_note_by_id(self.session,record_id)
async def get_note(self,note_id=None,user_id=None,current_user=None):
async def get_note(self,note_id=None,user_id=None,current_user=None,created_by=False):
if note_id:
row=await self._load(note_id)
if not row:
raise HTTPException(status_code=404,detail="Note not found")
await assert_manager_candidate_access(
self.session,current_user,user_id=row.user_id,
self.session,current_user,user_id=row.user_id,created_by=created_by,
)
return serialize_note(row)
if not user_id:
@ -29,11 +29,11 @@ class Note:
uid=Notes._as_uuid(user_id)
if uid is None:
raise HTTPException(status_code=400,detail="Invalid user_id")
await assert_manager_candidate_access(self.session,current_user,user_id=uid)
await assert_manager_candidate_access(self.session,current_user,user_id=uid,created_by=created_by)
rows=await Notes.get_notes_by_user(self.session,uid)
return [serialize_note(r) for r in rows]
async def create_note(self,payload,current_user):
async def create_note(self,payload,current_user,created_by=False):
fields={
"note":payload.get("note") or "",
"user_id":payload.get("user_id"),
@ -42,7 +42,7 @@ class Note:
if not fields["user_id"]:
raise HTTPException(status_code=400,detail="user_id is required")
await assert_manager_candidate_access(
self.session,current_user,user_id=fields["user_id"],
self.session,current_user,user_id=fields["user_id"],created_by=created_by,
)
row=await Notes.insert_note(self.session,fields)
await HistoryRecorder(self.session).record(
@ -54,7 +54,7 @@ class Note:
row=await self._load(row.id)
return serialize_note(row)
async def update_note(self,note_id,payload,current_user=None):
async def update_note(self,note_id,payload,current_user=None,created_by=False):
fields={k:v for k,v in payload.items() if v is not None and k in ("note",)}
if not fields:
raise HTTPException(status_code=400,detail="No fields to update")
@ -62,7 +62,7 @@ class Note:
if not before:
raise HTTPException(status_code=404,detail="Note not found")
await assert_manager_candidate_access(
self.session,current_user,user_id=before.user_id,
self.session,current_user,user_id=before.user_id,created_by=created_by,
)
old_note=before.note or ""
row=await Notes.update_note(self.session,note_id,fields)

View File

@ -247,6 +247,25 @@ def sees_all_candidates(current_user: dict | None) -> bool:
return PermissionTag.CANDIDATES_MANAGE.value in granted
def scopes_to_own_requisitions(current_user: dict | None) -> bool:
"""Jobs and candidates limited to requisitions this user created (or is assigned).
Opt-in from Access Control: tick Requisitions Configure
(`requisitions.configure`). That is independent of Create (who may open a
requisition) and of Manage (which already means admin and unscopes).
Hiring-manager portal roles still use this scope so the locked sidebar
keeps a matching candidate list. candidates.manage / admin still see every
job. Do not key custom roles off a name such as AI_TEAM_MANAGER.
"""
if is_hiring_manager(current_user):
return True
if is_admin(current_user) or sees_all_candidates(current_user):
return False
granted = (current_user or {}).get("permissions") or []
return PermissionTag.REQUISITIONS_CONFIGURE.value in granted
def has_permission(
granted: set[str] | list[str] | tuple[str, ...],
*required: PermissionTag,

View File

@ -0,0 +1,81 @@
/**
* scopesToOwnRequisitions mirrors backend users.permissions.scopes_to_own_requisitions.
*
* node permissions-scope.test.mjs
*/
import {
isAdmin,
isHiringManager,
seesAllCandidates,
scopesToOwnRequisitions,
} from './src/auth/permissions.js'
let failed = 0
function ok(name, cond, extra) {
if (cond) {
console.log(`ok ${name}`)
if (extra) console.log(` ${extra}`)
} else {
failed += 1
console.log(`FAIL ${name}`)
if (extra) console.log(` ${extra}`)
}
}
const recruiter = {
id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
role_name: 'recruiter',
permissions: ['candidates.view', 'jobs.view'],
}
ok('recruiter is not requisition-scoped', !scopesToOwnRequisitions(recruiter))
ok('recruiter does not see all candidates', !seesAllCandidates(recruiter))
const custom = {
...recruiter,
role_name: 'AI_TEAM_MANAGER',
permissions: ['candidates.view', 'jobs.view', 'requisitions.create'],
}
ok('requisitions.create alone does not scope jobs/candidates', !scopesToOwnRequisitions(custom))
ok('custom role is not hiring-manager portal', !isHiringManager(custom))
ok('custom role is not admin', !isAdmin(custom))
ok(
'Access Control requisitions.configure enables the scope',
scopesToOwnRequisitions({
...custom,
permissions: ['candidates.view', 'jobs.view', 'requisitions.create', 'requisitions.configure'],
}),
)
const manage = {
...custom,
permissions: ['candidates.view', 'candidates.manage', 'requisitions.configure'],
}
ok(
'candidates.manage wins over requisitions.configure',
!scopesToOwnRequisitions(manage) && seesAllCandidates(manage),
)
ok(
'admin is not requisition-scoped',
!scopesToOwnRequisitions({ role_name: 'admin', permissions: ['requisitions.create'] }) &&
isAdmin({ role_name: 'admin' }),
)
ok(
'hiring_manager stays portal-locked and requisition-scoped',
isHiringManager({ role_name: 'hiring_manager' }) &&
scopesToOwnRequisitions({ role_name: 'hiring_manager', permissions: ['candidates.view'] }),
)
ok(
'requisitions.manage is admin, not this scope',
isAdmin({ role_name: 'ops_lead', permissions: ['requisitions.manage'] }) &&
!scopesToOwnRequisitions({ role_name: 'ops_lead', permissions: ['requisitions.manage'] }),
)
if (failed) {
console.log(`\n${failed} failed`)
process.exit(1)
}
console.log('\nall passed')

View File

@ -62,3 +62,15 @@ export function seesAllCandidates(user) {
if (isAdmin(user)) return true
return (user?.permissions || []).includes('candidates.manage')
}
/** Jobs/candidates limited to requisitions this user created (or is assigned).
Matches backend `scopes_to_own_requisitions`. Custom roles opt in from
Access Control by ticking Requisitions Configure (`requisitions.configure`),
not Create. `requisitions.manage` already means admin. Do not expand
`isHiringManager` for this; that helper still locks the sidebar. */
export function scopesToOwnRequisitions(user) {
if (isHiringManager(user)) return true
if (isAdmin(user) || seesAllCandidates(user)) return false
return (user?.permissions || []).includes('requisitions.configure')
}

View File

@ -4,8 +4,10 @@
Recruiter rows come from GET /candidate/fetch (inbox + manual), one row
per application, so score / stage / job / recruiter have a source.
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
owns as recruiter (or created). Tick Requisitions Configure in Access
Control to see candidates on jobs opened from that user's requisitions;
recruiter assignment on the job does not hide them. Hiring managers use GET
/candidate/manager/fetch (same job chain). Adding a candidate
still goes through CV Import or the Add Candidate modal both run the CV
through persisted ATS scoring.
============================================================ */
@ -20,7 +22,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, seesAllCandidates } from '../auth/permissions'
import { isHiringManager, seesAllCandidates, scopesToOwnRequisitions } from '../auth/permissions'
import CandidateProfile from './CandidateProfile'
import { useJobTitles } from './ScoredCandidateProfile'
import { qk } from '../lib/queryKeys'
@ -297,6 +299,7 @@ function RecruiterCandidates() {
const { user } = useAuth()
const updateCandidates = useSeedMutation('candidates')
const unscoped = seesAllCandidates(user)
const requisitionScoped = scopesToOwnRequisitions(user)
const [q, setQ] = useState('')
const [jobId, setJobId] = useState('')
@ -331,8 +334,12 @@ function RecruiterCandidates() {
}),
})
const jobsQuery = useQuery({
queryKey: qk.jobPosts.list({ createdBy: unscoped ? 'all' : (user?.id ?? null) }),
queryFn: () => fetchJobs({ createdBy: unscoped ? undefined : user?.id }),
queryKey: qk.jobPosts.list({
createdBy: unscoped ? 'all' : requisitionScoped ? 'requisition' : (user?.id ?? null),
}),
queryFn: () => fetchJobs({
createdBy: unscoped || requisitionScoped ? undefined : user?.id,
}),
})
const candidates = useMemo(() => candidatesQuery.data?.rows ?? [], [candidatesQuery.data])
const jobsById = useMemo(
@ -629,7 +636,9 @@ function RecruiterCandidates() {
? 'Try a different search, job, stage, or band filter.'
: 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.'}
: requisitionScoped
? 'Candidates appear here when they are allocated to jobs opened from your requisitions.'
: 'Candidates appear here when they are allocated to a job you created.'}
</EmptyState>
</td>
</tr>

View File

@ -42,6 +42,16 @@ function humanise(slug) {
.join(' ')
}
/** Extra tooltip copy for tags whose matrix cell is an opt-in, not a screen. */
const TAG_HELP = {
'requisitions.configure':
'Limit Jobs and Candidates to requisitions this user created. Independent of Create. Untick to use the default recruiter list.',
'candidates.manage':
'See every candidate, not only jobs this user owns.',
'requisitions.manage':
'Org-wide requisition list (admin).',
}
export default function Rbac() {
const { toast } = useToast()
const { can } = useAuth()
@ -286,7 +296,9 @@ export default function Rbac() {
{role.bundles?.length
? ` (currently ${role.bundles.map((b) => b.name ?? b).join(', ')})`
: ''}
. Shared system bundles are not rewritten.
. Shared system bundles are not rewritten.{' '}
<b>Requisitions Configure</b> limits Jobs and Candidates to
requisitions that user created; it is not implied by Create.
</p>
{tagsQuery.isPending && (
@ -319,12 +331,13 @@ export default function Rbac() {
return <td key={action}><span className="text-muted">·</span></td>
}
const on = draft.has(tag)
const help = TAG_HELP[tag]
return (
<td key={action}>
<button
type="button"
className={`perm-check${on ? ' on' : ''}`}
title={tag}
title={help ? `${tag}${help}` : tag}
disabled={!canEdit || saveMatrix.isPending}
aria-pressed={on}
aria-label={`${humanise(mod)} ${humanise(action)}: ${on ? 'granted' : 'not granted'}`}