diff --git a/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx b/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx index 9f47684df..ae4c2a26c 100644 --- a/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx +++ b/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx @@ -47,7 +47,7 @@ import { } from '@ema-platform/api'; import { EmptyState, ErrorState, ModalFooter, PageHeader } from '@ema-platform/ui'; import { authStorage, usePermissions } from '@ema-platform/auth'; -import { PERMISSIONS } from '../../../layouts/nav-config'; +import { LICENSE_PERMISSIONS as PERMISSIONS } from '@ema-platform/auth'; /** Same resolution the shared RTK Query baseQuery uses. */ const API_BASE_URL = diff --git a/apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel/columns.tsx b/apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel/columns.tsx index 9a1798567..421a39c02 100644 --- a/apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel/columns.tsx +++ b/apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel/columns.tsx @@ -2,6 +2,7 @@ import { Badge, Button, Text } from '@mantine/core'; import { IconUserCheck } from '@tabler/icons-react'; import type { TFunction } from 'i18next'; import type { AdvancedColumn } from '@ema-platform/ui'; +import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; import type { AttendanceStatus, ExamRegistration } from '../../types/exam'; const ATTENDANCE_COLOR: Record = { @@ -78,14 +79,19 @@ export function examCandidateColumns( label: t('exam.candidates.record'), align: 'right', cell: ({ row }) => ( - + + ), }, ]; diff --git a/apps/backoffice/src/app/features/exam/components/ExamIncidentsPanel/columns.tsx b/apps/backoffice/src/app/features/exam/components/ExamIncidentsPanel/columns.tsx index 974e96595..5d5fab6fd 100644 --- a/apps/backoffice/src/app/features/exam/components/ExamIncidentsPanel/columns.tsx +++ b/apps/backoffice/src/app/features/exam/components/ExamIncidentsPanel/columns.tsx @@ -2,6 +2,7 @@ import { Badge, Button, Text } from '@mantine/core'; import { IconAlertTriangle } from '@tabler/icons-react'; import type { TFunction } from 'i18next'; import type { AdvancedColumn } from '@ema-platform/ui'; +import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; import type { ExamIncident, ExamIncidentStatus } from '../../types/exam'; const STATUS_COLOR: Record = { @@ -70,14 +71,19 @@ export function examIncidentColumns( align: 'right', cell: ({ row }) => row.original.status === 'OPEN' || row.original.status === 'UNDER_REVIEW' ? ( - + + ) : null, }, ]; diff --git a/apps/backoffice/src/app/features/exam/components/ExamIncidentsPanel/index.tsx b/apps/backoffice/src/app/features/exam/components/ExamIncidentsPanel/index.tsx index f41802210..454570733 100644 --- a/apps/backoffice/src/app/features/exam/components/ExamIncidentsPanel/index.tsx +++ b/apps/backoffice/src/app/features/exam/components/ExamIncidentsPanel/index.tsx @@ -16,6 +16,7 @@ import { IconInfoCircle, IconPlus } from '@tabler/icons-react'; import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui'; import { useDateDisplayer } from '@ema-platform/shared'; import { extractErrorMessage } from '@ema-platform/api'; +import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; import { useGetExamIncidentsQuery, useGetExamRegistrationsQuery, @@ -122,15 +123,20 @@ export function ExamIncidentsPanel({ examId }: { examId: string }) { {t('exam.incidents.section')} - + + {(incidents ?? []).length === 0 ? ( diff --git a/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx b/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx index aff652456..e49efe907 100644 --- a/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx +++ b/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx @@ -41,6 +41,7 @@ import { } from '@tabler/icons-react'; import { ModalFooter, notify, useErrorHandler } from '@ema-platform/ui'; import { extractErrorMessage } from '@ema-platform/api'; +import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; import { useGetExamQuery, useUpdateExamMutation, @@ -393,14 +394,16 @@ export function ExamDetailPage() { {t("exam.detail.questionsSection", { pts: totalPoints })} - + + + {(exam.questions ?? []).length === 0 ? ( }> @@ -461,9 +464,11 @@ export function ExamDetailPage() { - + + + ) : ( @@ -478,9 +483,11 @@ export function ExamDetailPage() { size="xs" style={{ width: 80 }} /> - + + + {t("exam.cancel")} - + + + )} diff --git a/apps/backoffice/src/app/features/exam/pages/ExamPage/actions.tsx b/apps/backoffice/src/app/features/exam/pages/ExamPage/actions.tsx index 724c872df..ba902be9d 100644 --- a/apps/backoffice/src/app/features/exam/pages/ExamPage/actions.tsx +++ b/apps/backoffice/src/app/features/exam/pages/ExamPage/actions.tsx @@ -2,6 +2,7 @@ import { ActionIcon, Group } from "@mantine/core"; import { IconEdit, IconTrash, IconDetails } from "@tabler/icons-react"; import type { TFunction } from "i18next"; import type { AdvancedColumn } from "@ema-platform/ui"; +import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth"; import type { Exam } from "../../types/exam"; export function examActionsColumn( @@ -17,22 +18,24 @@ export function examActionsColumn( align: "right", cell: ({ row }) => ( - handlers.onEdit(row.original)} - > - - - handlers.onDelete(row.original)} - > - - + + handlers.onEdit(row.original)} + > + + + handlers.onDelete(row.original)} + > + + + {!showForm && ( - + + + )} diff --git a/apps/backoffice/src/app/features/license-register/pages/LicenseRegisterPage/columns.tsx b/apps/backoffice/src/app/features/license-register/pages/LicenseRegisterPage/columns.tsx index edc3332bd..b168c8908 100644 --- a/apps/backoffice/src/app/features/license-register/pages/LicenseRegisterPage/columns.tsx +++ b/apps/backoffice/src/app/features/license-register/pages/LicenseRegisterPage/columns.tsx @@ -2,6 +2,7 @@ import { Badge, Button, Text, Tooltip } from '@mantine/core'; import { IconShieldCog } from '@tabler/icons-react'; import type { AdvancedColumn } from '@ema-platform/ui'; import type { Bilingual, IssuedLicense } from '@ema-platform/api'; +import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; const LICENSE_STATUS_COLORS: Record = { ACTIVE: 'green', @@ -84,16 +85,24 @@ export function licenseRegisterColumns( align: 'right', cell: ({ row }) => actionsFor(row.original).length > 0 ? ( - - - + + + + + ) : null, }, ]; diff --git a/apps/backoffice/src/app/features/license-review/config/actions.ts b/apps/backoffice/src/app/features/license-review/config/actions.ts index 0240477ae..02ccbd62e 100644 --- a/apps/backoffice/src/app/features/license-review/config/actions.ts +++ b/apps/backoffice/src/app/features/license-review/config/actions.ts @@ -1,5 +1,5 @@ import type { ApplicationDetail, LicenseStatus } from '@ema-platform/api'; -import { PERMISSIONS } from '../../../layouts/nav-config'; +import { LICENSE_PERMISSIONS as PERMISSIONS } from '@ema-platform/auth'; /** * Where an action is rendered. One tier per action, decided here rather than diff --git a/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/actions.tsx b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/actions.tsx index 305f923af..0c4a7dff3 100644 --- a/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/actions.tsx +++ b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/actions.tsx @@ -2,6 +2,7 @@ import { Button } from "@mantine/core"; import type { TFunction } from "i18next"; import type { LicenseApplication } from "@ema-platform/api"; import type { AdvancedColumn } from "@ema-platform/ui"; +import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth"; export function licenseQueueActionsColumn( t: TFunction, @@ -19,13 +20,18 @@ export function licenseQueueActionsColumn( cell: ({ row }) => row.original.assignedOfficerId === null && row.original.status === "SUBMITTED" ? ( - + + ) : ( - + + + diff --git a/apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage/actions.tsx b/apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage/actions.tsx index e03b361b3..6c1ce955a 100644 --- a/apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage/actions.tsx +++ b/apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage/actions.tsx @@ -3,6 +3,7 @@ import { IconCheck, IconPaperclip, IconX } from '@tabler/icons-react'; import type { TFunction } from 'i18next'; import type { AdvancedColumn } from '@ema-platform/ui'; import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api'; +import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; export function medicalActionsColumn( t: TFunction, @@ -28,24 +29,29 @@ export function medicalActionsColumn( > {t('recordVerification.evidence', 'Evidence')} - - + + + ), }; @@ -75,24 +81,29 @@ export function seaServiceActionsColumn( > {t('recordVerification.evidence', 'Evidence')} - - + + + ), }; diff --git a/apps/backoffice/src/app/features/payment-config/pages/PaymentConfigPage/actions.tsx b/apps/backoffice/src/app/features/payment-config/pages/PaymentConfigPage/actions.tsx index 88d98d0b6..669519c93 100644 --- a/apps/backoffice/src/app/features/payment-config/pages/PaymentConfigPage/actions.tsx +++ b/apps/backoffice/src/app/features/payment-config/pages/PaymentConfigPage/actions.tsx @@ -3,6 +3,7 @@ import { IconEdit } from '@tabler/icons-react'; import type { TFunction } from 'i18next'; import type { LicenseType } from '@ema-platform/api'; import type { AdvancedColumn } from '@ema-platform/ui'; +import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; export function paymentConfigActionsColumn( t: TFunction, @@ -13,14 +14,19 @@ export function paymentConfigActionsColumn( size: 90, align: 'right', cell: ({ row }) => ( - + + ), }; } diff --git a/apps/backoffice/src/app/features/question/pages/QuestionPage/actions.tsx b/apps/backoffice/src/app/features/question/pages/QuestionPage/actions.tsx index c3ad6b700..05f1b02fb 100644 --- a/apps/backoffice/src/app/features/question/pages/QuestionPage/actions.tsx +++ b/apps/backoffice/src/app/features/question/pages/QuestionPage/actions.tsx @@ -2,6 +2,7 @@ import { ActionIcon, Button, Group } from '@mantine/core'; import { IconEdit, IconGavel, IconSend, IconTrash } from '@tabler/icons-react'; import type { TFunction } from 'i18next'; import type { AdvancedColumn } from '@ema-platform/ui'; +import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; import type { Question } from '../../types/question'; export function questionActionsColumn( @@ -22,43 +23,49 @@ export function questionActionsColumn( return ( {(q.status === 'DRAFT' || q.status === 'REJECTED') && ( - + + + )} {q.status === 'PENDING_APPROVAL' && ( - <> + - + )} {q.status === 'APPROVED' && ( - + + + )} - handlers.onEdit(q)}> - - - handlers.onDelete(q)}> - - + + handlers.onEdit(q)}> + + + handlers.onDelete(q)}> + + + ); }, diff --git a/apps/backoffice/src/app/features/question/pages/QuestionPage/index.tsx b/apps/backoffice/src/app/features/question/pages/QuestionPage/index.tsx index 9dfec73ba..e5026c659 100644 --- a/apps/backoffice/src/app/features/question/pages/QuestionPage/index.tsx +++ b/apps/backoffice/src/app/features/question/pages/QuestionPage/index.tsx @@ -19,6 +19,7 @@ import { useTranslation } from 'react-i18next'; import { IconPlus, IconInfoCircle } from '@tabler/icons-react'; import { AdvancedColumn, AdvancedTable, ModalFooter, notify, useErrorHandler, useServerTable } from '@ema-platform/ui'; import { extractErrorMessage } from '@ema-platform/api'; +import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; import { useGetCertificationsQuery } from '../../../certification/api/certification-api'; import { useGetQuestionsQuery, @@ -219,9 +220,11 @@ export function QuestionPage() { {t('question.title')} {!showForm && ( - + + + )} diff --git a/apps/backoffice/src/app/features/result/components/RecordResultModal/index.tsx b/apps/backoffice/src/app/features/result/components/RecordResultModal/index.tsx index f24a9c1fa..15ed50335 100644 --- a/apps/backoffice/src/app/features/result/components/RecordResultModal/index.tsx +++ b/apps/backoffice/src/app/features/result/components/RecordResultModal/index.tsx @@ -17,6 +17,7 @@ import { import { IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react'; import { AdvancedTable, ModalFooter, notify, useServerTable } from '@ema-platform/ui'; import { extractErrorMessage } from '@ema-platform/api'; +import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; import { recordResultColumns } from './columns'; import { useCreateResultMutation } from '../../api/result-api'; import { useGetExamRegistrationsQuery } from '../../../exam/api/exam-api'; @@ -207,9 +208,11 @@ export function RecordResultModal({ - + + + )} diff --git a/apps/backoffice/src/app/features/result/pages/ExamAppealsPage/columns.tsx b/apps/backoffice/src/app/features/result/pages/ExamAppealsPage/columns.tsx index 76d7b78f4..01a35aeef 100644 --- a/apps/backoffice/src/app/features/result/pages/ExamAppealsPage/columns.tsx +++ b/apps/backoffice/src/app/features/result/pages/ExamAppealsPage/columns.tsx @@ -2,6 +2,7 @@ import { Badge, Button, Text } from '@mantine/core'; import { IconGavel } from '@tabler/icons-react'; import type { TFunction } from 'i18next'; import type { AdvancedColumn } from '@ema-platform/ui'; +import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; import type { ExamAppeal } from '../../types/result'; export function examAppealsColumns( @@ -59,15 +60,17 @@ export function examAppealsColumns( label: t('result.appeals.decide'), align: 'right', cell: ({ row }) => ( - + + + ), }, ]; diff --git a/apps/backoffice/src/app/features/result/pages/ResultPage/actions.tsx b/apps/backoffice/src/app/features/result/pages/ResultPage/actions.tsx index 29cd22d22..d24ad573f 100644 --- a/apps/backoffice/src/app/features/result/pages/ResultPage/actions.tsx +++ b/apps/backoffice/src/app/features/result/pages/ResultPage/actions.tsx @@ -2,6 +2,7 @@ import { Button, Group } from '@mantine/core'; import { IconEye, IconTrash } from '@tabler/icons-react'; import type { TFunction } from 'i18next'; import type { AdvancedColumn } from '@ema-platform/ui'; +import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; import type { Result } from '../../types/result'; export type QcAction = 'moderate' | 'approve' | 'return'; @@ -23,31 +24,45 @@ export function resultActionsColumn( {(r.reviewStatus === 'MARKED' || r.reviewStatus === 'MODERATED') && ( <> - - + + + + + + )} {(r.reviewStatus === 'APPROVED' || r.reviewStatus === 'MODERATED') && ( - + + + )} - + + + ); }, diff --git a/apps/backoffice/src/app/features/result/pages/ResultPage/index.tsx b/apps/backoffice/src/app/features/result/pages/ResultPage/index.tsx index 7db5856e4..583ea57d0 100644 --- a/apps/backoffice/src/app/features/result/pages/ResultPage/index.tsx +++ b/apps/backoffice/src/app/features/result/pages/ResultPage/index.tsx @@ -38,6 +38,7 @@ import { notify, BilingualInput, useErrorHandler, AdvancedTable, useServerTable, import { useDateDisplayer } from '@ema-platform/shared'; import type { BilingualValue } from '@ema-platform/ui'; import { extractErrorMessage, useLocalized } from '@ema-platform/api'; +import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; import { useGetResultsQuery, useLazyGetResultQuery, @@ -300,17 +301,19 @@ export function ResultPage() { {t('result.subtitle')} - + + + diff --git a/apps/backoffice/src/app/features/seafarer-registry/pages/SeafarerRegistryPage/actions.tsx b/apps/backoffice/src/app/features/seafarer-registry/pages/SeafarerRegistryPage/actions.tsx index d4c5fed9e..0ebda3bc7 100644 --- a/apps/backoffice/src/app/features/seafarer-registry/pages/SeafarerRegistryPage/actions.tsx +++ b/apps/backoffice/src/app/features/seafarer-registry/pages/SeafarerRegistryPage/actions.tsx @@ -2,6 +2,7 @@ import { Button, Tooltip } from '@mantine/core'; import { IconShieldCog } from '@tabler/icons-react'; import type { TFunction } from 'i18next'; import type { AdvancedColumn } from '@ema-platform/ui'; +import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; import type { ProfileRow } from './columns'; export function seafarerStatusActionColumn( @@ -13,16 +14,21 @@ export function seafarerStatusActionColumn( label: t('seafarerRegistry.columns.actions', 'Actions'), cell: ({ row }) => row.original.seafarerNumber ? ( - - - + + + + + ) : null, }; } diff --git a/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationQueuePage/columns.tsx b/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationQueuePage/columns.tsx index fb40ed3bd..b912d4677 100644 --- a/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationQueuePage/columns.tsx +++ b/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationQueuePage/columns.tsx @@ -2,6 +2,7 @@ import { Badge, Button, Text, Tooltip } from '@mantine/core'; import { IconShieldCog } from '@tabler/icons-react'; import type { AdvancedColumn } from '@ema-platform/ui'; import type { Vessel } from '@ema-platform/api'; +import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; const VESSEL_STATUS_COLORS: Record = { REGISTERED: 'green', @@ -76,19 +77,24 @@ export function vesselRegistrationQueueColumns( label: 'Actions', align: 'right', cell: ({ row }) => ( - - - + + + + + ), }, ]; diff --git a/apps/backoffice/src/app/layouts/nav-config.ts b/apps/backoffice/src/app/layouts/nav-config.ts index ba0c25c2e..ddc1fd7ec 100644 --- a/apps/backoffice/src/app/layouts/nav-config.ts +++ b/apps/backoffice/src/app/layouts/nav-config.ts @@ -26,24 +26,13 @@ import { IconUserShield, } from '@tabler/icons-react'; import type { NavSection } from '@ema-platform/ui'; +import { LICENSE_PERMISSIONS as P } from '@ema-platform/auth'; /** - * Permission keys mirrored from the API's `LICENSE_PERMISSIONS`. - * - * Kept as literals rather than imported: the backoffice bundle must not pull - * in server code, and these strings are a published contract — the IAM seed - * and every `PermissionGuard([...])` already read from the same list. + * Every licence-type queue and its review workspace share one gate: the + * licence application queue permissions, granted as any-of. */ -export const PERMISSIONS = { - VIEW_APPLICATION_QUEUE: 'can:View:license-application-queue', - VIEW_APPLICATIONS: 'can:View:license-applications', - VIEW_LICENSES: 'can:View:licenses', - VIEW_LICENSE_TYPES: 'can:View:license-types', - VIEW_PAYMENTS: 'can:View:license-payments', - VIEW_TEMPLATES: 'can:View:license-templates', - UPDATE_TEMPLATE: 'can:update:license-template', - PUBLISH_TEMPLATE: 'can:publish:license-template', -} as const; +const APPLICATION_QUEUE = [P.VIEW_APPLICATION_QUEUE, P.VIEW_APPLICATIONS]; /** * The backoffice information architecture. @@ -63,80 +52,92 @@ export const NAV_SECTIONS: NavSection[] = [ to: '/licence-review', label: 'nav.allApplications', icon: IconListCheck, - permissions: [PERMISSIONS.VIEW_APPLICATION_QUEUE], + permissions: APPLICATION_QUEUE, }, { // A disclosure, not a destination — each child deep-links the grid to // one type, which is a facet of the same workspace. label: 'nav.byType', icon: IconTruck, - permissions: [PERMISSIONS.VIEW_APPLICATIONS], + permissions: APPLICATION_QUEUE, children: [ - { to: '/licence-review/type/FREIGHT_FORWARDER', label: 'nav.typeFreightForwarder', icon: IconTruck }, - { to: '/licence-review/type/SHIPPING_AGENT', label: 'nav.typeShippingAgent', icon: IconShip }, - { to: '/licence-review/type/COMBINED_SA_FF', label: 'nav.typeCombined', icon: IconFileDescription }, - { to: '/licence-review/type/JOINT_INVESTOR', label: 'nav.typeJointInvestment', icon: IconUsers }, - { to: '/licence-review/type/MULTIMODAL_TRANSPORT_OPERATOR', label: 'nav.typeMto', icon: IconAnchor }, + { to: '/licence-review/type/FREIGHT_FORWARDER', label: 'nav.typeFreightForwarder', icon: IconTruck, permissions: APPLICATION_QUEUE }, + { to: '/licence-review/type/SHIPPING_AGENT', label: 'nav.typeShippingAgent', icon: IconShip, permissions: APPLICATION_QUEUE }, + { to: '/licence-review/type/COMBINED_SA_FF', label: 'nav.typeCombined', icon: IconFileDescription, permissions: APPLICATION_QUEUE }, + { to: '/licence-review/type/JOINT_INVESTOR', label: 'nav.typeJointInvestment', icon: IconUsers, permissions: APPLICATION_QUEUE }, + { to: '/licence-review/type/MULTIMODAL_TRANSPORT_OPERATOR', label: 'nav.typeMto', icon: IconAnchor, permissions: APPLICATION_QUEUE }, ], }, { to: '/licence-register', label: 'nav.licenceRegister', icon: IconListCheck, - permissions: [PERMISSIONS.VIEW_LICENSES], + permissions: [P.VIEW_LICENSES], }, { to: '/certificate-designer', label: 'nav.certificateDesigner', icon: IconRosetteDiscountCheck, - permissions: [PERMISSIONS.VIEW_TEMPLATES], + permissions: [P.VIEW_TEMPLATES], }, - { to: '/licence-review/type/PRE_WAIVER', label: 'nav.preWaiverQueue', icon: IconShieldOff }, - { to: '/licence-review/type/POST_WAIVER', label: 'nav.postWaiverQueue', icon: IconShieldOff }, + { to: '/licence-review/type/PRE_WAIVER', label: 'nav.preWaiverQueue', icon: IconShieldOff, permissions: APPLICATION_QUEUE }, + { to: '/licence-review/type/POST_WAIVER', label: 'nav.postWaiverQueue', icon: IconShieldOff, permissions: APPLICATION_QUEUE }, { + // Every figure on it is derived from the licence application queue. to: '/logistics-head-dashboard', label: 'nav.logisticsHeadDashboard', icon: IconGauge, + permissions: APPLICATION_QUEUE, }, { to: '/payment-config', label: 'nav.paymentConfig', icon: IconCreditCard, - permissions: [PERMISSIONS.VIEW_PAYMENTS], + permissions: [P.VIEW_PAYMENTS], }, ], }, { label: 'nav.groupSeafarer', items: [ - { to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers }, - { to: '/licence-review/type/SEAFARER_REGISTRATION', label: 'nav.seafarerRegistrationQueue', icon: IconId }, - { to: '/licence-review/type/CERTIFICATE_OF_COMPETENCY', label: 'nav.cocQueue', icon: IconShieldCheck }, - { to: '/licence-review/type/CERTIFICATE_OF_PROFICIENCY', label: 'nav.copQueue', icon: IconShieldCheck }, - { to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2, soon: true }, - { to: '/licence-review/type/ENDORSEMENT_COC', label: 'nav.endorsementCocQueue', icon: IconRubberStamp }, - { to: '/licence-review/type/ENDORSEMENT_GOC', label: 'nav.endorsementGocQueue', icon: IconRubberStamp }, - { to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart }, + { to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers, permissions: [P.VIEW_SEAFARER_REGISTRY] }, + { to: '/licence-review/type/SEAFARER_REGISTRATION', label: 'nav.seafarerRegistrationQueue', icon: IconId, permissions: APPLICATION_QUEUE }, + { to: '/licence-review/type/CERTIFICATE_OF_COMPETENCY', label: 'nav.cocQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE }, + { to: '/licence-review/type/CERTIFICATE_OF_PROFICIENCY', label: 'nav.copQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE }, + { to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2, soon: true, permissions: APPLICATION_QUEUE }, + { to: '/licence-review/type/ENDORSEMENT_COC', label: 'nav.endorsementCocQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE }, + { to: '/licence-review/type/ENDORSEMENT_GOC', label: 'nav.endorsementGocQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE }, + { to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart, permissions: [P.VERIFY_SEAFARER_RECORDS] }, ], }, { label: 'nav.groupVessels', items: [ - { to: '/licence-review/type/VESSEL_REGISTRATION', label: 'nav.vesselRegistrationApplicationQueue', icon: IconAnchor }, - { to: '/vessel-registration-queue', label: 'nav.vesselRegistrationQueue', icon: IconAnchor }, - { to: '/licence-review/type/VESSEL_OWNERSHIP_TRANSFER', label: 'nav.ownershipTransferQueue', icon: IconArrowsExchange }, - { to: '/vessel-registration-queue/new', label: 'nav.vesselFormBuilder', icon: IconFilePlus, soon: true }, - { to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconChartBar, soon: true }, - { to: '/vessel-registration-head-dashboard', label: 'nav.vesselRegistrationHeadDashboard', icon: IconGauge, soon: true }, + { to: '/licence-review/type/VESSEL_REGISTRATION', label: 'nav.vesselRegistrationApplicationQueue', icon: IconAnchor, permissions: APPLICATION_QUEUE }, + { to: '/vessel-registration-queue', label: 'nav.vesselRegistrationQueue', icon: IconAnchor, permissions: [P.VIEW_VESSEL_REGISTRY] }, + { to: '/licence-review/type/VESSEL_OWNERSHIP_TRANSFER', label: 'nav.ownershipTransferQueue', icon: IconArrowsExchange, permissions: APPLICATION_QUEUE }, + { to: '/vessel-registration-queue/new', label: 'nav.vesselFormBuilder', icon: IconFilePlus, soon: true, permissions: [P.VIEW_VESSEL_REGISTRY] }, + { to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconChartBar, soon: true, permissions: [P.VIEW_VESSEL_REGISTRY] }, + { to: '/vessel-registration-head-dashboard', label: 'nav.vesselRegistrationHeadDashboard', icon: IconGauge, soon: true, permissions: [P.VIEW_VESSEL_REGISTRY] }, ], }, { label: 'nav.groupExaminations', items: [ - { to: '/questions', label: 'nav.questions', icon: IconQuestionMark }, - { to: '/exams', label: 'nav.exams', icon: IconClipboardList }, - { to: '/exam-results', label: 'nav.examResults', icon: IconReport }, - { to: '/exam-appeals', label: 'nav.examAppeals', icon: IconGavel }, + { to: '/questions', label: 'nav.questions', icon: IconQuestionMark, permissions: [P.APPROVE_QUESTION, P.AUTHOR_QUESTION] }, + { + to: '/exams', + label: 'nav.exams', + icon: IconClipboardList, + permissions: [P.MANAGE_EXAMS, P.RECORD_EXAM_ATTENDANCE, P.MANAGE_EXAM_INCIDENTS, P.PUBLISH_EXAM_RESULT], + }, + { + to: '/exam-results', + label: 'nav.examResults', + icon: IconReport, + permissions: [P.RECORD_EXAM_RESULT, P.MODERATE_EXAM_RESULT, P.APPROVE_EXAM_RESULT, P.PUBLISH_EXAM_RESULT], + }, + { to: '/exam-appeals', label: 'nav.examAppeals', icon: IconGavel, permissions: [P.DECIDE_EXAM_APPEAL] }, ], }, { @@ -144,10 +145,12 @@ export const NAV_SECTIONS: NavSection[] = [ items: [ { to: '/um/user-management/dashboard', label: 'nav.userManagement', icon: IconUserShield }, { + // Professions, locations and certifications have no dedicated keys; + // the config-view keys are the closest published contract. to: '/configuration', label: 'nav.configuration', icon: IconSettings, - permissions: [PERMISSIONS.VIEW_LICENSE_TYPES], + permissions: [P.VIEW_LICENSE_TYPES, P.VIEW_TEMPLATES], }, { to: '/analytics', label: 'nav.analytics', icon: IconChartBar, soon: true }, ], diff --git a/apps/backoffice/src/app/router/index.tsx b/apps/backoffice/src/app/router/index.tsx index ae40f7be3..a5a2c5c5b 100644 --- a/apps/backoffice/src/app/router/index.tsx +++ b/apps/backoffice/src/app/router/index.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from 'react'; import { createBrowserRouter, RouterProvider, @@ -8,6 +9,8 @@ import { ForgotPasswordPage, SetPasswordPage, OTPVerificationPage, + RequirePermission, + LICENSE_PERMISSIONS as P, } from '@ema-platform/auth'; import { AuthLayout } from '../layouts/AuthLayout'; import { BackofficeLayout } from '../layouts/BackofficeLayout'; @@ -38,6 +41,14 @@ import { LicenseReviewPage } from '../features/license-review/pages/LicenseRevie import { LogisticsHeadDashboardPage } from '../features/logistics-head/pages/LogisticsHeadDashboardPage'; import { CertificateDesignerPage } from '../features/certificate-designer/pages/CertificateDesignerPage'; +/** Any-of gate shared by every licence-type queue and its review workspace. */ +const APPLICATION_QUEUE = [P.VIEW_APPLICATION_QUEUE, P.VIEW_APPLICATIONS]; + +/** Route gate: same keys as the route's nav item in nav-config.ts. */ +const guard = (anyOf: string[], element: ReactNode) => ( + {element} +); + const router = createBrowserRouter([ { element: , @@ -60,41 +71,41 @@ const router = createBrowserRouter([ children: [ { index: true, element: }, { path: 'dashboard', element: }, - { path: 'vessel-registration-head-dashboard', element: }, - { path: 'logistics-head-dashboard', element: }, + { path: 'vessel-registration-head-dashboard', element: guard([P.VIEW_VESSEL_REGISTRY], ) }, + { path: 'logistics-head-dashboard', element: guard(APPLICATION_QUEUE, ) }, { path: 'profile', element: }, - { path: 'configuration', element: }, + { path: 'configuration', element: guard([P.VIEW_LICENSE_TYPES, P.VIEW_TEMPLATES], ) }, { path: 'analytics', element: }, - { path: 'applications/:id', element: }, + { path: 'applications/:id', element: guard(APPLICATION_QUEUE, ) }, // CoC/CoP review happens in the config-driven licence queue. { path: 'coc-queue', element: }, { path: 'coc-queue/:id', element: }, // Endorsement review happens in the config-driven licence queue. { path: 'endorsement-queue', element: }, { path: 'endorsement-queue/:id', element: }, - { path: 'medical-verification', element: }, - { path: 'payment-config', element: }, - { path: 'seafarer-registry', element: }, - { path: 'seaman-book-queue', element: }, - { path: 'questions', element: }, - { path: 'exams', element: }, - { path: 'exams/:id', element: }, - { path: 'exam-results', element: }, - { path: 'exam-appeals', element: }, - { path: 'vessel-registration-queue', element: }, - { path: 'vessel-registration-queue/new', element: }, + { path: 'medical-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], ) }, + { path: 'payment-config', element: guard([P.VIEW_PAYMENTS], ) }, + { path: 'seafarer-registry', element: guard([P.VIEW_SEAFARER_REGISTRY], ) }, + { path: 'seaman-book-queue', element: guard(APPLICATION_QUEUE, ) }, + { path: 'questions', element: guard([P.APPROVE_QUESTION, P.AUTHOR_QUESTION], ) }, + { path: 'exams', element: guard([P.MANAGE_EXAMS, P.RECORD_EXAM_ATTENDANCE, P.MANAGE_EXAM_INCIDENTS, P.PUBLISH_EXAM_RESULT], ) }, + { path: 'exams/:id', element: guard([P.MANAGE_EXAMS, P.RECORD_EXAM_ATTENDANCE, P.MANAGE_EXAM_INCIDENTS, P.PUBLISH_EXAM_RESULT], ) }, + { path: 'exam-results', element: guard([P.RECORD_EXAM_RESULT, P.MODERATE_EXAM_RESULT, P.APPROVE_EXAM_RESULT, P.PUBLISH_EXAM_RESULT], ) }, + { path: 'exam-appeals', element: guard([P.DECIDE_EXAM_APPEAL], ) }, + { path: 'vessel-registration-queue', element: guard([P.VIEW_VESSEL_REGISTRY], ) }, + { path: 'vessel-registration-queue/new', element: guard([P.VIEW_VESSEL_REGISTRY], ) }, // { path: 'vessel-registration-queue/:id', element: }, //{ path: 'vessel-registration-report', element: }, { path: 'vessel-ownership-transfer', element: }, { path: 'vessel-ownership-transfer/:id', element: }, // Config-driven review workspace, shared by every licence type. - { path: 'certificate-designer', element: }, - { path: 'licence-review', element: }, - { path: 'licence-register', element: }, + { path: 'certificate-designer', element: guard([P.VIEW_TEMPLATES], ) }, + { path: 'licence-review', element: guard(APPLICATION_QUEUE, ) }, + { path: 'licence-register', element: guard([P.VIEW_LICENSES], ) }, // Deep link into the grid with the type facet pinned, so "Freight // Forwarder" in the nav is a filtered view rather than a page. - { path: 'licence-review/type/:typeCode', element: }, - { path: 'licence-review/:id', element: }, + { path: 'licence-review/type/:typeCode', element: guard(APPLICATION_QUEUE, ) }, + { path: 'licence-review/:id', element: guard(APPLICATION_QUEUE, ) }, { path: 'freight-forwarder-license', element: }, { path: 'freight-forwarder-license/:id', element: }, { path: 'shipping-agent-license', element: }, diff --git a/apps/portal/src/app/features/certificates/pages/CertificatesPage/columns.tsx b/apps/portal/src/app/features/certificates/pages/CertificatesPage/columns.tsx index 99e480e3a..6a8df144d 100644 --- a/apps/portal/src/app/features/certificates/pages/CertificatesPage/columns.tsx +++ b/apps/portal/src/app/features/certificates/pages/CertificatesPage/columns.tsx @@ -2,8 +2,11 @@ import { Badge, Button, Text } from '@mantine/core'; import { IconCertificate } from '@tabler/icons-react'; import type { AdvancedColumn } from '@ema-platform/ui'; import type { Bilingual, IssuedLicense } from '@ema-platform/api'; +import { PORTAL_PERMISSIONS } from '@ema-platform/auth'; export function certificateColumns(deps: { + /** Permission check from usePermissions() — hooks can't run in a cell. */ + can: (required?: string[]) => boolean; localized: (value: Bilingual | undefined) => string; showDate: (value: string | null | undefined) => string; onDownload: (license: IssuedLicense) => void; @@ -45,16 +48,17 @@ export function certificateColumns(deps: { header: '', label: 'Actions', align: 'right', - cell: ({ row }) => ( - - ), + cell: ({ row }) => + deps.can([PORTAL_PERMISSIONS.VIEW_OWN_CERTIFICATES]) ? ( + + ) : null, }, ]; } diff --git a/apps/portal/src/app/features/certificates/pages/CertificatesPage/index.tsx b/apps/portal/src/app/features/certificates/pages/CertificatesPage/index.tsx index 4b02cbb4f..42ab94351 100644 --- a/apps/portal/src/app/features/certificates/pages/CertificatesPage/index.tsx +++ b/apps/portal/src/app/features/certificates/pages/CertificatesPage/index.tsx @@ -30,7 +30,7 @@ import { useGetMyMedicalCertificatesQuery, useGetMySeaTimeQuery, } from '@ema-platform/api'; -import { useCurrentProfile } from '@ema-platform/auth'; +import { useCurrentProfile, usePermissions } from '@ema-platform/auth'; import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui'; import { useDateDisplayer } from '@ema-platform/shared'; import { certificateColumns } from './columns'; @@ -76,6 +76,7 @@ export function CertificatesPage() { const showDate = useDateDisplayer(); const localized = useLocalized(); const issuedTable = useServerTable(); + const { can } = usePermissions(); const registered = Boolean(profile?.seafarerNumber) && profile?.seafarerStatus === 'ACTIVE'; @@ -225,6 +226,7 @@ export function CertificatesPage() { download(license.id), diff --git a/apps/portal/src/app/features/exams/pages/ExamsPage/columns.tsx b/apps/portal/src/app/features/exams/pages/ExamsPage/columns.tsx index 298edc6a0..f58e39a7f 100644 --- a/apps/portal/src/app/features/exams/pages/ExamsPage/columns.tsx +++ b/apps/portal/src/app/features/exams/pages/ExamsPage/columns.tsx @@ -2,6 +2,7 @@ import { Badge, Button, Text } from '@mantine/core'; import { IconFileText, IconGavel } from '@tabler/icons-react'; import type { AdvancedColumn } from '@ema-platform/ui'; import type { Bilingual } from '@ema-platform/api'; +import { PORTAL_PERMISSIONS } from '@ema-platform/auth'; import type { AttendanceStatus, MyAppeal, @@ -19,6 +20,8 @@ const ATTENDANCE_COLOR: Record = { }; export function registrationColumns(deps: { + /** Permission check from usePermissions() — hooks can't run in a cell. */ + can: (required?: string[]) => boolean; localized: (value: Bilingual | undefined) => string; showDate: (value: string | null | undefined) => string; onDownloadSlip: (registration: MyRegistration) => void; @@ -72,21 +75,24 @@ export function registrationColumns(deps: { }, { header: 'Slip', - cell: ({ row }) => ( - - ), + cell: ({ row }) => + deps.can([PORTAL_PERMISSIONS.VIEW_OWN_EXAM]) ? ( + + ) : null, }, ]; } export function resultColumns(deps: { + /** Permission check from usePermissions() — hooks can't run in a cell. */ + can: (required?: string[]) => boolean; localized: (value: Bilingual | undefined) => string; showDate: (value: string | null | undefined) => string; appeals: MyAppeal[]; @@ -128,7 +134,7 @@ export function resultColumns(deps: { {appeal.appealNumber} · {appeal.status} - ) : ( + ) : deps.can([PORTAL_PERMISSIONS.VIEW_OWN_EXAM]) ? ( - ); + ) : null; }, }, ]; diff --git a/apps/portal/src/app/features/exams/pages/ExamsPage/index.tsx b/apps/portal/src/app/features/exams/pages/ExamsPage/index.tsx index 993b8c7d9..b4cb0e0cf 100644 --- a/apps/portal/src/app/features/exams/pages/ExamsPage/index.tsx +++ b/apps/portal/src/app/features/exams/pages/ExamsPage/index.tsx @@ -21,6 +21,11 @@ import { openAuthedDocument, useLocalized, } from '@ema-platform/api'; +import { + PORTAL_PERMISSIONS, + RequirePermission, + usePermissions, +} from '@ema-platform/auth'; import { registrationColumns, resultColumns } from './columns'; export interface OpenExam { @@ -107,6 +112,7 @@ export function ExamsPage() { const [appealTrigger, { isLoading: appealing }] = useApiMutation(); const registrationTable = useServerTable(); const resultTable = useServerTable(); + const { can } = usePermissions(); const registeredExamIds = new Set((mine ?? []).map((r) => r.exam?.id)); @@ -211,14 +217,16 @@ export function ExamsPage() { Registered ) : ( - + + + )} @@ -231,6 +239,7 @@ export function ExamsPage() { tableName="My registrations" columns={registrationColumns({ + can, localized, showDate, onDownloadSlip: downloadSlip, @@ -250,6 +259,7 @@ export function ExamsPage() { tableName="My results" columns={resultColumns({ + can, localized, showDate, appeals: appeals ?? [], diff --git a/apps/portal/src/app/features/licensing/components/LicenseCard.tsx b/apps/portal/src/app/features/licensing/components/LicenseCard.tsx index f99252488..baab1a8c4 100644 --- a/apps/portal/src/app/features/licensing/components/LicenseCard.tsx +++ b/apps/portal/src/app/features/licensing/components/LicenseCard.tsx @@ -19,6 +19,11 @@ import { } from '@ema-platform/api'; import { notify } from '@ema-platform/ui'; import { useDateDisplayer } from '@ema-platform/shared'; +import { + LICENSE_PERMISSIONS, + PORTAL_PERMISSIONS, + RequirePermission, +} from '@ema-platform/auth'; /** * Renewal reuses the ordinary application wizard — a renewal is an @@ -109,36 +114,46 @@ export function LicenseCard({ {showDate(license.expiryDate)} - - - - - + + + + + + + {/* Renewal opens inside the licence type's window and stays open after expiry, so a lapsed licence is renewed rather than applied for afresh. */} {renewable && ( - + + + )} ); diff --git a/apps/portal/src/app/features/licensing/components/LicenseCatalogue.tsx b/apps/portal/src/app/features/licensing/components/LicenseCatalogue.tsx index 486a9d8d3..e3ac1bf0f 100644 --- a/apps/portal/src/app/features/licensing/components/LicenseCatalogue.tsx +++ b/apps/portal/src/app/features/licensing/components/LicenseCatalogue.tsx @@ -29,6 +29,7 @@ import { useGetMyOperatorTypesQuery, } from '@ema-platform/api'; import type { LicenseCategory, LicenseType } from '@ema-platform/api'; +import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; /** * The licence catalogue an applicant chooses from, grouped by category. @@ -308,16 +309,18 @@ function LicenseTypeCard({ )} - + + + diff --git a/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx b/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx index 86387c114..3c95453c2 100644 --- a/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx +++ b/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx @@ -54,7 +54,12 @@ import { type Vessel, } from '@ema-platform/api'; import { getCountryCode, getCountryName, ModalFooter } from '@ema-platform/ui'; -import { useCurrentProfile } from '@ema-platform/auth'; +import { + LICENSE_PERMISSIONS, + PORTAL_PERMISSIONS, + RequirePermission, + useCurrentProfile, +} from '@ema-platform/auth'; import { ConfigDrivenSection, fillFromVessel } from '../components/ConfigDrivenSection'; import { DocumentSlots } from '../components/DocumentSlots'; import { StaffEvidence } from '../components/StaffEvidence'; @@ -675,14 +680,23 @@ export function LicenseApplicationPage() { {active < steps.length - 1 ? ( ) : ( - + + )} diff --git a/apps/portal/src/app/features/licensing/pages/MyApplicationsPage/actions.tsx b/apps/portal/src/app/features/licensing/pages/MyApplicationsPage/actions.tsx index 7d20c0079..9e823f994 100644 --- a/apps/portal/src/app/features/licensing/pages/MyApplicationsPage/actions.tsx +++ b/apps/portal/src/app/features/licensing/pages/MyApplicationsPage/actions.tsx @@ -3,10 +3,13 @@ import { IconDownload } from '@tabler/icons-react'; import type { TFunction } from 'i18next'; import type { AdvancedColumn } from '@ema-platform/ui'; import type { LicenseApplication } from '@ema-platform/api'; +import { PORTAL_PERMISSIONS } from '@ema-platform/auth'; export function applicationActionsColumn( t: TFunction, deps: { + /** Permission check from usePermissions() — hooks can't run in a cell. */ + can: (required?: string[]) => boolean; bypassEnabled: boolean; bypassing: boolean; isPaying: boolean; @@ -39,7 +42,11 @@ export function applicationActionsColumn( used to be "View", which opened the application wizard — so the one thing the applicant came back for was the one thing the button did not do. */} - {app.status === 'CERTIFICATE_ISSUED' && ( + {app.status === 'CERTIFICATE_ISSUED' && + deps.can([ + PORTAL_PERMISSIONS.VIEW_OWN_CERTIFICATES, + PORTAL_PERMISSIONS.VIEW_COMPANY_LICENSES, + ]) && ( )} + {/* In PAYMENT_PENDING this button initiates payment, so it needs + that grant; every other status it merely opens the wizard. */} + {(app.status !== 'PAYMENT_PENDING' || + deps.can([PORTAL_PERMISSIONS.INITIATE_PAYMENT])) && ( + + + diff --git a/apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage/actions.tsx b/apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage/actions.tsx index 141b8eef6..28a3e4dc2 100644 --- a/apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage/actions.tsx +++ b/apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage/actions.tsx @@ -2,8 +2,11 @@ import { ActionIcon, Group, Tooltip } from '@mantine/core'; import { IconEdit, IconPaperclip, IconTrash } from '@tabler/icons-react'; import type { AdvancedColumn } from '@ema-platform/ui'; import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api'; +import { PORTAL_PERMISSIONS } from '@ema-platform/auth'; export function seaServiceActionsColumn(handlers: { + /** Permission check from usePermissions() — hooks can't run in a cell. */ + can: (required?: string[]) => boolean; onEvidence: (record: SeaServiceRecord) => void; onEdit: (record: SeaServiceRecord) => void; onDelete: (record: SeaServiceRecord) => void; @@ -25,25 +28,29 @@ export function seaServiceActionsColumn(handlers: { - - handlers.onEdit(record)} - > - - - - - handlers.onDelete(record)} - > - - - + {handlers.can([PORTAL_PERMISSIONS.EDIT_SEA_SERVICE]) && ( + <> + + handlers.onEdit(record)} + > + + + + + handlers.onDelete(record)} + > + + + + + )} ); }, @@ -51,6 +58,8 @@ export function seaServiceActionsColumn(handlers: { } export function medicalActionsColumn(handlers: { + /** Permission check from usePermissions() — hooks can't run in a cell. */ + can: (required?: string[]) => boolean; onEvidence: (certificate: MedicalCertificate) => void; onEdit: (certificate: MedicalCertificate) => void; onDelete: (certificate: MedicalCertificate) => void; @@ -72,25 +81,29 @@ export function medicalActionsColumn(handlers: { - - handlers.onEdit(certificate)} - > - - - - - handlers.onDelete(certificate)} - > - - - + {handlers.can([PORTAL_PERMISSIONS.UPLOAD_MEDICAL]) && ( + <> + + handlers.onEdit(certificate)} + > + + + + + handlers.onDelete(certificate)} + > + + + + + )} ); }, diff --git a/apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage/index.tsx b/apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage/index.tsx index 9db908f57..c8a7f7b90 100644 --- a/apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage/index.tsx +++ b/apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage/index.tsx @@ -44,6 +44,11 @@ import { useUpdateSeaServiceRecordMutation, } from '@ema-platform/api'; import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api'; +import { + PORTAL_PERMISSIONS, + RequirePermission, + usePermissions, +} from '@ema-platform/auth'; import { seaServiceColumns, medicalColumns, FITNESS_OPTIONS } from './columns'; import { seaServiceActionsColumn, medicalActionsColumn } from './actions'; @@ -112,18 +117,20 @@ function EvidenceModal({ )) )} - - {(props) => ( - - )} - + + + {(props) => ( + + )} + + ); @@ -144,6 +151,7 @@ const EMPTY_SEA_SERVICE = { function SeaServiceTab() { const showDate = useDateDisplayer(); + const { can } = usePermissions(); const { data: records, isLoading, refetch } = useGetMySeaServiceRecordsQuery(); const { data: seaTime } = useGetMySeaTimeQuery(); const [createRecord, { isLoading: creating }] = @@ -231,6 +239,7 @@ function SeaServiceTab() { const columns = [ ...seaServiceColumns(showDate), seaServiceActionsColumn({ + can, onEvidence: (record) => setEvidenceFor(record.id), onEdit: openEdit, onDelete: remove, @@ -251,9 +260,11 @@ function SeaServiceTab() { )} - + + + {(records ?? []).length === 0 ? ( @@ -387,6 +398,7 @@ const EMPTY_MEDICAL = { function MedicalTab() { const showDate = useDateDisplayer(); + const { can } = usePermissions(); const { data: certificates, isLoading, refetch } = useGetMyMedicalCertificatesQuery(); const [createCertificate, { isLoading: creating }] = useCreateMedicalCertificateMutation(); @@ -466,6 +478,7 @@ function MedicalTab() { const columns = [ ...medicalColumns(showDate), medicalActionsColumn({ + can, onEvidence: (certificate) => setEvidenceFor(certificate.id), onEdit: openEdit, onDelete: remove, @@ -479,9 +492,11 @@ function MedicalTab() { STCW medical fitness certificates. An expired certificate blocks new applications that require one. - + + + {(certificates ?? []).length === 0 ? ( diff --git a/apps/portal/src/app/features/seafarer/pages/SeafarerRegistrationPage.tsx b/apps/portal/src/app/features/seafarer/pages/SeafarerRegistrationPage.tsx index 0ec435e18..ad4bca49c 100644 --- a/apps/portal/src/app/features/seafarer/pages/SeafarerRegistrationPage.tsx +++ b/apps/portal/src/app/features/seafarer/pages/SeafarerRegistrationPage.tsx @@ -28,7 +28,11 @@ import { TERMINAL_STATUSES, useGetMyApplicationsQuery, } from '@ema-platform/api'; -import { useCurrentProfile } from '@ema-platform/auth'; +import { + PORTAL_PERMISSIONS, + RequirePermission, + useCurrentProfile, +} from '@ema-platform/auth'; const REGISTRATION_TYPE_KEY = 'SEAFARER_REGISTRATION'; @@ -244,13 +248,18 @@ export function SeafarerRegistrationPage() { - + + diff --git a/apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationPage/columns.tsx b/apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationPage/columns.tsx index 82496bb7c..b5027ea89 100644 --- a/apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationPage/columns.tsx +++ b/apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationPage/columns.tsx @@ -6,6 +6,7 @@ import { } from '@tabler/icons-react'; import type { AdvancedColumn } from '@ema-platform/ui'; import type { IssuedLicense, Vessel } from '@ema-platform/api'; +import { PORTAL_PERMISSIONS } from '@ema-platform/auth'; const CATEGORY_LABELS: Record = { INLAND_WATERWAY: 'Inland Waterway', @@ -19,6 +20,8 @@ const VESSEL_STATUS_COLORS: Record = { }; export function vesselColumns(handlers: { + /** Permission check from usePermissions() — hooks can't run in a cell. */ + can: (required?: string[]) => boolean; licenseById: Map; onDownloadCertificate: (vessel: Vessel) => void; onRenew: (vessel: Vessel) => void; @@ -102,39 +105,45 @@ export function vesselColumns(handlers: { handlers.licenseById.get(vessel.licenseId)?.renewable ?? false; return ( - - - - {renewable && vessel.status === 'REGISTERED' && ( - + {handlers.can([PORTAL_PERMISSIONS.VIEW_OWN_CERTIFICATES]) && ( + + + )} + {renewable && + vessel.status === 'REGISTERED' && + handlers.can([PORTAL_PERMISSIONS.APPLY_VESSEL_REGISTRATION]) && ( + + + + )} + {handlers.can([PORTAL_PERMISSIONS.REPORT_VESSEL_INCIDENT]) && ( + + )} - - - ); }, diff --git a/apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationPage/index.tsx b/apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationPage/index.tsx index f8fb59d33..6ed619f97 100644 --- a/apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationPage/index.tsx +++ b/apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationPage/index.tsx @@ -38,6 +38,11 @@ import { useGetMyVesselsQuery, } from '@ema-platform/api'; import type { Vessel } from '@ema-platform/api'; +import { + PORTAL_PERMISSIONS, + RequirePermission, + usePermissions, +} from '@ema-platform/auth'; import { vesselColumns } from './columns'; const REGISTRATION_TYPE_KEY = 'VESSEL_REGISTRATION'; @@ -135,6 +140,7 @@ export function VesselRegistrationPage() { const [getCertificateUrl] = useGetCertificateUrlMutation(); const [incidentFor, setIncidentFor] = useState(null); const table = useServerTable(); + const { can } = usePermissions(); const pagedVessels = table.paginate(vessels ?? []); const inFlight = (applications?.items ?? []).filter( @@ -186,12 +192,17 @@ export function VesselRegistrationPage() { Vessel Registration - + + {/* ----------------------------------------------------- in-flight */} @@ -262,20 +273,26 @@ export function VesselRegistrationPage() { issues the registration certificate and enters the vessel in the national register. - + + ) : ( boolean; localized: (value: Bilingual | undefined) => string; showDate: (value: string | null | undefined) => string; onDownload: (license: IssuedLicense) => void; @@ -44,16 +47,17 @@ export function waiverLetterColumns(deps: { header: '', label: t('waiver.columns.actions', 'Actions'), align: 'right', - cell: ({ row }) => ( - - ), + cell: ({ row }) => + deps.can([PORTAL_PERMISSIONS.VIEW_WAIVER_LETTER]) ? ( + + ) : null, }, ]; } diff --git a/apps/portal/src/app/features/waiver/pages/WaiverPage/index.tsx b/apps/portal/src/app/features/waiver/pages/WaiverPage/index.tsx index 34f708f9b..494c1b81e 100644 --- a/apps/portal/src/app/features/waiver/pages/WaiverPage/index.tsx +++ b/apps/portal/src/app/features/waiver/pages/WaiverPage/index.tsx @@ -25,6 +25,11 @@ import { } from '@ema-platform/api'; import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui'; import { useDateDisplayer } from '@ema-platform/shared'; +import { + PORTAL_PERMISSIONS, + RequirePermission, + usePermissions, +} from '@ema-platform/auth'; import { waiverLetterColumns } from './columns'; const WAIVER_TYPE_KEYS = ['PRE_WAIVER', 'POST_WAIVER']; @@ -46,6 +51,7 @@ export function WaiverPage() { const showDate = useDateDisplayer(); const localized = useLocalized(); const lettersTable = useServerTable(); + const { can } = usePermissions(); const waiverApplications = (applications?.items ?? []).filter((app) => WAIVER_TYPE_KEYS.includes(app.licenseType?.key ?? ''), @@ -97,12 +103,14 @@ export function WaiverPage() { 'The cargo has not yet arrived. Applying before arrival avoids the post-waiver penalty.', )} - + + + @@ -113,13 +121,15 @@ export function WaiverPage() { 'The cargo has already arrived. Granted once per shipment, and only against a settled penalty with the receipt attached.', )} - + + + @@ -172,6 +182,7 @@ export function WaiverPage() { tableName={t('waiver.issuedLetters', 'Issued waiver letters')} columns={waiverLetterColumns({ t, + can, localized, showDate, onDownload: (license) => download(license.id), diff --git a/apps/portal/src/app/layouts/PortalLayout.tsx b/apps/portal/src/app/layouts/PortalLayout.tsx index dcf5e0ce7..76b2d67be 100644 --- a/apps/portal/src/app/layouts/PortalLayout.tsx +++ b/apps/portal/src/app/layouts/PortalLayout.tsx @@ -19,9 +19,15 @@ import { Outlet, useLocation, useNavigate } from "react-router-dom"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { useDispatch } from "react-redux"; -import { notify, AppHeader, AppSidebar } from "@ema-platform/ui"; +import { notify, AppHeader, AppSidebar, filterByPermissions } from "@ema-platform/ui"; import type { NavItem } from "@ema-platform/ui"; -import { BrandMark, logout } from "@ema-platform/auth"; +import { + BrandMark, + logout, + usePermissions, + LICENSE_PERMISSIONS, + PORTAL_PERMISSIONS, +} from "@ema-platform/auth"; import { baseApi, useGetUnseenNotificationsQuery } from "@ema-platform/api"; import { SUPPORTED_LANGUAGES } from "../i18n/config"; import { useAppSelector } from "../store/hooks"; @@ -30,6 +36,11 @@ const BADGE_POLL_MS = 60_000; type PortalNavItem = NavItem & { i18nKey: string }; +// Grant keys the server computes per portal account type (seafarer, vessel +// owner, logistics rep). Items with no `permissions` are universal. +const P = PORTAL_PERMISSIONS; +const L = LICENSE_PERMISSIONS; + /** * Grouped for scannability, with `soon` marking screens that have no backend * behind them yet. There are no per-licence-type entries: applicants pick a @@ -56,26 +67,26 @@ const NAV_SECTIONS: { label?: string; items: PortalNavItem[] }[] = [ { label: "nav.groupLicensing", items: [ - { to: '/licensing/applications', label: 'My Applications', i18nKey: 'nav.myApplications', icon: IconTruck }, - { to: '/waiver', label: 'Waiver', i18nKey: 'nav.waiver', icon: IconShieldOff }, + { to: '/licensing/applications', label: 'My Applications', i18nKey: 'nav.myApplications', icon: IconTruck, permissions: [L.VIEW_OWN_APPLICATIONS] }, + { to: '/waiver', label: 'Waiver', i18nKey: 'nav.waiver', icon: IconShieldOff, permissions: [P.APPLY_WAIVER, P.VIEW_WAIVER_LETTER] }, ], }, { label: "nav.groupSeafarer", items: [ - { to: '/seafarer-registration', label: 'Seafarer Registration', i18nKey: 'nav.seafarerRegistration', icon: IconList }, - { to: '/seafarer/records', label: 'My Sea Records', i18nKey: 'nav.seaRecords', icon: IconList }, - { to: '/seaman-book', label: 'Seaman Book', i18nKey: 'nav.myApplication', icon: IconSend, soon: true }, - { to: '/certificates', label: 'Certificates', i18nKey: 'nav.certificates', icon: IconShieldCheck }, - { to: '/exams', label: 'Examinations', i18nKey: 'nav.exams', icon: IconList }, - { to: '/endorsements', label: 'Endorsements', i18nKey: 'nav.endorsements', icon: IconRubberStamp }, + { to: '/seafarer-registration', label: 'Seafarer Registration', i18nKey: 'nav.seafarerRegistration', icon: IconList, permissions: [P.APPLY_SEAFARER_REGISTRATION] }, + { to: '/seafarer/records', label: 'My Sea Records', i18nKey: 'nav.seaRecords', icon: IconList, permissions: [P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL] }, + { to: '/seaman-book', label: 'Seaman Book', i18nKey: 'nav.myApplication', icon: IconSend, soon: true, permissions: [P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL] }, + { to: '/certificates', label: 'Certificates', i18nKey: 'nav.certificates', icon: IconShieldCheck, permissions: [P.VIEW_OWN_CERTIFICATES] }, + { to: '/exams', label: 'Examinations', i18nKey: 'nav.exams', icon: IconList, permissions: [P.VIEW_OWN_EXAM, P.APPLY_EXAM] }, + { to: '/endorsements', label: 'Endorsements', i18nKey: 'nav.endorsements', icon: IconRubberStamp, permissions: [P.VIEW_OWN_CERTIFICATES] }, ], }, { label: "nav.groupVessels", items: [ - { to: '/vessel-registration', label: 'Vessel Registration', i18nKey: 'nav.vesselRegistration', icon: IconShip }, - { to: '/vessel-ownership-transfer', label: 'Ownership Transfer', i18nKey: 'nav.ownershipTransfer', icon: IconArrowsExchange }, + { to: '/vessel-registration', label: 'Vessel Registration', i18nKey: 'nav.vesselRegistration', icon: IconShip, permissions: [P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS] }, + { to: '/vessel-ownership-transfer', label: 'Ownership Transfer', i18nKey: 'nav.ownershipTransfer', icon: IconArrowsExchange, permissions: [P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS] }, ], }, { @@ -134,20 +145,22 @@ export function PortalLayout() { pollingInterval: BADGE_POLL_MS, refetchOnMountOrArgChange: false, }); + const { permissions: granted, known } = usePermissions(); - const sections = useMemo( - () => - NAV_SECTIONS.map((section) => ({ - label: section.label, - items: section.items.map(({ i18nKey, ...rest }) => ({ - ...rest, - label: t(i18nKey), - badge: - rest.to === "/notifications" && unseen?.count ? unseen.count : undefined, - })), + const sections = useMemo(() => { + const translated = NAV_SECTIONS.map((section) => ({ + label: section.label, + items: section.items.map(({ i18nKey, ...rest }) => ({ + ...rest, + label: t(i18nKey), + badge: + rest.to === "/notifications" && unseen?.count ? unseen.count : undefined, })), - [t, unseen?.count], - ); + })); + // Unfiltered until the grant list has loaded — same fail-open rule as + // RequirePermission: a moment of extra nav beats a flash of empty nav. + return known ? filterByPermissions(translated, granted) : translated; + }, [t, unseen?.count, granted, known]); // Breadcrumb trail const segments = location.pathname.split("/").filter(Boolean); diff --git a/apps/portal/src/app/router.tsx b/apps/portal/src/app/router.tsx index ea161b1cc..e6ec53024 100644 --- a/apps/portal/src/app/router.tsx +++ b/apps/portal/src/app/router.tsx @@ -11,8 +11,16 @@ import { OTPVerificationPage, ForgotPasswordPage, SetPasswordPage, + RequirePermission, + LICENSE_PERMISSIONS, + PORTAL_PERMISSIONS, } from "@ema-platform/auth"; +// Same grant keys as the matching PortalLayout nav items; the API enforces +// the real rule, this just keeps direct URLs consistent with the hidden nav. +const P = PORTAL_PERMISSIONS; +const L = LICENSE_PERMISSIONS; + // Portal feature pages import { DashboardPage } from "./features/dashboard/pages/DashboardPage"; import { RequireOperations } from "./features/onboarding/components/RequireOperations"; @@ -107,7 +115,14 @@ export const router = createBrowserRouter([ { path: "/onboarding/operations", element: }, // Config-driven licensing: one set of pages serves every licence type. - { path: "/licensing/applications", element: }, + { + path: "/licensing/applications", + element: ( + + + + ), + }, // Telebirr returns the applicant to these. { path: "/payments/check", element: }, @@ -117,19 +132,29 @@ export const router = createBrowserRouter([ path: "/licensing/:typeCode/apply", element: ( - + + + ), }, { path: "/licensing/:typeCode/applications/:applicationId", - element: , + element: ( + + + + ), }, // Notification / email deep links arrive as /applications/; resolve the // licence type and forward to the canonical route. { path: "/applications/:applicationId", - element: , + element: ( + + + + ), }, // Seafarer @@ -137,12 +162,28 @@ export const router = createBrowserRouter([ path: "/seafarer-registration", element: ( - + + + ), }, - { path: "/seafarer/records", element: }, - { path: "/exams", element: }, + { + path: "/seafarer/records", + element: ( + + + + ), + }, + { + path: "/exams", + element: ( + + + + ), + }, // The public-facing registry was a hardcoded mock and does not belong in // the applicant portal; officers browse seafarers in the backoffice. { @@ -156,17 +197,59 @@ export const router = createBrowserRouter([ // Phase 1 { path: "/documents", element: }, - { path: "/seaman-book", element: }, - { path: "/seaman-book/apply", element: }, + { + path: "/seaman-book", + element: ( + + + + ), + }, + { + path: "/seaman-book/apply", + element: ( + + + + ), + }, { path: "/notifications", element: }, // Phase 2 — CoC / CoP - { path: "/certificates", element: }, - { path: "/certificates/apply", element: }, + { + path: "/certificates", + element: ( + + + + ), + }, + { + path: "/certificates/apply", + element: ( + + + + ), + }, // Phase 3 — Endorsement - { path: "/endorsements", element: }, - { path: "/vessel-registration", element: }, + { + path: "/endorsements", + element: ( + + + + ), + }, + { + path: "/vessel-registration", + element: ( + + + + ), + }, // The registration wizard is the config-driven licensing flow; the old // standalone wizard posted to endpoints that never existed. { @@ -177,7 +260,14 @@ export const router = createBrowserRouter([ path: "/vessel-registration-dashboard", element: , }, - { path: "/vessel-ownership-transfer", element: }, + { + path: "/vessel-ownership-transfer", + element: ( + + + + ), + }, // The nav item used to nest this under /vessel-registration, which made // the sidebar's prefix-match (nav-utils.ts isItemActive) light up both // items at once. Kept as a redirect for old bookmarks/links. @@ -260,15 +350,33 @@ export const router = createBrowserRouter([ element: , }, // Waiver has no backend yet, so it says so rather than pretending. - { path: "/waiver", element: }, + { + path: "/waiver", + element: ( + + + + ), + }, { path: "/waiver/apply", element: }, // Vessel Registration - { path: "/vessel-registrations", element: }, + { + path: "/vessel-registrations", + element: ( + + + + ), + }, // { path: '/vessel-registrations/apply', element: }, { path: "/vessel-registrations/:id", - element: , + element: ( + + + + ), }, // General diff --git a/libs/auth/src/index.ts b/libs/auth/src/index.ts index b8ecc7986..886e79d3b 100644 --- a/libs/auth/src/index.ts +++ b/libs/auth/src/index.ts @@ -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, diff --git a/libs/auth/src/lib/components/RequirePermission.tsx b/libs/auth/src/lib/components/RequirePermission.tsx new file mode 100644 index 000000000..775095a55 --- /dev/null +++ b/libs/auth/src/lib/components/RequirePermission.tsx @@ -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: } /> + * Element usage: