Frontend Role-Based Access Control (RBAC)
Overview
The frontend enforces role-based UI restrictions so that users with viewer-tier roles cannot see or interact with edit, delete, or create actions. This mirrors the backend's @HasRole decorators — the backend still validates every mutation, but the frontend now hides UI elements the user isn't authorised to use.
Architecture
useCompanyRole() <-- hook (queries company for user's role)
|
getRoleTier() <-- maps role name -> "admin" | "member" | "viewer" | "none"
|
canWrite / canManage <-- permission booleans
|
+---------+----------+
| |
Card configs Page components
(_canEdit flag) (button visibility)
Role Tiers
| Tier | Role Names | Permissions |
|---|---|---|
| Admin | Super Admin, Company Admin | Full access — edit, delete, create, invite, manage team, company settings |
| Member | Internal Sales, Recruiter/HR | Edit, delete, create records (jobs, candidates, contacts, talent pools, etc.) |
| Viewer | External Sales, Economy, Auditor/Viewer | View only — no write actions visible |
| None | No role / jobseeker | Jobseekers are unrestricted on their own routes; none acts as a passthrough |
Permission Helpers
Defined in lib/utils/roles.ts:
canWrite(tier) // true for admin + member
canManage(tier) // true for admin only
canRead(tier) // true for all except "none"
Key Files
| File | Purpose |
|---|---|
lib/utils/roles.ts | Role tier constants, permission helpers, write-action key lists |
lib/hooks/useCompanyRole.ts | Hook that fetches the current user's role in their selected company |
lib/hooks/index.ts | Re-exports useCompanyRole |
components/ux/TWResponsiveCard/cardBuilder/cardBuilder.ts | CardBuildContext type; buildCardFromConfig / buildCardsFromConfig accept optional 4th context param |
How It Works
1. The useCompanyRole Hook
import { useCompanyRole } from "@hooks/useCompanyRole"
const { canWrite, canManage, roleName, roleTier, loading } = useCompanyRole()
- Queries
GET_MY_COMPANY_ROLE(lightweight — only fetchesuserCompanyRoleswith role names) - Uses
cache-and-networkfetch policy so the first render uses cached data while refreshing in the background - For jobseekers (no
selectedCompanyId): returnscanWrite: true, canManage: true— jobseeker routes are not restricted - For super admins (
user.isSuperAdmin): bypasses all checks, returns full access
2. Card Action Visibility
Card configs use requireAll: ["_canEdit"] on write actions:
// In a card config (e.g. contactConfig.ts)
actions: {
footer: ["view"],
menu: [
{ key: "edit", requireAll: ["_canEdit"] },
{ key: "delete", requireAll: ["_canEdit"] },
],
},
The _canEdit flag is injected into card data via the context parameter:
buildCardsFromConfig(CONFIG, data, handlers, { _canEdit: canWrite })
The buildCardsFromConfig function defaults _canEdit: true so existing callers (jobseeker pages, admin pages) that don't pass context continue to work without restriction.
3. Page-Level Button Guards
Create / Add / Import / Invite buttons are wrapped with permission checks:
{canWrite && (
<button onClick={() => router.push("/company/jobs/createjob")}>
Create Job
</button>
)}
For admin-only operations (team management, company settings):
{canManage && (
<TWButton label="Invite" onClick={() => openModal("add")} />
)}
4. Settings Panel readOnly Pattern
A separate, complementary mechanism gates per-settings-panel read-only access. Each entry in lib/utils/settingsPresets.ts (Profile, Security, Company, Team, Knowledge Base, …) can declare readOnlyForTiers: RoleTier[] to mark itself read-only for the listed tiers. The settings router (app/(shared)/userprofile/(settings)/[tab]/page.tsx) computes the flag once per render:
const panel = TAB_TO_PANEL_KEY[tab] ? panels.find((p) => p.key === panelKey) : undefined
const readOnly = !!panel?.readOnlyForTiers?.includes(role.roleTier)
and threads it into the panel component for the company-scoped and simple tab kinds.
Defence-in-depth: re-derive on the page itself. A panel can also be reached outside the settings router (e.g. Knowledge Base lives at the standalone route /company/chatbot/knowledge as well as the knowledge-base settings tab). For these pages the router-supplied flag is not enough — the page must derive readOnly itself from the same panel definition so the gating works regardless of how the user got there:
import { useCompanyRoleFor } from "@hooks/useCompanyRole"
import { findMasterPanel } from "@utils/settingsPresets"
const role = useCompanyRoleFor(companyId || null)
const readOnlyTiers = findMasterPanel("knowledgeBase")?.readOnlyForTiers ?? []
const readOnly = readOnlyTiers.includes(role.roleTier)
This keeps settingsPresets.ts as the single source of truth — flipping a tier in readOnlyForTiers updates both the sidebar visibility and the page behaviour with no second edit.
When readOnly is true the page should hide create/upload affordances entirely, disable any toggles or destructive actions, and surface a neutral info banner so the user understands why the controls are disabled. See the Knowledge Base implementation in app/company/chatbot/knowledge/page.tsx and AI Agent → Knowledge Base page behaviours for a worked example.
useCompanyPermissions
useCompanyRole answers "what tier is my role?". useCompanyPermissions (lib/hooks/useCompanyPermissions.ts) layers ownership and membership context on top of it, so the UI can distinguish an owner from an invited member from a pure job seeker.
import { useCompanyPermissions } from "@hooks/useCompanyPermissions"
const {
isOwner, // selectedCompany.billingOwnerId === user.id
isInvitedMember, // member of a company they don't own
isPureJobseeker, // no company memberships at all
ownsAnyCompany,
canCreateCompany, // super admin OR pure jobseeker OR owner-of-any
canSwitchToJobseeker,
canInviteMembers, // admin tier
canManageTeam, // admin tier
} = useCompanyPermissions()
Key rules:
isOwnerisselectedCompany.billingOwnerId === user.id— ownership is a billing fact, not a role name.canCreateCompany/canSwitchToJobseekerare the same set: super admin OR pure job seeker OR owner-of-any-company. Invited-only members (members who own no company) cannot create a parallel company or switch to job-seeker mode.canInviteMembers/canManageTeamare the admin tier (roleTier === "admin"or super admin).
Gates driven by these booleans
| Surface | Rule |
|---|---|
Sidebar account switcher + ProfileDropdown | Hide "Create Company" for invited-only members |
/userprofile/add-company | Shows "Not available for team members" for invited-only members |
| Add-company form | Blocks submit when the typed company email belongs to a different user (identity-fraud prevention) |
| Team space "Invite member" CTA | Shown only when canInviteMembers |
| IAM Dashboard entry | Gated on canManageTeam (renders an "Admins only" lock otherwise) |
Three-view sidebar
ClientLayout.tsx picks one of three nav-item sets based on the viewer's ownership/membership context:
| Viewer | Sidebar | Source |
|---|---|---|
| Pure job seeker | jobSeekerNavItems | lib/config/navigation.ts |
Invited member (isInvitedMember && !isOwner && !canManageTeam) | teamNavItems | lib/config/navigation.ts |
| Owner / company admin | mainNavItems | lib/config/navigation.ts |
teamNavItems powers the dedicated Team Workspace at /team/* (a third top-level route group peer to /company/* and /jobseeker/*): Dashboard (/team), Pipelines (/team/pipeline), Members (/team/members), Calendar (/team/calendar), My CV → CV Builder (/team/cv-builder), My Applications (/team/applications). Invited members never see /company/* URLs and manage their personal CV inside /team without toggling to job-seeker mode. See Team Member Management → Team Workspace.
IAM Dashboard
The admin-only IAM dashboard (app/company/team/iam/page.tsx, surfaced at /userprofile/iam via the companyIam → iam mapping in settingsPresets.ts) is where admins manage Members, Roles, and Permissions. Entry is gated on canManageTeam; the page renders an "Admins only" lock when the caller is not an admin. The Roles tab lets admins clone/create/edit/delete custom company roles, and the Members tab reassigns roles (UPDATE_USER_COMPANY_ROLE) with a role-change audit feed.
The list of assignable roles comes from GET_COMPANY_ASSIGNABLE_ROLES (platform-only roles filtered server-side); the frontend intersects IDs defensively so super admin / support never appear while the query loads. Full details in the IAM Dashboard page.
requiredPermission-based nav gating
Each jobSeekerNavItems / teamNavItems entry in lib/config/navigation.ts carries a requiredPermission key:
{ label: "Dashboard", href: "/team", requiredPermission: "jobseeker.dashboard.view" }
{ label: "Pipelines", href: "/team/pipeline", requiredPermission: "pipeline.view" }
{ label: "Members", href: "/team/members", requiredPermission: "team.view" }
The Sidebar's filterNavItems hides an item when the member lacks the effective permission for its key.
Until the backend surfaces effectivePermissions on whoAmI, the filter defaults to showing every item — nothing is hidden yet. Wiring the requiredPermission keys now means per-member nav visibility becomes a data change (backend ships effective permissions) rather than a code change.
Card Configs Updated
All 14 company-facing card configs have requireAll: ["_canEdit"] on their write actions:
| Config | Write Actions Gated |
|---|---|
candidateConfig | assign, edit, delete |
contactConfig | edit, delete |
jobsConfig | edit, assign, duplicate, delete |
talentConfig | edit (+ hideIf orphan), delete |
talentPoolConfig | edit, delete |
pipelineConfig | edit (footer) |
sharelistConfig | edit, delete |
invitationConfig | resend, delete |
collaboratingPartnerConfig | accept, withdraw, leave, delete |
candidateRecommendedConfig | assign, edit, delete |
resumeCandidateConfig | edit, delete |
companyConnectionRequestConfig | accept, reject |
recommendedTalentsConfig | addCandidate |
memberConfig | (menu commented out — no change needed) |
Pages Updated
Company Pages (using canWrite)
app/company/jobs/page.tsx— Create Job button hiddenapp/company/resume/candidates/page.tsxapp/company/resume/talents/page.tsx— Import + Add buttons hiddenapp/company/resume/talentpool/page.tsx— Add Pool button hiddenapp/company/resume/sharelist/page.tsx— Add List button hiddenapp/company/connections/contacts/page.tsx— Import + Add buttons hiddenapp/company/connections/companies/page.tsxapp/company/connections/collaboratingpartner/page.tsx— Invite button hiddenapp/company/connections/collaboratingpartner/_components/CollaboratorsGrid.tsxapp/company/connections/collaboratingpartner/_components/SharedPipelinesTab.tsxapp/company/connections/collaboratingpartner/_components/SharedTalentsTab.tsxapp/company/connections/collaboratingpartner/_components/InvitationsHistoryList.tsxapp/company/connections/collaboratingpartner/_components/ReceivedJobSeekerInvitations.tsxapp/company/pipeline/_components/PipelineListCard.tsx— Add Pipeline button hiddenapp/company/pipeline/manage/[id]/page.tsxapp/company/jobs/[jobId]/page.tsxcomponents/reusable/candidates/CandidatesKanban.tsxcomponents/ux/KanbanJobCardAdapter.tsxcomponents/reusable/jobs/OptionsDropdown.tsx— Publish, Priority, Assign, Edit, Duplicate, Delete items hidden
Admin-Only Pages (using canManage)
app/company/team/members/page.tsx— Invite button hiddenapp/(shared)/userprofile/editCompanyPage.tsx— Edit/Delete company settingsapp/(shared)/userprofile/members/memberPage.tsx— Member managementapp/(shared)/userprofile/invitation/invitationPage.tsx— Invite button hidden
Adding RBAC to a New Page
-
Import the hook:
import { useCompanyRole } from "@hooks/useCompanyRole" -
Call it in your component:
const { canWrite, canManage } = useCompanyRole() -
For card-based pages, pass context:
buildCardsFromConfig(CONFIG, data, handlers, { _canEdit: canWrite }) -
Wrap create/edit/delete buttons:
{canWrite && <button>Create</button>} -
For admin-only operations, use
canManageinstead ofcanWrite.
Adding a New Role
If the backend adds a new role, update lib/utils/roles.ts:
const ADMIN_ROLES = ["Super Admin", "Company Admin"] as const
const MEMBER_ROLES = ["Internal Sales", "Recruiter/HR"] as const
const VIEWER_ROLES = ["External Sales", "Economy", "Auditor/Viewer"] as const
Add the new role name to the appropriate tier array. The arrays are tied to the canonical RDS Role table — only names that actually exist in Role.name belong here. Stale aliases (e.g. "Recruiter", "Auditor", "Viewer") used to live in this file as silent permissiveness; they were removed once the canonical names were verified against prod and dev. No other changes needed — the getRoleTier function handles the rest.
Permission Error Toasts
When the backend rejects a mutation with extensions.statusCode === 403 and one of the canonical messages emitted by CompanyWriteGuard, the Apollo error link in graphql/apollo/apolloClient.ts swallows the red GraphQL-error overlay and surfaces a friendly TWToast instead. This keeps the UI honest when a viewer-tier user finds an ungated affordance (or fires a mutation directly via dev tools).
The detection is exact-match on the canonical strings — no substring matching, no statusCode-only fallback — so we never accidentally swallow an unrelated 403.
Backend message (from CompanyWriteGuard) | TWToast title | TWToast description |
|---|---|---|
Your role is read-only on this company. Ask a Company Admin to perform this action. | Read-only role | Your role on this company is read-only. Ask a Company Admin to perform this action. |
You do not have a role on this company. | Missing company role | You don't have a role on this company yet. Ask an admin to invite you. |
Company context is required for this action. | Missing company context | Switch to a company before performing this action. |
// graphql/apollo/apolloClient.ts (excerpt)
const detectPermissionError = (error: GraphQLFormattedError) => {
if (error.extensions?.statusCode !== 403) return null
const message = error.message ?? ""
if (message.startsWith("Your role is read-only")) {
return { title: "Read-only role", description: "Your role on this company is read-only..." }
}
// ... two more exact matches
return null
}
If you add a new canonical message in CompanyWriteGuard, mirror it here — otherwise the user gets a generic Apollo error overlay instead of the friendly toast. See Roles & Permissions → CompanyWriteGuard for the full backend contract.
Important Notes
- Backend still enforces — The frontend hides UI, but the backend validates every mutation with
@HasRoledecorators. If a viewer somehow calls a mutation, the backend rejects it. - Jobseekers are unrestricted — The hook returns
canWrite: truefor jobseekers since they don't have company roles and their own routes don't need restriction. - Super admins bypass everything —
user.isSuperAdminreturns full access regardless of company role. - Apollo caching — The
GET_MY_COMPANY_ROLEquery usescache-and-network, so role data is available immediately from cache after the first load and refreshed in the background.