Skip to main content

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

TierRole NamesPermissions
AdminSuper Admin, Company AdminFull access — edit, delete, create, invite, manage team, company settings
MemberInternal Sales, Recruiter/HREdit, delete, create records (jobs, candidates, contacts, talent pools, etc.)
ViewerExternal Sales, Economy, Auditor/ViewerView only — no write actions visible
NoneNo role / jobseekerJobseekers 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

FilePurpose
lib/utils/roles.tsRole tier constants, permission helpers, write-action key lists
lib/hooks/useCompanyRole.tsHook that fetches the current user's role in their selected company
lib/hooks/index.tsRe-exports useCompanyRole
components/ux/TWResponsiveCard/cardBuilder/cardBuilder.tsCardBuildContext 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 fetches userCompanyRoles with role names)
  • Uses cache-and-network fetch policy so the first render uses cached data while refreshing in the background
  • For jobseekers (no selectedCompanyId): returns canWrite: 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:

  • isOwner is selectedCompany.billingOwnerId === user.id — ownership is a billing fact, not a role name.
  • canCreateCompany / canSwitchToJobseeker are 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 / canManageTeam are the admin tier (roleTier === "admin" or super admin).

Gates driven by these booleans

SurfaceRule
Sidebar account switcher + ProfileDropdownHide "Create Company" for invited-only members
/userprofile/add-companyShows "Not available for team members" for invited-only members
Add-company formBlocks submit when the typed company email belongs to a different user (identity-fraud prevention)
Team space "Invite member" CTAShown only when canInviteMembers
IAM Dashboard entryGated 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:

ViewerSidebarSource
Pure job seekerjobSeekerNavItemslib/config/navigation.ts
Invited member (isInvitedMember && !isOwner && !canManageTeam)teamNavItemslib/config/navigation.ts
Owner / company adminmainNavItemslib/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.

Phase 1 defaults to show-all

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:

ConfigWrite Actions Gated
candidateConfigassign, edit, delete
contactConfigedit, delete
jobsConfigedit, assign, duplicate, delete
talentConfigedit (+ hideIf orphan), delete
talentPoolConfigedit, delete
pipelineConfigedit (footer)
sharelistConfigedit, delete
invitationConfigresend, delete
collaboratingPartnerConfigaccept, withdraw, leave, delete
candidateRecommendedConfigassign, edit, delete
resumeCandidateConfigedit, delete
companyConnectionRequestConfigaccept, reject
recommendedTalentsConfigaddCandidate
memberConfig(menu commented out — no change needed)

Pages Updated

Company Pages (using canWrite)

  • app/company/jobs/page.tsx — Create Job button hidden
  • app/company/resume/candidates/page.tsx
  • app/company/resume/talents/page.tsx — Import + Add buttons hidden
  • app/company/resume/talentpool/page.tsx — Add Pool button hidden
  • app/company/resume/sharelist/page.tsx — Add List button hidden
  • app/company/connections/contacts/page.tsx — Import + Add buttons hidden
  • app/company/connections/companies/page.tsx
  • app/company/connections/collaboratingpartner/page.tsx — Invite button hidden
  • app/company/connections/collaboratingpartner/_components/CollaboratorsGrid.tsx
  • app/company/connections/collaboratingpartner/_components/SharedPipelinesTab.tsx
  • app/company/connections/collaboratingpartner/_components/SharedTalentsTab.tsx
  • app/company/connections/collaboratingpartner/_components/InvitationsHistoryList.tsx
  • app/company/connections/collaboratingpartner/_components/ReceivedJobSeekerInvitations.tsx
  • app/company/pipeline/_components/PipelineListCard.tsx — Add Pipeline button hidden
  • app/company/pipeline/manage/[id]/page.tsx
  • app/company/jobs/[jobId]/page.tsx
  • components/reusable/candidates/CandidatesKanban.tsx
  • components/ux/KanbanJobCardAdapter.tsx
  • components/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 hidden
  • app/(shared)/userprofile/editCompanyPage.tsx — Edit/Delete company settings
  • app/(shared)/userprofile/members/memberPage.tsx — Member management
  • app/(shared)/userprofile/invitation/invitationPage.tsx — Invite button hidden

Adding RBAC to a New Page

  1. Import the hook:

    import { useCompanyRole } from "@hooks/useCompanyRole"
  2. Call it in your component:

    const { canWrite, canManage } = useCompanyRole()
  3. For card-based pages, pass context:

    buildCardsFromConfig(CONFIG, data, handlers, { _canEdit: canWrite })
  4. Wrap create/edit/delete buttons:

    {canWrite && <button>Create</button>}
  5. For admin-only operations, use canManage instead of canWrite.

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 titleTWToast description
Your role is read-only on this company. Ask a Company Admin to perform this action.Read-only roleYour 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 roleYou don't have a role on this company yet. Ask an admin to invite you.
Company context is required for this action.Missing company contextSwitch 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 @HasRole decorators. If a viewer somehow calls a mutation, the backend rejects it.
  • Jobseekers are unrestricted — The hook returns canWrite: true for jobseekers since they don't have company roles and their own routes don't need restriction.
  • Super admins bypass everythinguser.isSuperAdmin returns full access regardless of company role.
  • Apollo caching — The GET_MY_COMPANY_ROLE query uses cache-and-network, so role data is available immediately from cache after the first load and refreshed in the background.