Merge pull request 'SQS_BROKER' (#68) from SQS_BROKER into main
Deploy to S3 / deploy (push) Successful in 33s
Details
Deploy to S3 / deploy (push) Successful in 33s
Details
Reviewed-on: #68pull/69/head^2
commit
6a1f5ba9fa
|
|
@ -214,8 +214,10 @@ class Inbox(SQLModel, table=True):
|
||||||
return or_(Users.name.ilike(pattern), Users.email.ilike(pattern))
|
return or_(Users.name.ilike(pattern), Users.email.ilike(pattern))
|
||||||
|
|
||||||
@classmethod
|
@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:
|
try:
|
||||||
|
if job_post_ids is not None and not list(job_post_ids):
|
||||||
|
return []
|
||||||
options=[selectinload(cls.messages)]
|
options=[selectinload(cls.messages)]
|
||||||
if user_id:
|
if user_id:
|
||||||
options.extend([
|
options.extend([
|
||||||
|
|
@ -234,6 +236,11 @@ class Inbox(SQLModel, table=True):
|
||||||
qry = qry.where(cls.user_id == user_id)
|
qry = qry.where(cls.user_id == user_id)
|
||||||
if search:
|
if search:
|
||||||
qry = qry.where(cls._candidate_search_filter(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
|
# 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.
|
# boundary can't drop or repeat a row when created_at collides.
|
||||||
qry = qry.order_by(cls.created_at.desc(), cls.id.desc())
|
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))
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
@classmethod
|
@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."""
|
"""Result-set size for the same predicate get_candidate_profile pages over."""
|
||||||
try:
|
try:
|
||||||
|
if job_post_ids is not None and not list(job_post_ids):
|
||||||
|
return 0
|
||||||
qry = (
|
qry = (
|
||||||
select(func.count())
|
select(func.count())
|
||||||
.select_from(cls)
|
.select_from(cls)
|
||||||
|
|
@ -261,6 +270,11 @@ class Inbox(SQLModel, table=True):
|
||||||
qry = qry.where(cls.user_id == user_id)
|
qry = qry.where(cls.user_id == user_id)
|
||||||
if search:
|
if search:
|
||||||
qry = qry.where(cls._candidate_search_filter(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)
|
result = await session.execute(qry)
|
||||||
return result.scalar_one()
|
return result.scalar_one()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -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})
|
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
|
||||||
|
|
@ -390,16 +390,20 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
|
|
||||||
@classmethod
|
@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)."""
|
"""Newest applications with a user + job for Talent Pool (manual / form)."""
|
||||||
from users.models import Users
|
from users.models import Users
|
||||||
|
|
||||||
|
if job_post_ids is not None and not list(job_post_ids):
|
||||||
|
return []
|
||||||
statement = (
|
statement = (
|
||||||
select(cls)
|
select(cls)
|
||||||
.join(Users, cls.user_id == Users.id)
|
.join(Users, cls.user_id == Users.id)
|
||||||
.where(cls.user_id.is_not(None), cls.job_post_id.is_not(None))
|
.where(cls.user_id.is_not(None), cls.job_post_id.is_not(None))
|
||||||
.order_by(cls.created_at.desc())
|
.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:
|
if search:
|
||||||
like = f"%{search.strip()}%"
|
like = f"%{search.strip()}%"
|
||||||
statement = statement.where(
|
statement = statement.where(
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ from job.history.views import HistoryRecorder
|
||||||
from job.notes.serializers import serialize_note
|
from job.notes.serializers import serialize_note
|
||||||
from job.candidate.plugins import extract_candidate_email
|
from job.candidate.plugins import extract_candidate_email
|
||||||
from users.models import Users
|
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
|
from employment_agent.plugins import parse_phone
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
@ -42,6 +42,7 @@ MANUAL_UPLOAD_TO_ADDRESS=os.getenv(
|
||||||
"MANUAL_UPLOAD_TO_ADDRESS","manual-cv-upload@hr-ats.local"
|
"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"
|
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):
|
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
|
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(
|
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,
|
||||||
):
|
):
|
||||||
"""Hiring managers may only touch applications on jobs they own."""
|
"""Row access: hiring-manager jobs, unscoped (manage/admin), or jobs this user created."""
|
||||||
if not is_hiring_manager(current_user):
|
if not is_hiring_manager(current_user) and sees_all_candidates(current_user):
|
||||||
return
|
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:
|
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
|
job_id=JobPosts._as_uuid(job_post_id) if job_post_id is not None else None
|
||||||
uid=user_id
|
uid=user_id
|
||||||
if job_id is None and (inbox_id is not None or manual_id is not None):
|
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)
|
candidate_jobs=await assigned_job_ids_for_user(session,uid)
|
||||||
if candidate_jobs & owned:
|
if candidate_jobs & owned:
|
||||||
return
|
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:
|
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:
|
async def parse_linkedin_url_from_cv(resume_text) -> str | None:
|
||||||
|
|
@ -789,21 +823,32 @@ class CandidateView:
|
||||||
try:
|
try:
|
||||||
if not user_id and is_hiring_manager(current_user):
|
if not user_id and is_hiring_manager(current_user):
|
||||||
raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL)
|
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(
|
await assert_manager_candidate_access(
|
||||||
self.session,current_user,user_id=user_id,
|
self.session,current_user,user_id=user_id,
|
||||||
)
|
)
|
||||||
detail=bool(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:
|
if detail:
|
||||||
records=rows if isinstance(rows,list) else ([rows] if rows else [])
|
records=rows if isinstance(rows,list) else ([rows] if rows else [])
|
||||||
if records and is_hiring_manager(current_user):
|
owned=await owned_job_ids_for_candidate_scope(self.session,current_user)
|
||||||
owned=set(await JobPosts.ids_for_manager(self.session,current_user.get("id")))
|
if records and owned is not None:
|
||||||
|
owned_set=set(owned)
|
||||||
kept=[]
|
kept=[]
|
||||||
for rec in records:
|
for rec in records:
|
||||||
msg=getattr(rec,"messages",None)
|
msg=getattr(rec,"messages",None)
|
||||||
jid=getattr(msg,"assigned_job_post_id",None) if msg is not None else 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)
|
kept.append(rec)
|
||||||
if kept:
|
if kept:
|
||||||
return await self.attach_profile_detail(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)
|
manual=await Manual_UPLOAD_CANDIDATE.get_by_user_id(self.session,user_id)
|
||||||
if not manual:
|
if not manual:
|
||||||
return []
|
return []
|
||||||
if is_hiring_manager(current_user):
|
if owned is not None:
|
||||||
owned=set(await JobPosts.ids_for_manager(self.session,current_user.get("id")))
|
owned_set=set(owned)
|
||||||
if not manual.job_post_id or manual.job_post_id not in owned:
|
if not manual.job_post_id or manual.job_post_id not in owned_set:
|
||||||
raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL)
|
raise HTTPException(status_code=403,detail=_scope_detail(current_user))
|
||||||
user=await Users.get_user_by_id(self.session,user_id)
|
user=await Users.get_user_by_id(self.session,user_id)
|
||||||
job_post=None
|
job_post=None
|
||||||
if manual.job_post_id:
|
if manual.job_post_id:
|
||||||
|
|
@ -841,7 +886,7 @@ class CandidateView:
|
||||||
if not isinstance(inbox_payloads,list):
|
if not isinstance(inbox_payloads,list):
|
||||||
inbox_payloads=[inbox_payloads] if inbox_payloads else []
|
inbox_payloads=[inbox_payloads] if inbox_payloads else []
|
||||||
manual_rows=await Manual_UPLOAD_CANDIDATE.list_for_talent_pool(
|
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")}
|
seen={p.get("user_id") for p in inbox_payloads if p.get("user_id")}
|
||||||
manual_payloads=[]
|
manual_payloads=[]
|
||||||
|
|
@ -902,9 +947,16 @@ class CandidateView:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500,detail=str(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:
|
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:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -914,6 +966,7 @@ class CandidateView:
|
||||||
try:
|
try:
|
||||||
if not user_id:
|
if not user_id:
|
||||||
raise HTTPException(status_code=400,detail="user_id is required")
|
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}
|
fields={k:v for k,v in (payload or {}).items() if k in ("favorite","rating") and v is not None}
|
||||||
if not fields:
|
if not fields:
|
||||||
raise HTTPException(status_code=400,detail="favorite or rating is required")
|
raise HTTPException(status_code=400,detail="favorite or rating is required")
|
||||||
|
|
|
||||||
|
|
@ -481,6 +481,21 @@ class JobPosts(SQLModel, table=True):
|
||||||
out.append(row_id)
|
out.append(row_id)
|
||||||
return out
|
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
|
@classmethod
|
||||||
async def count_open_reqs_by_hiring_managers(cls, session: AsyncSession, user_ids):
|
async def count_open_reqs_by_hiring_managers(cls, session: AsyncSession, user_ids):
|
||||||
"""Open requisitions per hiring manager, keyed by users.id."""
|
"""Open requisitions per hiring manager, keyed by users.id."""
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,11 @@ class RolePermissionTagsUpdate(BaseModel):
|
||||||
is_active: bool | None = None
|
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")
|
@router.get("/roles/fetch")
|
||||||
async def fetch_roles(
|
async def fetch_roles(
|
||||||
current_user: dict = Depends(require_permission(PermissionTag.RBAC_USERS_VIEW)),
|
current_user: dict = Depends(require_permission(PermissionTag.RBAC_USERS_VIEW)),
|
||||||
|
|
@ -174,6 +179,23 @@ async def update_permission(
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500,detail=str(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")
|
@router.put("/roles/permission-tags/update")
|
||||||
async def update_role_permission_tags(
|
async def update_role_permission_tags(
|
||||||
payload: RolePermissionTagsUpdate,
|
payload: RolePermissionTagsUpdate,
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,46 @@ class Role:
|
||||||
updated = await Roles.update_role(self.session, int(record_id), fields)
|
updated = await Roles.update_role(self.session, int(record_id), fields)
|
||||||
return await self._role_payload(updated)
|
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):
|
async def delete_role(self, record_id):
|
||||||
role = await Roles.get_role_by_id(self.session, int(record_id))
|
role = await Roles.get_role_by_id(self.session, int(record_id))
|
||||||
if not role or role.is_deleted:
|
if not role or role.is_deleted:
|
||||||
|
|
|
||||||
|
|
@ -234,6 +234,19 @@ def is_admin(current_user: dict | None) -> bool:
|
||||||
return PermissionTag.REQUISITIONS_MANAGE.value in granted
|
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(
|
def has_permission(
|
||||||
granted: set[str] | list[str] | tuple[str, ...],
|
granted: set[str] | list[str] | tuple[str, ...],
|
||||||
*required: PermissionTag,
|
*required: PermissionTag,
|
||||||
|
|
|
||||||
|
|
@ -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 { downloadFile, fetchBlobUrl, request } from '../lib/apiClient'
|
||||||
import { toDate } from '../lib/format'
|
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 } = {}) {
|
export function listCandidateUsers({ roleId = 8, top = 10, skip = 0, assignedJobPostId } = {}) {
|
||||||
return request('/candidate/fetch/users', {
|
return request('/candidate/fetch/users', {
|
||||||
params: { role_id: roleId, top, skip, assigned_job_post_id: assignedJobPostId },
|
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) {
|
export function toCandidateUserView(row) {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
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 } = {}) {
|
export function list({ search, limit, offset, assignedJobPostId } = {}) {
|
||||||
return request('/candidate/fetch', {
|
return request('/candidate/fetch', {
|
||||||
params: { search, limit, offset, assigned_job_post_id: assignedJobPostId },
|
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) {
|
export function getByUserId(userId) {
|
||||||
return request('/candidate/fetch', { params: { user_id: 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 } = {}) {
|
export function listForManager({ limit = 50, offset = 0 } = {}) {
|
||||||
return request('/candidate/manager/fetch', { params: { limit, offset } })
|
return request('/candidate/manager/fetch', { params: { limit, offset } })
|
||||||
}
|
}
|
||||||
|
|
@ -331,11 +265,7 @@ export function toRows(res) {
|
||||||
return res?.data ? [res.data] : []
|
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) {
|
export function jobIdsOf(row) {
|
||||||
if (!row || typeof row !== 'object') return []
|
if (!row || typeof row !== 'object') return []
|
||||||
const ids = []
|
const ids = []
|
||||||
|
|
|
||||||
|
|
@ -34,3 +34,12 @@ export function listPermissionTags() {
|
||||||
export function updatePermissionTags(body) {
|
export function updatePermissionTags(body) {
|
||||||
return request('/roles/permission-tags/update', { method: 'PUT', 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 },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,3 +48,17 @@ export function isHiringManager(user) {
|
||||||
const name = (user?.role_name || '').trim().toLowerCase()
|
const name = (user?.role_name || '').trim().toLowerCase()
|
||||||
return name === HIRING_MANAGER_ROLE || name === 'manager'
|
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')
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@
|
||||||
|
|
||||||
Recruiter rows come from GET /candidate/fetch (inbox + manual), one row
|
Recruiter rows come from GET /candidate/fetch (inbox + manual), one row
|
||||||
per application, so score / stage / job / recruiter have a source.
|
per application, so score / stage / job / recruiter have a source.
|
||||||
Hiring managers use GET /candidate/manager/fetch (jobs on their
|
Without candidates.manage the server scopes that list to jobs the user
|
||||||
requisitions). Adding a candidate still goes through CV Import or the
|
created (job_posts.created_by). Hiring managers use GET
|
||||||
Add Candidate modal — both run the CV through persisted ATS scoring.
|
/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'
|
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 { Avatar, Badge, EmptyState, FieldError, Icon, ScoreChip, SkeletonRows } from '../ui/primitives'
|
||||||
import { useToast } from '../ui/Toast'
|
import { useToast } from '../ui/Toast'
|
||||||
import { useAuth } from '../auth/AuthContext'
|
import { useAuth } from '../auth/AuthContext'
|
||||||
import { isHiringManager } from '../auth/permissions'
|
import { isHiringManager, seesAllCandidates } from '../auth/permissions'
|
||||||
import CandidateProfile from './CandidateProfile'
|
import CandidateProfile from './CandidateProfile'
|
||||||
import { useJobTitles } from './ScoredCandidateProfile'
|
import { useJobTitles } from './ScoredCandidateProfile'
|
||||||
import { qk } from '../lib/queryKeys'
|
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 res = await candidatesApi.listJobs()
|
||||||
const rows = Array.isArray(res?.data) ? res.data : []
|
const rows = Array.isArray(res?.data) ? res.data : []
|
||||||
return rows
|
return rows
|
||||||
.filter((row) => row && row.id != null)
|
.filter((row) => row && row.id != null)
|
||||||
|
.filter((row) => !createdBy || String(row.created_by || '') === String(createdBy))
|
||||||
.map((row) => ({
|
.map((row) => ({
|
||||||
id: String(row.id),
|
id: String(row.id),
|
||||||
title: typeof row.title === 'string' && row.title.trim() ? row.title : 'Untitled',
|
title: typeof row.title === 'string' && row.title.trim() ? row.title : 'Untitled',
|
||||||
|
|
@ -291,7 +294,9 @@ function RecruiterCandidates() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { user } = useAuth()
|
||||||
const updateCandidates = useSeedMutation('candidates')
|
const updateCandidates = useSeedMutation('candidates')
|
||||||
|
const unscoped = seesAllCandidates(user)
|
||||||
|
|
||||||
const [q, setQ] = useState('')
|
const [q, setQ] = useState('')
|
||||||
const [jobId, setJobId] = useState('')
|
const [jobId, setJobId] = useState('')
|
||||||
|
|
@ -325,7 +330,10 @@ function RecruiterCandidates() {
|
||||||
assignedJobPostId: jobId || undefined,
|
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 candidates = useMemo(() => candidatesQuery.data?.rows ?? [], [candidatesQuery.data])
|
||||||
const jobsById = useMemo(
|
const jobsById = useMemo(
|
||||||
() => Object.fromEntries((jobsQuery.data ?? []).map((j) => [j.id, j])),
|
() => Object.fromEntries((jobsQuery.data ?? []).map((j) => [j.id, j])),
|
||||||
|
|
@ -619,7 +627,9 @@ function RecruiterCandidates() {
|
||||||
<EmptyState title={candidates.length ? 'No matches' : 'No applications yet'}>
|
<EmptyState title={candidates.length ? 'No matches' : 'No applications yet'}>
|
||||||
{candidates.length
|
{candidates.length
|
||||||
? 'Try a different search, job, stage, or band filter.'
|
? '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>
|
</EmptyState>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,11 @@
|
||||||
now come from GET /permission-tags/fetch, so a 105th tag appears here without
|
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.
|
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
|
Tick a cell to grant or revoke that module.action tag, then Save. Save
|
||||||
backend grants access through BUNDLES — `roles.permissions` is a list of
|
writes the exact set onto a per-role overlay bundle (PUT /roles/matrix/update)
|
||||||
permission-bundle ids, and `effective_permissions` is the resolved union. An
|
so shared system bundles are not mutated. The Edit modal still assigns
|
||||||
arbitrary per-tag set is not expressible through PUT /roles/update, so the
|
named bundles; the next Save of this grid replaces the role's bundle list
|
||||||
matrix stays read-only and the editable thing is the bundle set, which is
|
with that overlay.
|
||||||
what actually determines access. Editing bundles writes real permissions;
|
|
||||||
a per-cell grid would have to lie about what it saved.
|
|
||||||
|
|
||||||
Delete is soft server-side and refuses system roles (Role.delete_role), so
|
Delete is soft server-side and refuses system roles (Role.delete_role), so
|
||||||
the button is hidden on those rather than offered and rejected.
|
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 PageHeader from '../ui/PageHeader'
|
||||||
import { Badge, EmptyState, FieldError, Icon } from '../ui/primitives'
|
import { Badge, EmptyState, FieldError, Icon } from '../ui/primitives'
|
||||||
import { useToast } from '../ui/Toast'
|
import { useToast } from '../ui/Toast'
|
||||||
|
import { useAuth } from '../auth/AuthContext'
|
||||||
import { useFormState } from '../components/AuthLayout'
|
import { useFormState } from '../components/AuthLayout'
|
||||||
import { qk } from '../lib/queryKeys'
|
import { qk } from '../lib/queryKeys'
|
||||||
import { friendlyAuthError } from '../lib/errors'
|
import { friendlyAuthError } from '../lib/errors'
|
||||||
|
|
@ -45,11 +44,14 @@ function humanise(slug) {
|
||||||
|
|
||||||
export default function Rbac() {
|
export default function Rbac() {
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
const { can } = useAuth()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const [selectedId, setSelectedId] = useState(null)
|
const [selectedId, setSelectedId] = useState(null)
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
const [editing, setEditing] = useState(null)
|
const [editing, setEditing] = useState(null)
|
||||||
const [confirmDelete, setConfirmDelete] = useState(null)
|
const [confirmDelete, setConfirmDelete] = useState(null)
|
||||||
|
const [draft, setDraft] = useState(() => new Set())
|
||||||
|
const canEdit = can('rbac_users.edit')
|
||||||
|
|
||||||
const rolesQuery = useQuery({
|
const rolesQuery = useQuery({
|
||||||
queryKey: qk.roles.list(),
|
queryKey: qk.roles.list(),
|
||||||
|
|
@ -95,6 +97,38 @@ export default function Rbac() {
|
||||||
|
|
||||||
const role = roles.find((r) => r.id === selectedId) ?? roles[0]
|
const role = roles.find((r) => r.id === selectedId) ?? roles[0]
|
||||||
const granted = useMemo(() => new Set(role?.effective_permissions ?? []), [role])
|
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() })
|
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'),
|
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
|
const totalTags = tags.length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -226,12 +281,12 @@ export default function Rbac() {
|
||||||
|
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<p className="text-muted text-sm" style={{ marginBottom: 12 }}>
|
<p className="text-muted text-sm" style={{ marginBottom: 12 }}>
|
||||||
<Icon name="lock" /> These are the role’s <b>resolved</b> permissions. Access is granted
|
Tick a module action to grant it, then <b>Save</b>. Grants are stored on this
|
||||||
through bundles
|
role’s overlay bundle
|
||||||
{role.bundles?.length
|
{role.bundles?.length
|
||||||
? ` — ${role.bundles.map((b) => b.name ?? b).join(', ')}`
|
? ` (currently ${role.bundles.map((b) => b.name ?? b).join(', ')})`
|
||||||
: ' — none assigned yet'}
|
: ''}
|
||||||
. Edit the bundle set to change what this role can do.
|
. Shared system bundles are not rewritten.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{tagsQuery.isPending && (
|
{tagsQuery.isPending && (
|
||||||
|
|
@ -245,6 +300,7 @@ export default function Rbac() {
|
||||||
</EmptyState>
|
</EmptyState>
|
||||||
)}
|
)}
|
||||||
{tagsQuery.isSuccess && modules.length > 0 && (
|
{tagsQuery.isSuccess && modules.length > 0 && (
|
||||||
|
<>
|
||||||
<div className="table-wrap">
|
<div className="table-wrap">
|
||||||
<table className="rbac-matrix">
|
<table className="rbac-matrix">
|
||||||
<thead>
|
<thead>
|
||||||
|
|
@ -262,16 +318,20 @@ export default function Rbac() {
|
||||||
if (!tagSet.has(tag)) {
|
if (!tagSet.has(tag)) {
|
||||||
return <td key={action}><span className="text-muted">·</span></td>
|
return <td key={action}><span className="text-muted">·</span></td>
|
||||||
}
|
}
|
||||||
const on = granted.has(tag)
|
const on = draft.has(tag)
|
||||||
return (
|
return (
|
||||||
<td key={action}>
|
<td key={action}>
|
||||||
<span
|
<button
|
||||||
|
type="button"
|
||||||
className={`perm-check${on ? ' on' : ''}`}
|
className={`perm-check${on ? ' on' : ''}`}
|
||||||
title={tag}
|
title={tag}
|
||||||
|
disabled={!canEdit || saveMatrix.isPending}
|
||||||
|
aria-pressed={on}
|
||||||
aria-label={`${humanise(mod)} ${humanise(action)}: ${on ? 'granted' : 'not granted'}`}
|
aria-label={`${humanise(mod)} ${humanise(action)}: ${on ? 'granted' : 'not granted'}`}
|
||||||
|
onClick={() => toggleTag(tag)}
|
||||||
>
|
>
|
||||||
<Icon name="check" />
|
<Icon name="check" />
|
||||||
</span>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
@ -280,6 +340,17 @@ export default function Rbac() {
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -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 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 { 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; }
|
.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 { background: var(--primary); border-color: var(--primary); color: var(--primary-fg); }
|
||||||
.perm-check.on svg { width: 13px; height: 13px; }
|
.perm-check.on svg { width: 13px; height: 13px; }
|
||||||
.perm-check:not(.on) svg { display: none; }
|
.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 */
|
/* AI Assistant chat */
|
||||||
.chat-wrap { display: flex; flex-direction: column; height: calc(100vh - 190px); height: calc(100dvh - 190px); }
|
.chat-wrap { display: flex; flex-direction: column; height: calc(100vh - 190px); height: calc(100dvh - 190px); }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue