feat: implement permission-based access control across various pages and components

- Added RequirePermission component to manage access based on user permissions.
- Integrated permission checks in MySeaRecordsPage, SeafarerRegistrationPage, VesselRegistrationPage, WaiverPage, and PortalLayout.
- Updated router to enforce permissions for specific routes.
- Introduced PORTAL_PERMISSIONS and LICENSE_PERMISSIONS constants for consistent permission management.
- Enhanced usePermissions hook to fetch permissions from the server and determine access rights.
- Refactored UI components to conditionally render based on user permissions, improving security and user experience.
This commit is contained in:
Nati
2026-08-13 12:58:11 +00:00
parent 49fbd8fc53
commit 6852fc86c6
46 changed files with 1093 additions and 581 deletions

View File

@@ -26,6 +26,11 @@ export {
} from "./lib/store/signup.slice";
export { usePermissions } from "./lib/hooks/usePermissions";
export type { PermissionSet } from "./lib/hooks/usePermissions";
export { RequirePermission } from "./lib/components/RequirePermission";
export {
LICENSE_PERMISSIONS,
PORTAL_PERMISSIONS,
} from "./lib/permissions.constants";
export {
useCurrentProfile,
useGetMyProfileQuery,

View File

@@ -0,0 +1,39 @@
import type { ReactNode } from 'react';
import { Navigate } from 'react-router-dom';
import { usePermissions } from '../hooks/usePermissions';
interface RequirePermissionProps {
/** Passes when the user holds ANY of these keys. */
anyOf: string[];
/** Route mode: where to send a denied user. Defaults to "/". */
redirectTo?: string;
/**
* Element mode: render nothing instead of redirecting. Use for buttons and
* page fragments; leave false for route elements.
*/
hideOnly?: boolean;
children: ReactNode;
}
/**
* Permission gate for routes, sections and buttons.
*
* Route usage: <Route element={<RequirePermission anyOf={[KEY]}><Page /></RequirePermission>} />
* Element usage: <RequirePermission anyOf={[KEY]} hideOnly><Button /></RequirePermission>
*
* While the permission list is still loading (`known === false`) it renders
* children — the API enforces the real rule, and a flash of a forbidden
* button costs at most a 403, whereas hiding everything flashes an empty app
* at every legitimate user on every load.
*/
export function RequirePermission({
anyOf,
redirectTo = '/',
hideOnly = false,
children,
}: RequirePermissionProps) {
const { can, known } = usePermissions();
if (!known || can(anyOf)) return <>{children}</>;
return hideOnly ? null : <Navigate to={redirectTo} replace />;
}

View File

@@ -85,6 +85,12 @@ export interface ProfileMeResponse {
profile: CurrentProfile;
completeness: number;
missing: ProfileField[];
/**
* Every permission key the caller effectively holds (role + portal
* account-type + position grants), computed server-side. Feeds
* `usePermissions()`.
*/
permissions?: string[];
}
const profileApi = baseApi

View File

@@ -1,47 +1,15 @@
import { useMemo } from 'react';
import { useSelector } from 'react-redux';
import { authStorage } from '../utils/auth-storage';
interface TokenClaims {
permissions?: string[];
roles?: string[];
}
/**
* Reads the claims out of the access token without verifying it.
*
* Verification is the API's job — this is only used to decide what to *show*.
* Every guarded route is enforced server-side by `PermissionGuard`, so the
* worst a wrong answer here can do is offer a menu item that then 403s.
*/
function decodeClaims(token: string | undefined): TokenClaims | null {
if (!token) return null;
const payload = token.split('.')[1];
if (!payload) return null;
try {
const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
// The claim set is UTF-8; atob yields latin-1, so non-ASCII names would
// otherwise come back mangled.
const decoded = decodeURIComponent(
json
.split('')
.map((c) => `%${c.charCodeAt(0).toString(16).padStart(2, '0')}`)
.join(''),
);
return JSON.parse(decoded) as TokenClaims;
} catch {
return null;
}
}
import { useGetMyProfileQuery } from './useCurrentProfile';
export interface PermissionSet {
permissions: string[];
/** True if the user holds any one of `required`. Empty `required` = allowed. */
can: (required?: string[]) => boolean;
/**
* Whether permissions could be read at all. When false, callers should show
* everything rather than hide the whole application from someone whose token
* simply does not carry the claim.
* Whether the effective permission list has loaded. While false, `can()`
* returns true so the shell does not flash an empty sidebar during the
* first fetch; callers gating destructive actions should also check
* `known` and disable rather than hide.
*/
known: boolean;
}
@@ -49,22 +17,22 @@ export interface PermissionSet {
/**
* What the signed-in user is allowed to do.
*
* Deliberately fails open: if the token carries no `permissions` claim we
* report `known: false` and `can()` returns true. Hiding navigation on a
* claim-shape mismatch would leave a legitimate officer staring at an empty
* sidebar with no way to tell why, whereas failing open costs at most a 403
* on a link they should not have seen.
* The list comes from `GET /profiles/me` (`permissions: string[]`), which the
* API computes per session: role grants, portal account-type grants and
* position grants combined. The access token itself carries no permission
* claims, so nothing is decoded client-side any more.
*
* Fails open only while loading (`known: false`); once the server has
* answered, `can()` is authoritative for show/hide. Enforcement remains
* server-side — the worst a wrong answer here can do is offer a control that
* then 403s.
*/
export function usePermissions(): PermissionSet {
// Re-read whenever the session changes rather than only on mount.
const token = useSelector(
(state: { auth?: { token?: string | null } }) => state.auth?.token,
);
const { data, isSuccess } = useGetMyProfileQuery();
return useMemo(() => {
const claims = decodeClaims(token ?? authStorage.getToken());
const permissions = claims?.permissions ?? [];
const known = Array.isArray(claims?.permissions);
const permissions = data?.permissions ?? [];
const known = isSuccess && Array.isArray(data?.permissions);
const granted = new Set(permissions);
return {
@@ -76,5 +44,5 @@ export function usePermissions(): PermissionSet {
return required.some((permission) => granted.has(permission));
},
};
}, [token]);
}, [data?.permissions, isSuccess]);
}

View File

@@ -0,0 +1,89 @@
/**
* Frontend mirror of the API's permission keys.
*
* Sources of truth in emaapi:
* - src/common/constants/licensing-permissions.constant.ts (backoffice duties)
* - src/common/constants/portal-permissions.constant.ts (Level 1 portal roles)
*
* Keys only — grants are computed server-side and arrive on
* `GET /profiles/me` as `permissions: string[]`. A typo here can only
* mis-hide UI; the API enforces the real thing.
*/
export const LICENSE_PERMISSIONS = {
CREATE_APPLICATION: "can:create:license-application",
VIEW_OWN_APPLICATIONS: "can:View:my-license-applications",
UPDATE_APPLICATION: "can:update:license-application",
SUBMIT_APPLICATION: "can:submit:license-application",
VIEW_APPLICATION_QUEUE: "can:View:license-application-queue",
VIEW_APPLICATIONS: "can:View:license-applications",
CLAIM_APPLICATION: "can:claim:license-application",
REVIEW_APPLICATION: "can:review:license-application",
EVALUATE_APPLICATION: "can:evaluate:license-application",
REQUEST_ADJUSTMENT: "can:request-adjustment:license-application",
APPROVE_APPLICATION: "can:approve:license-application",
REJECT_APPLICATION: "can:reject:license-application",
ASSIGN_APPLICATION: "can:assign:license-application",
HOLD_APPLICATION: "can:hold:license-application",
ESCALATE_APPLICATION: "can:escalate:license-application",
REVIEW_DOCUMENTS: "can:review:license-application-documents",
CREATE_INSPECTION: "can:create:inspection",
UPDATE_INSPECTION: "can:update:inspection",
VIEW_INSPECTIONS: "can:View:inspections",
CONFIRM_PAYMENT: "can:confirm:license-payment",
VIEW_PAYMENTS: "can:View:license-payments",
CREATE_LICENSE_TYPE: "can:create:license-type",
VIEW_LICENSE_TYPES: "can:View:license-types",
UPDATE_LICENSE_TYPE: "can:update:license-type",
DELETE_LICENSE_TYPE: "can:delete:license-type",
CREATE_TEMPLATE: "can:create:license-template",
VIEW_TEMPLATES: "can:View:license-templates",
UPDATE_TEMPLATE: "can:update:license-template",
PUBLISH_TEMPLATE: "can:publish:license-template",
VIEW_LICENSES: "can:View:licenses",
SUSPEND_LICENSE: "can:suspend:license",
CANCEL_LICENSE: "can:cancel:license",
MANAGE_SEAFARER_STATUS: "can:manage:seafarer-status",
VIEW_SEAFARER_REGISTRY: "can:View:seafarer-registry",
VERIFY_SEAFARER_RECORDS: "can:verify:seafarer-records",
VIEW_VESSEL_REGISTRY: "can:View:vessel-registry",
MANAGE_VESSEL_STATUS: "can:manage:vessel-status",
APPROVE_QUESTION: "can:approve:exam-question",
AUTHOR_QUESTION: "can:author:exam-question",
MANAGE_EXAMS: "can:manage:exams",
RECORD_EXAM_ATTENDANCE: "can:record:exam-attendance",
MANAGE_EXAM_INCIDENTS: "can:manage:exam-incidents",
RECORD_EXAM_RESULT: "can:record:exam-result",
MODERATE_EXAM_RESULT: "can:moderate:exam-result",
APPROVE_EXAM_RESULT: "can:approve:exam-result",
PUBLISH_EXAM_RESULT: "can:publish:exam-result",
DECIDE_EXAM_APPEAL: "can:decide:exam-appeal",
} as const;
export const PORTAL_PERMISSIONS = {
VIEW_OWN_PROFILE: "can:View:own-profile",
EDIT_OWN_PROFILE: "can:edit:own-profile",
RESUBMIT_APPLICATION: "can:resubmit:license-application",
UPLOAD_DOCUMENTS: "can:upload:own-documents",
VIEW_OWN_DOCUMENTS: "can:View:own-documents",
REPLACE_DOCUMENTS: "can:replace:own-documents",
INITIATE_PAYMENT: "can:initiate:own-payment",
VIEW_OWN_PAYMENTS: "can:View:own-payments",
VIEW_NOTIFICATIONS: "can:View:own-notifications",
APPLY_SEAFARER_REGISTRATION: "can:apply:seafarer-registration",
ADD_SEA_SERVICE: "can:add:own-sea-service",
EDIT_SEA_SERVICE: "can:edit:own-sea-service",
VIEW_OWN_SEA_SERVICE: "can:View:own-sea-service",
UPLOAD_MEDICAL: "can:upload:own-medical-certificate",
VIEW_OWN_MEDICAL: "can:View:own-medical-certificate",
APPLY_SEAFARER_CERTIFICATE: "can:apply:seafarer-certificate",
APPLY_EXAM: "can:apply:exam",
VIEW_OWN_EXAM: "can:View:own-exam",
VIEW_OWN_CERTIFICATES: "can:View:own-certificates",
APPLY_VESSEL_REGISTRATION: "can:apply:vessel-registration",
VIEW_OWN_VESSELS: "can:View:own-vessels",
REPORT_VESSEL_INCIDENT: "can:report:own-vessel-incident",
APPLY_LOGISTICS_LICENSE: "can:apply:logistics-license",
VIEW_COMPANY_LICENSES: "can:View:company-licenses",
APPLY_WAIVER: "can:apply:waiver",
VIEW_WAIVER_LETTER: "can:View:own-waiver-letter",
} as const;