mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-30 00:38:12 +00:00
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:
@@ -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,
|
||||
|
||||
39
libs/auth/src/lib/components/RequirePermission.tsx
Normal file
39
libs/auth/src/lib/components/RequirePermission.tsx
Normal 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 />;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
89
libs/auth/src/lib/permissions.constants.ts
Normal file
89
libs/auth/src/lib/permissions.constants.ts
Normal 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;
|
||||
Reference in New Issue
Block a user