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

@@ -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 =

View File

@@ -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<AttendanceStatus, string> = {
@@ -78,14 +79,19 @@ export function examCandidateColumns(
label: t('exam.candidates.record'),
align: 'right',
cell: ({ row }) => (
<Button
size="compact-xs"
variant="light"
leftSection={<IconUserCheck size={12} />}
onClick={() => handlers.onRecord(row.original)}
<RequirePermission
anyOf={[LICENSE_PERMISSIONS.RECORD_EXAM_ATTENDANCE]}
hideOnly
>
{t('exam.candidates.record')}
</Button>
<Button
size="compact-xs"
variant="light"
leftSection={<IconUserCheck size={12} />}
onClick={() => handlers.onRecord(row.original)}
>
{t('exam.candidates.record')}
</Button>
</RequirePermission>
),
},
];

View File

@@ -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<ExamIncidentStatus, string> = {
@@ -70,14 +71,19 @@ export function examIncidentColumns(
align: 'right',
cell: ({ row }) =>
row.original.status === 'OPEN' || row.original.status === 'UNDER_REVIEW' ? (
<Button
size="compact-xs"
variant="light"
leftSection={<IconAlertTriangle size={12} />}
onClick={() => handlers.onResolve(row.original)}
<RequirePermission
anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAM_INCIDENTS]}
hideOnly
>
{t('exam.incidents.resolve')}
</Button>
<Button
size="compact-xs"
variant="light"
leftSection={<IconAlertTriangle size={12} />}
onClick={() => handlers.onResolve(row.original)}
>
{t('exam.incidents.resolve')}
</Button>
</RequirePermission>
) : null,
},
];

View File

@@ -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 }) {
<Paper withBorder radius="lg" p="lg">
<Group justify="space-between" mb="md">
<Title order={5}>{t('exam.incidents.section')}</Title>
<Button
size="xs"
variant="light"
color="orange"
leftSection={<IconPlus size={14} />}
onClick={() => setAddOpen(true)}
<RequirePermission
anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAM_INCIDENTS]}
hideOnly
>
{t('exam.incidents.add')}
</Button>
<Button
size="xs"
variant="light"
color="orange"
leftSection={<IconPlus size={14} />}
onClick={() => setAddOpen(true)}
>
{t('exam.incidents.add')}
</Button>
</RequirePermission>
</Group>
{(incidents ?? []).length === 0 ? (

View File

@@ -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() {
<Title order={5}>
{t("exam.detail.questionsSection", { pts: totalPoints })}
</Title>
<Button
variant="light"
size="xs"
leftSection={<IconPlus size={14} />}
onClick={openAssignModal}
>
{t("exam.manageQuestions")}
</Button>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
<Button
variant="light"
size="xs"
leftSection={<IconPlus size={14} />}
onClick={openAssignModal}
>
{t("exam.manageQuestions")}
</Button>
</RequirePermission>
</Group>
{(exam.questions ?? []).length === 0 ? (
<Alert color="gray" icon={<IconInfoCircle size={16} />}>
@@ -461,9 +464,11 @@ export function ExamDetailPage() {
<Button variant="default" onClick={closeAssign} size="sm">
{t("exam.cancel")}
</Button>
<Button onClick={handleAssign} size="sm" loading={isAssigning}>
{t("exam.saveAssignments")}
</Button>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
<Button onClick={handleAssign} size="sm" loading={isAssigning}>
{t("exam.saveAssignments")}
</Button>
</RequirePermission>
</ModalFooter>
</>
) : (
@@ -478,9 +483,11 @@ export function ExamDetailPage() {
size="xs"
style={{ width: 80 }}
/>
<Button size="xs" variant="light" loading={isDrawing} onClick={handleRandomSelect}>
{t('exam.randomSelect')}
</Button>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
<Button size="xs" variant="light" loading={isDrawing} onClick={handleRandomSelect}>
{t('exam.randomSelect')}
</Button>
</RequirePermission>
</Group>
<QuestionAssigner
available={eligibleQuestions}
@@ -492,9 +499,11 @@ export function ExamDetailPage() {
<Button variant="default" onClick={closeAssign} size="sm">
{t("exam.cancel")}
</Button>
<Button onClick={handleAssign} size="sm" loading={isAssigning}>
{t("exam.saveAssignments")}
</Button>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
<Button onClick={handleAssign} size="sm" loading={isAssigning}>
{t("exam.saveAssignments")}
</Button>
</RequirePermission>
</ModalFooter>
</>
)}

View File

@@ -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 }) => (
<Group gap="xs">
<ActionIcon
variant="subtle"
color="blue"
size="sm"
onClick={() => handlers.onEdit(row.original)}
>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={() => handlers.onDelete(row.original)}
>
<IconTrash size={14} />
</ActionIcon>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
<ActionIcon
variant="subtle"
color="blue"
size="sm"
onClick={() => handlers.onEdit(row.original)}
>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={() => handlers.onDelete(row.original)}
>
<IconTrash size={14} />
</ActionIcon>
</RequirePermission>
<ActionIcon
variant="subtle"
color="red"

View File

@@ -24,6 +24,7 @@ import {
IconClipboardList,
} from "@tabler/icons-react";
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, AmharicDatePicker } from "@ema-platform/ui";
import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
import { useGetCertificationsQuery } from "../../../certification/api/certification-api";
import {
useGetExamsQuery,
@@ -450,14 +451,16 @@ export function ExamPage() {
</Text>
</div>
{!showForm && (
<Button
variant="light"
leftSection={<IconPlus size={16} />}
onClick={() => setShowForm(true)}
size="sm"
>
{t("exam.add")}
</Button>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
<Button
variant="light"
leftSection={<IconPlus size={16} />}
onClick={() => setShowForm(true)}
size="sm"
>
{t("exam.add")}
</Button>
</RequirePermission>
)}
</Group>

View File

@@ -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<string, string> = {
ACTIVE: 'green',
@@ -84,16 +85,24 @@ export function licenseRegisterColumns(
align: 'right',
cell: ({ row }) =>
actionsFor(row.original).length > 0 ? (
<Tooltip label="Suspend / revoke / reinstate">
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconShieldCog size={14} />}
onClick={() => handlers.onStatus(row.original)}
>
Status
</Button>
</Tooltip>
<RequirePermission
anyOf={[
LICENSE_PERMISSIONS.SUSPEND_LICENSE,
LICENSE_PERMISSIONS.CANCEL_LICENSE,
]}
hideOnly
>
<Tooltip label="Suspend / revoke / reinstate">
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconShieldCog size={14} />}
onClick={() => handlers.onStatus(row.original)}
>
Status
</Button>
</Tooltip>
</RequirePermission>
) : null,
},
];

View File

@@ -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

View File

@@ -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" ? (
<Button
size="xs"
loading={handlers.claiming}
onClick={() => handlers.onClaim(row.original.id)}
<RequirePermission
anyOf={[LICENSE_PERMISSIONS.CLAIM_APPLICATION]}
hideOnly
>
{t("queue.claim", "Claim")}
</Button>
<Button
size="xs"
loading={handlers.claiming}
onClick={() => handlers.onClaim(row.original.id)}
>
{t("queue.claim", "Claim")}
</Button>
</RequirePermission>
) : (
<Button
size="xs"

View File

@@ -52,6 +52,7 @@ import {
AmharicDatePicker,
type AdvancedColumn,
} from "@ema-platform/ui";
import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
import {
DEFAULT_VIEW,
SAVED_VIEWS,
@@ -638,12 +639,17 @@ export function LicenseQueuePage() {
>
{t("queue.export", "Export CSV")}
</Button>
<Button loading={claiming} onClick={handleBulkClaim}>
{t("queue.bulkClaim", {
count: selected.length,
defaultValue: "Claim {{count}}",
})}
</Button>
<RequirePermission
anyOf={[LICENSE_PERMISSIONS.CLAIM_APPLICATION]}
hideOnly
>
<Button loading={claiming} onClick={handleBulkClaim}>
{t("queue.bulkClaim", {
count: selected.length,
defaultValue: "Claim {{count}}",
})}
</Button>
</RequirePermission>
</Group>
</Group>
</Paper>

View File

@@ -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')}
</Button>
<Button
size="compact-xs"
color="teal"
leftSection={<IconCheck size={14} />}
loading={handlers.ruling}
onClick={() => handlers.onVerify(row.original)}
<RequirePermission
anyOf={[LICENSE_PERMISSIONS.VERIFY_SEAFARER_RECORDS]}
hideOnly
>
{t('recordVerification.verify', 'Verify')}
</Button>
<Button
size="compact-xs"
color="red"
variant="light"
leftSection={<IconX size={14} />}
onClick={() => handlers.onReject(row.original)}
>
{t('recordVerification.reject', 'Reject')}
</Button>
<Button
size="compact-xs"
color="teal"
leftSection={<IconCheck size={14} />}
loading={handlers.ruling}
onClick={() => handlers.onVerify(row.original)}
>
{t('recordVerification.verify', 'Verify')}
</Button>
<Button
size="compact-xs"
color="red"
variant="light"
leftSection={<IconX size={14} />}
onClick={() => handlers.onReject(row.original)}
>
{t('recordVerification.reject', 'Reject')}
</Button>
</RequirePermission>
</Group>
),
};
@@ -75,24 +81,29 @@ export function seaServiceActionsColumn(
>
{t('recordVerification.evidence', 'Evidence')}
</Button>
<Button
size="compact-xs"
color="teal"
leftSection={<IconCheck size={14} />}
loading={handlers.ruling}
onClick={() => handlers.onVerify(row.original)}
<RequirePermission
anyOf={[LICENSE_PERMISSIONS.VERIFY_SEAFARER_RECORDS]}
hideOnly
>
{t('recordVerification.verify', 'Verify')}
</Button>
<Button
size="compact-xs"
color="red"
variant="light"
leftSection={<IconX size={14} />}
onClick={() => handlers.onReject(row.original)}
>
{t('recordVerification.reject', 'Reject')}
</Button>
<Button
size="compact-xs"
color="teal"
leftSection={<IconCheck size={14} />}
loading={handlers.ruling}
onClick={() => handlers.onVerify(row.original)}
>
{t('recordVerification.verify', 'Verify')}
</Button>
<Button
size="compact-xs"
color="red"
variant="light"
leftSection={<IconX size={14} />}
onClick={() => handlers.onReject(row.original)}
>
{t('recordVerification.reject', 'Reject')}
</Button>
</RequirePermission>
</Group>
),
};

View File

@@ -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 }) => (
<Button
size="xs"
variant="light"
leftSection={<IconEdit size={14} />}
onClick={() => handlers.onEdit(row.original)}
<RequirePermission
anyOf={[LICENSE_PERMISSIONS.UPDATE_LICENSE_TYPE]}
hideOnly
>
{t('paymentConfig.edit', 'Edit')}
</Button>
<Button
size="xs"
variant="light"
leftSection={<IconEdit size={14} />}
onClick={() => handlers.onEdit(row.original)}
>
{t('paymentConfig.edit', 'Edit')}
</Button>
</RequirePermission>
),
};
}

View File

@@ -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 (
<Group gap="xs">
{(q.status === 'DRAFT' || q.status === 'REJECTED') && (
<Button
size="compact-xs"
variant="light"
leftSection={<IconSend size={12} />}
loading={handlers.isSubmittingReview}
onClick={() => handlers.onSubmitForApproval(q)}
>
{t('question.qc.submit')}
</Button>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]} hideOnly>
<Button
size="compact-xs"
variant="light"
leftSection={<IconSend size={12} />}
loading={handlers.isSubmittingReview}
onClick={() => handlers.onSubmitForApproval(q)}
>
{t('question.qc.submit')}
</Button>
</RequirePermission>
)}
{q.status === 'PENDING_APPROVAL' && (
<>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_QUESTION]} hideOnly>
<Button size="compact-xs" variant="light" color="teal" onClick={() => handlers.onReview(q, 'APPROVED')}>
{t('question.qc.approve')}
</Button>
<Button size="compact-xs" variant="light" color="red" onClick={() => handlers.onReview(q, 'REJECTED')}>
{t('question.qc.reject')}
</Button>
</>
</RequirePermission>
)}
{q.status === 'APPROVED' && (
<Button
size="compact-xs"
variant="subtle"
color="dark"
leftSection={<IconGavel size={12} />}
onClick={() => handlers.onReview(q, 'RETIRED')}
>
{t('question.qc.retire')}
</Button>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_QUESTION]} hideOnly>
<Button
size="compact-xs"
variant="subtle"
color="dark"
leftSection={<IconGavel size={12} />}
onClick={() => handlers.onReview(q, 'RETIRED')}
>
{t('question.qc.retire')}
</Button>
</RequirePermission>
)}
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => handlers.onEdit(q)}>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => handlers.onDelete(q)}>
<IconTrash size={14} />
</ActionIcon>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]} hideOnly>
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => handlers.onEdit(q)}>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => handlers.onDelete(q)}>
<IconTrash size={14} />
</ActionIcon>
</RequirePermission>
</Group>
);
},

View File

@@ -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() {
<Group justify="space-between" align="flex-end">
<Title order={2}>{t('question.title')}</Title>
{!showForm && (
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
{t('question.addQuestion')}
</Button>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]} hideOnly>
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
{t('question.addQuestion')}
</Button>
</RequirePermission>
)}
</Group>

View File

@@ -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({
<ModalFooter>
<Button variant="default" onClick={onClose} size="sm">{t('result.cancel')}</Button>
<Button onClick={handleSave} size="sm" loading={isSaving}>
{t('result.saveResult')}
</Button>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.RECORD_EXAM_RESULT]} hideOnly>
<Button onClick={handleSave} size="sm" loading={isSaving}>
{t('result.saveResult')}
</Button>
</RequirePermission>
</ModalFooter>
</>
)}

View File

@@ -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 }) => (
<Button
size="compact-xs"
variant="light"
color="grape"
leftSection={<IconGavel size={12} />}
onClick={() => handlers.onDecide(row.original)}
>
{t('result.appeals.decide')}
</Button>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.DECIDE_EXAM_APPEAL]} hideOnly>
<Button
size="compact-xs"
variant="light"
color="grape"
leftSection={<IconGavel size={12} />}
onClick={() => handlers.onDecide(row.original)}
>
{t('result.appeals.decide')}
</Button>
</RequirePermission>
),
},
];

View File

@@ -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(
<Group gap="xs">
{(r.reviewStatus === 'MARKED' || r.reviewStatus === 'MODERATED') && (
<>
<Button size="compact-xs" variant="light" color="yellow" onClick={() => handlers.onQc(r, 'moderate')}>
{t('result.review.moderate')}
</Button>
<Button size="compact-xs" variant="light" color="blue" onClick={() => handlers.onQc(r, 'approve')}>
{t('result.review.approve')}
</Button>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MODERATE_EXAM_RESULT]} hideOnly>
<Button size="compact-xs" variant="light" color="yellow" onClick={() => handlers.onQc(r, 'moderate')}>
{t('result.review.moderate')}
</Button>
</RequirePermission>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT]} hideOnly>
<Button size="compact-xs" variant="light" color="blue" onClick={() => handlers.onQc(r, 'approve')}>
{t('result.review.approve')}
</Button>
</RequirePermission>
</>
)}
{(r.reviewStatus === 'APPROVED' || r.reviewStatus === 'MODERATED') && (
<Button size="compact-xs" variant="subtle" color="orange" onClick={() => handlers.onQc(r, 'return')}>
{t('result.review.return')}
</Button>
<RequirePermission
anyOf={[
LICENSE_PERMISSIONS.MODERATE_EXAM_RESULT,
LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT,
]}
hideOnly
>
<Button size="compact-xs" variant="subtle" color="orange" onClick={() => handlers.onQc(r, 'return')}>
{t('result.review.return')}
</Button>
</RequirePermission>
)}
<Button size="xs" variant="subtle" leftSection={<IconEye size={13} />} onClick={() => handlers.onViewDetail(r)}>
{t('result.action.viewEdit')}
</Button>
<Button
size="xs"
variant="subtle"
color="red"
leftSection={<IconTrash size={13} />}
onClick={() => handlers.onDelete(r)}
>
{t('result.action.delete')}
</Button>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT]} hideOnly>
<Button
size="xs"
variant="subtle"
color="red"
leftSection={<IconTrash size={13} />}
onClick={() => handlers.onDelete(r)}
>
{t('result.action.delete')}
</Button>
</RequirePermission>
</Group>
);
},

View File

@@ -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() {
<Text fz="sm" c="dimmed">{t('result.subtitle')}</Text>
</div>
<Group gap="sm">
<Button
variant="light"
color="teal"
size="sm"
loading={isPublishing}
disabled={!examFilter}
leftSection={<IconSend size={15} />}
onClick={handlePublish}
>
{t('result.review.publish')}
</Button>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.PUBLISH_EXAM_RESULT]} hideOnly>
<Button
variant="light"
color="teal"
size="sm"
loading={isPublishing}
disabled={!examFilter}
leftSection={<IconSend size={15} />}
onClick={handlePublish}
>
{t('result.review.publish')}
</Button>
</RequirePermission>
<Button leftSection={<IconPlus size={15} />} onClick={openPicker} size="sm">
{t('result.record')}
</Button>

View File

@@ -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 ? (
<Tooltip label={t('seafarerRegistry.statusActionTooltip', 'Suspend / reinstate / close')}>
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconShieldCog size={14} />}
onClick={() => handlers.onStatus(row.original)}
>
{t('seafarerRegistry.statusAction', 'Status')}
</Button>
</Tooltip>
<RequirePermission
anyOf={[LICENSE_PERMISSIONS.MANAGE_SEAFARER_STATUS]}
hideOnly
>
<Tooltip label={t('seafarerRegistry.statusActionTooltip', 'Suspend / reinstate / close')}>
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconShieldCog size={14} />}
onClick={() => handlers.onStatus(row.original)}
>
{t('seafarerRegistry.statusAction', 'Status')}
</Button>
</Tooltip>
</RequirePermission>
) : null,
};
}

View File

@@ -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<string, string> = {
REGISTERED: 'green',
@@ -76,19 +77,24 @@ export function vesselRegistrationQueueColumns(
label: 'Actions',
align: 'right',
cell: ({ row }) => (
<Tooltip label="Suspend / deregister / reinstate">
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconShieldCog size={14} />}
onClick={(e) => {
e.stopPropagation();
handlers.onStatus(row.original);
}}
>
Status
</Button>
</Tooltip>
<RequirePermission
anyOf={[LICENSE_PERMISSIONS.MANAGE_VESSEL_STATUS]}
hideOnly
>
<Tooltip label="Suspend / deregister / reinstate">
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconShieldCog size={14} />}
onClick={(e) => {
e.stopPropagation();
handlers.onStatus(row.original);
}}
>
Status
</Button>
</Tooltip>
</RequirePermission>
),
},
];

View File

@@ -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 },
],

View File

@@ -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) => (
<RequirePermission anyOf={anyOf}>{element}</RequirePermission>
);
const router = createBrowserRouter([
{
element: <AuthLayout />,
@@ -60,41 +71,41 @@ const router = createBrowserRouter([
children: [
{ index: true, element: <Navigate to="/dashboard" replace /> },
{ path: 'dashboard', element: <DashboardPage /> },
{ path: 'vessel-registration-head-dashboard', element: <VesselRegistrationHeadDashboardPage /> },
{ path: 'logistics-head-dashboard', element: <LogisticsHeadDashboardPage /> },
{ path: 'vessel-registration-head-dashboard', element: guard([P.VIEW_VESSEL_REGISTRY], <VesselRegistrationHeadDashboardPage />) },
{ path: 'logistics-head-dashboard', element: guard(APPLICATION_QUEUE, <LogisticsHeadDashboardPage />) },
{ path: 'profile', element: <ProfilePage /> },
{ path: 'configuration', element: <ConfigurationPage /> },
{ path: 'configuration', element: guard([P.VIEW_LICENSE_TYPES, P.VIEW_TEMPLATES], <ConfigurationPage />) },
{ path: 'analytics', element: <AnalyticsPage /> },
{ path: 'applications/:id', element: <ApplicationReviewPage /> },
{ path: 'applications/:id', element: guard(APPLICATION_QUEUE, <ApplicationReviewPage />) },
// CoC/CoP review happens in the config-driven licence queue.
{ path: 'coc-queue', element: <Navigate to="/licence-review/type/CERTIFICATE_OF_COMPETENCY" replace /> },
{ path: 'coc-queue/:id', element: <Navigate to="/licence-review" replace /> },
// Endorsement review happens in the config-driven licence queue.
{ path: 'endorsement-queue', element: <Navigate to="/licence-review/type/ENDORSEMENT_COC" replace /> },
{ path: 'endorsement-queue/:id', element: <Navigate to="/licence-review" replace /> },
{ path: 'medical-verification', element: <MedicalVerificationPage /> },
{ path: 'payment-config', element: <PaymentConfigPage /> },
{ path: 'seafarer-registry', element: <SeafarerRegistryPage /> },
{ path: 'seaman-book-queue', element: <SeamanBookQueuePage /> },
{ path: 'questions', element: <QuestionPage /> },
{ path: 'exams', element: <ExamPage /> },
{ path: 'exams/:id', element: <ExamDetailPage /> },
{ path: 'exam-results', element: <ResultPage /> },
{ path: 'exam-appeals', element: <ExamAppealsPage /> },
{ path: 'vessel-registration-queue', element: <VesselRegistrationQueuePage /> },
{ path: 'vessel-registration-queue/new', element: <VesselRegistrationFormBuilderPage /> },
{ path: 'medical-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], <MedicalVerificationPage />) },
{ path: 'payment-config', element: guard([P.VIEW_PAYMENTS], <PaymentConfigPage />) },
{ path: 'seafarer-registry', element: guard([P.VIEW_SEAFARER_REGISTRY], <SeafarerRegistryPage />) },
{ path: 'seaman-book-queue', element: guard(APPLICATION_QUEUE, <SeamanBookQueuePage />) },
{ path: 'questions', element: guard([P.APPROVE_QUESTION, P.AUTHOR_QUESTION], <QuestionPage />) },
{ path: 'exams', element: guard([P.MANAGE_EXAMS, P.RECORD_EXAM_ATTENDANCE, P.MANAGE_EXAM_INCIDENTS, P.PUBLISH_EXAM_RESULT], <ExamPage />) },
{ path: 'exams/:id', element: guard([P.MANAGE_EXAMS, P.RECORD_EXAM_ATTENDANCE, P.MANAGE_EXAM_INCIDENTS, P.PUBLISH_EXAM_RESULT], <ExamDetailPage />) },
{ path: 'exam-results', element: guard([P.RECORD_EXAM_RESULT, P.MODERATE_EXAM_RESULT, P.APPROVE_EXAM_RESULT, P.PUBLISH_EXAM_RESULT], <ResultPage />) },
{ path: 'exam-appeals', element: guard([P.DECIDE_EXAM_APPEAL], <ExamAppealsPage />) },
{ path: 'vessel-registration-queue', element: guard([P.VIEW_VESSEL_REGISTRY], <VesselRegistrationQueuePage />) },
{ path: 'vessel-registration-queue/new', element: guard([P.VIEW_VESSEL_REGISTRY], <VesselRegistrationFormBuilderPage />) },
// { path: 'vessel-registration-queue/:id', element: <VesselRegistrationReviewPage /> },
//{ path: 'vessel-registration-report', element: <VesselRegistrationReportPage /> },
{ path: 'vessel-ownership-transfer', element: <Navigate to="/licence-review/type/VESSEL_OWNERSHIP_TRANSFER" replace /> },
{ path: 'vessel-ownership-transfer/:id', element: <Navigate to="/licence-review" replace /> },
// Config-driven review workspace, shared by every licence type.
{ path: 'certificate-designer', element: <CertificateDesignerPage /> },
{ path: 'licence-review', element: <LicenseQueuePage /> },
{ path: 'licence-register', element: <LicenseRegisterPage /> },
{ path: 'certificate-designer', element: guard([P.VIEW_TEMPLATES], <CertificateDesignerPage />) },
{ path: 'licence-review', element: guard(APPLICATION_QUEUE, <LicenseQueuePage />) },
{ path: 'licence-register', element: guard([P.VIEW_LICENSES], <LicenseRegisterPage />) },
// 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: <LicenseQueuePage /> },
{ path: 'licence-review/:id', element: <LicenseReviewPage /> },
{ path: 'licence-review/type/:typeCode', element: guard(APPLICATION_QUEUE, <LicenseQueuePage />) },
{ path: 'licence-review/:id', element: guard(APPLICATION_QUEUE, <LicenseReviewPage />) },
{ path: 'freight-forwarder-license', element: <Navigate to="/licence-review" replace /> },
{ path: 'freight-forwarder-license/:id', element: <Navigate to="/licence-review" replace /> },
{ path: 'shipping-agent-license', element: <Navigate to="/licence-review" replace /> },

View File

@@ -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 }) => (
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconCertificate size={14} />}
onClick={() => deps.onDownload(row.original)}
>
Download
</Button>
),
cell: ({ row }) =>
deps.can([PORTAL_PERMISSIONS.VIEW_OWN_CERTIFICATES]) ? (
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconCertificate size={14} />}
onClick={() => deps.onDownload(row.original)}
>
Download
</Button>
) : null,
},
];
}

View File

@@ -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() {
<AdvancedTable
tableName="Issued certificates"
columns={certificateColumns({
can,
localized,
showDate,
onDownload: (license) => download(license.id),

View File

@@ -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<AttendanceStatus, string> = {
};
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 }) => (
<Button
size="compact-xs"
variant="light"
leftSection={<IconFileText size={13} />}
onClick={() => deps.onDownloadSlip(row.original)}
>
Slip
</Button>
),
cell: ({ row }) =>
deps.can([PORTAL_PERMISSIONS.VIEW_OWN_EXAM]) ? (
<Button
size="compact-xs"
variant="light"
leftSection={<IconFileText size={13} />}
onClick={() => deps.onDownloadSlip(row.original)}
>
Slip
</Button>
) : 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: {
<Badge size="sm" variant="light" color="grape">
{appeal.appealNumber} · {appeal.status}
</Badge>
) : (
) : deps.can([PORTAL_PERMISSIONS.VIEW_OWN_EXAM]) ? (
<Button
size="compact-xs"
variant="light"
@@ -138,7 +144,7 @@ export function resultColumns(deps: {
>
Appeal
</Button>
);
) : null;
},
},
];

View File

@@ -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
</Badge>
) : (
<Button
size="compact-sm"
loading={registering}
leftSection={<IconClipboardList size={14} />}
onClick={() => register(exam)}
>
Register
</Button>
<RequirePermission anyOf={[PORTAL_PERMISSIONS.APPLY_EXAM]} hideOnly>
<Button
size="compact-sm"
loading={registering}
leftSection={<IconClipboardList size={14} />}
onClick={() => register(exam)}
>
Register
</Button>
</RequirePermission>
)}
</Group>
</Card>
@@ -231,6 +239,7 @@ export function ExamsPage() {
<AdvancedTable<MyRegistration>
tableName="My registrations"
columns={registrationColumns({
can,
localized,
showDate,
onDownloadSlip: downloadSlip,
@@ -250,6 +259,7 @@ export function ExamsPage() {
<AdvancedTable<MyResult>
tableName="My results"
columns={resultColumns({
can,
localized,
showDate,
appeals: appeals ?? [],

View File

@@ -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)}
</Text>
</Box>
<Tooltip label="Download certificate">
<ActionIcon
variant="light"
radius="md"
size="lg"
loading={isDownloading}
onClick={onDownload}
>
<IconDownload size={16} />
</ActionIcon>
</Tooltip>
<RequirePermission
anyOf={[
PORTAL_PERMISSIONS.VIEW_OWN_CERTIFICATES,
PORTAL_PERMISSIONS.VIEW_COMPANY_LICENSES,
]}
hideOnly
>
<Tooltip label="Download certificate">
<ActionIcon
variant="light"
radius="md"
size="lg"
loading={isDownloading}
onClick={onDownload}
>
<IconDownload size={16} />
</ActionIcon>
</Tooltip>
</RequirePermission>
</Group>
{/* 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 && (
<Button
fullWidth
mt="sm"
size="xs"
variant={expired ? 'filled' : 'light'}
color={expired ? 'orange' : undefined}
loading={isRenewing}
leftSection={<IconRefresh size={14} />}
onClick={onRenew}
>
{expired
? 'Renew — this licence has expired'
: `Renew — expires in ${days} day${days === 1 ? '' : 's'}`}
</Button>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.CREATE_APPLICATION]} hideOnly>
<Button
fullWidth
mt="sm"
size="xs"
variant={expired ? 'filled' : 'light'}
color={expired ? 'orange' : undefined}
loading={isRenewing}
leftSection={<IconRefresh size={14} />}
onClick={onRenew}
>
{expired
? 'Renew — this licence has expired'
: `Renew — expires in ${days} day${days === 1 ? '' : 's'}`}
</Button>
</RequirePermission>
)}
</Card>
);

View File

@@ -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({
</Tooltip>
)}
</Group>
<Button
fullWidth
mt="sm"
size="xs"
variant="light"
color={canApply ? undefined : 'gray'}
rightSection={<IconArrowRight size={14} />}
>
{canApply ? 'Start application' : 'Add to my operations'}
</Button>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.CREATE_APPLICATION]} hideOnly>
<Button
fullWidth
mt="sm"
size="xs"
variant="light"
color={canApply ? undefined : 'gray'}
rightSection={<IconArrowRight size={14} />}
>
{canApply ? 'Start application' : 'Add to my operations'}
</Button>
</RequirePermission>
</Box>
</Stack>
</Card>

View File

@@ -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 ? (
<Button onClick={handleContinue}>Continue</Button>
) : (
<Button
color="teal"
loading={submitting || resubmitting}
disabled={readOnly}
onClick={handleSubmit}
<RequirePermission
anyOf={
isAdjusting
? [PORTAL_PERMISSIONS.RESUBMIT_APPLICATION]
: [LICENSE_PERMISSIONS.SUBMIT_APPLICATION]
}
hideOnly
>
{isAdjusting ? 'Resubmit corrections' : 'Submit application'}
</Button>
<Button
color="teal"
loading={submitting || resubmitting}
disabled={readOnly}
onClick={handleSubmit}
>
{isAdjusting ? 'Resubmit corrections' : 'Submit application'}
</Button>
</RequirePermission>
)}
</Group>
</Paper>

View File

@@ -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,
]) && (
<Button
size="xs"
leftSection={<IconDownload size={14} />}
@@ -48,6 +55,10 @@ export function applicationActionsColumn(
{t('applications.actions.certificate')}
</Button>
)}
{/* 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])) && (
<Button
size="xs"
loading={deps.isPaying && app.status === 'PAYMENT_PENDING'}
@@ -80,6 +91,7 @@ export function applicationActionsColumn(
? t('applications.actions.certificate')
: t('applications.actions.view')}
</Button>
)}
</Group>
);
},

View File

@@ -48,6 +48,11 @@ import {
useGetPaymentCapabilitiesQuery,
type LicenseStatus,
} from '@ema-platform/api';
import {
LICENSE_PERMISSIONS,
RequirePermission,
usePermissions,
} from '@ema-platform/auth';
import { applicationColumns } from './columns';
import { applicationActionsColumn } from './actions';
import classes from '../MyApplicationsPage.module.css';
@@ -96,6 +101,7 @@ export function MyApplicationsPage() {
const [getCertificateUrl] = useGetCertificateUrlMutation();
const { renewLicense, isRenewing } = useRenewLicense();
const [isDownloadingCert, setIsDownloadingCert] = useState(false);
const { can } = usePermissions();
const [tab, setTab] = useState<Tab>(() =>
typeof window !== 'undefined' ? tabFromHash(window.location.hash) : 'applications',
@@ -237,6 +243,7 @@ export function MyApplicationsPage() {
const columns = [
...applicationColumns(t, { language: i18n.language, showDate, statusLabel }),
applicationActionsColumn(t, {
can,
bypassEnabled: capabilities?.bypassEnabled ?? false,
bypassing,
isPaying,
@@ -258,9 +265,11 @@ export function MyApplicationsPage() {
{t('applications.subtitle')}
</Text>
</Box>
<Button leftSection={<IconPlus size={16} />} onClick={() => changeTab('apply')}>
{t('applications.newApplication')}
</Button>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.CREATE_APPLICATION]} hideOnly>
<Button leftSection={<IconPlus size={16} />} onClick={() => changeTab('apply')}>
{t('applications.newApplication')}
</Button>
</RequirePermission>
</Group>
<SimpleGrid cols={{ base: 2, md: 4 }} spacing="md">

View File

@@ -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: {
<IconPaperclip size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label={locked ? 'Verified records are frozen' : 'Edit'}>
<ActionIcon
variant="subtle"
disabled={locked}
onClick={() => handlers.onEdit(record)}
>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label={locked ? 'Verified records are frozen' : 'Delete'}>
<ActionIcon
variant="subtle"
color="red"
disabled={locked}
onClick={() => handlers.onDelete(record)}
>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
{handlers.can([PORTAL_PERMISSIONS.EDIT_SEA_SERVICE]) && (
<>
<Tooltip label={locked ? 'Verified records are frozen' : 'Edit'}>
<ActionIcon
variant="subtle"
disabled={locked}
onClick={() => handlers.onEdit(record)}
>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label={locked ? 'Verified records are frozen' : 'Delete'}>
<ActionIcon
variant="subtle"
color="red"
disabled={locked}
onClick={() => handlers.onDelete(record)}
>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
</>
)}
</Group>
);
},
@@ -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: {
<IconPaperclip size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label={locked ? 'Verified certificates are frozen' : 'Edit'}>
<ActionIcon
variant="subtle"
disabled={locked}
onClick={() => handlers.onEdit(certificate)}
>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label={locked ? 'Verified certificates are frozen' : 'Delete'}>
<ActionIcon
variant="subtle"
color="red"
disabled={locked}
onClick={() => handlers.onDelete(certificate)}
>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
{handlers.can([PORTAL_PERMISSIONS.UPLOAD_MEDICAL]) && (
<>
<Tooltip label={locked ? 'Verified certificates are frozen' : 'Edit'}>
<ActionIcon
variant="subtle"
disabled={locked}
onClick={() => handlers.onEdit(certificate)}
>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label={locked ? 'Verified certificates are frozen' : 'Delete'}>
<ActionIcon
variant="subtle"
color="red"
disabled={locked}
onClick={() => handlers.onDelete(certificate)}
>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
</>
)}
</Group>
);
},

View File

@@ -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({
</Group>
))
)}
<FileButton onChange={upload} accept="image/*,application/pdf">
{(props) => (
<Button
{...props}
variant="light"
loading={uploading}
leftSection={<IconFileUpload size={16} />}
>
Upload evidence
</Button>
)}
</FileButton>
<RequirePermission anyOf={[PORTAL_PERMISSIONS.UPLOAD_DOCUMENTS]} hideOnly>
<FileButton onChange={upload} accept="image/*,application/pdf">
{(props) => (
<Button
{...props}
variant="light"
loading={uploading}
leftSection={<IconFileUpload size={16} />}
>
Upload evidence
</Button>
)}
</FileButton>
</RequirePermission>
</Stack>
</Modal>
);
@@ -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() {
</Badge>
)}
</Group>
<Button leftSection={<IconPlus size={16} />} onClick={openCreate}>
Add sea service
</Button>
<RequirePermission anyOf={[PORTAL_PERMISSIONS.ADD_SEA_SERVICE]} hideOnly>
<Button leftSection={<IconPlus size={16} />} onClick={openCreate}>
Add sea service
</Button>
</RequirePermission>
</Group>
{(records ?? []).length === 0 ? (
<Paper withBorder p="xl" radius="md">
@@ -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.
</Text>
<Button leftSection={<IconPlus size={16} />} onClick={openCreate}>
Add certificate
</Button>
<RequirePermission anyOf={[PORTAL_PERMISSIONS.UPLOAD_MEDICAL]} hideOnly>
<Button leftSection={<IconPlus size={16} />} onClick={openCreate}>
Add certificate
</Button>
</RequirePermission>
</Group>
{(certificates ?? []).length === 0 ? (
<Paper withBorder p="xl" radius="md">

View File

@@ -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() {
</List.Item>
</List>
<Group mt="md">
<Button
size="md"
rightSection={<IconArrowRight size={18} />}
onClick={() => navigate(`/licensing/${REGISTRATION_TYPE_KEY}/apply`)}
<RequirePermission
anyOf={[PORTAL_PERMISSIONS.APPLY_SEAFARER_REGISTRATION]}
hideOnly
>
Start registration
</Button>
<Button
size="md"
rightSection={<IconArrowRight size={18} />}
onClick={() => navigate(`/licensing/${REGISTRATION_TYPE_KEY}/apply`)}
>
Start registration
</Button>
</RequirePermission>
</Group>
</Stack>
</Card>

View File

@@ -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<string, string> = {
INLAND_WATERWAY: 'Inland Waterway',
@@ -19,6 +20,8 @@ const VESSEL_STATUS_COLORS: Record<string, string> = {
};
export function vesselColumns(handlers: {
/** Permission check from usePermissions() — hooks can't run in a cell. */
can: (required?: string[]) => boolean;
licenseById: Map<string, IssuedLicense>;
onDownloadCertificate: (vessel: Vessel) => void;
onRenew: (vessel: Vessel) => void;
@@ -102,39 +105,45 @@ export function vesselColumns(handlers: {
handlers.licenseById.get(vessel.licenseId)?.renewable ?? false;
return (
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Tooltip label="Download certificate">
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconCertificate size={14} />}
onClick={() => handlers.onDownloadCertificate(vessel)}
>
Certificate
</Button>
</Tooltip>
{renewable && vessel.status === 'REGISTERED' && (
<Tooltip label="Renew the registration">
{handlers.can([PORTAL_PERMISSIONS.VIEW_OWN_CERTIFICATES]) && (
<Tooltip label="Download certificate">
<Button
size="compact-xs"
variant="light"
leftSection={<IconRefresh size={14} />}
onClick={() => handlers.onRenew(vessel)}
variant="subtle"
leftSection={<IconCertificate size={14} />}
onClick={() => handlers.onDownloadCertificate(vessel)}
>
Renew
Certificate
</Button>
</Tooltip>
)}
{renewable &&
vessel.status === 'REGISTERED' &&
handlers.can([PORTAL_PERMISSIONS.APPLY_VESSEL_REGISTRATION]) && (
<Tooltip label="Renew the registration">
<Button
size="compact-xs"
variant="light"
leftSection={<IconRefresh size={14} />}
onClick={() => handlers.onRenew(vessel)}
>
Renew
</Button>
</Tooltip>
)}
{handlers.can([PORTAL_PERMISSIONS.REPORT_VESSEL_INCIDENT]) && (
<Tooltip label="Report accident / incident">
<Button
size="compact-xs"
variant="subtle"
color="orange"
leftSection={<IconAlertTriangle size={14} />}
onClick={() => handlers.onReportIncident(vessel)}
>
Incident
</Button>
</Tooltip>
)}
<Tooltip label="Report accident / incident">
<Button
size="compact-xs"
variant="subtle"
color="orange"
leftSection={<IconAlertTriangle size={14} />}
onClick={() => handlers.onReportIncident(vessel)}
>
Incident
</Button>
</Tooltip>
</Group>
);
},

View File

@@ -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<Vessel | null>(null);
const table = useServerTable();
const { can } = usePermissions();
const pagedVessels = table.paginate(vessels ?? []);
const inFlight = (applications?.items ?? []).filter(
@@ -186,12 +192,17 @@ export function VesselRegistrationPage() {
<Stack>
<Group justify="space-between">
<Title order={2}>Vessel Registration</Title>
<Button
leftSection={<IconPlus size={16} />}
onClick={() => navigate(`/licensing/${REGISTRATION_TYPE_KEY}/apply`)}
<RequirePermission
anyOf={[PORTAL_PERMISSIONS.APPLY_VESSEL_REGISTRATION]}
hideOnly
>
Register a vessel
</Button>
<Button
leftSection={<IconPlus size={16} />}
onClick={() => navigate(`/licensing/${REGISTRATION_TYPE_KEY}/apply`)}
>
Register a vessel
</Button>
</RequirePermission>
</Group>
{/* ----------------------------------------------------- in-flight */}
@@ -262,20 +273,26 @@ export function VesselRegistrationPage() {
issues the registration certificate and enters the vessel in
the national register.
</Text>
<Button
mt="xs"
onClick={() =>
navigate(`/licensing/${REGISTRATION_TYPE_KEY}/apply`)
}
<RequirePermission
anyOf={[PORTAL_PERMISSIONS.APPLY_VESSEL_REGISTRATION]}
hideOnly
>
Start registration
</Button>
<Button
mt="xs"
onClick={() =>
navigate(`/licensing/${REGISTRATION_TYPE_KEY}/apply`)
}
>
Start registration
</Button>
</RequirePermission>
</Stack>
</Paper>
) : (
<AdvancedTable
tableName="My vessels"
columns={vesselColumns({
can,
licenseById,
onDownloadCertificate: downloadCertificate,
onRenew: renew,

View File

@@ -3,9 +3,12 @@ import { IconFileText } from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { Bilingual, IssuedLicense } from '@ema-platform/api';
import { PORTAL_PERMISSIONS } from '@ema-platform/auth';
export function waiverLetterColumns(deps: {
t: TFunction;
/** 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;
@@ -44,16 +47,17 @@ export function waiverLetterColumns(deps: {
header: '',
label: t('waiver.columns.actions', 'Actions'),
align: 'right',
cell: ({ row }) => (
<Button
size="compact-xs"
variant="light"
leftSection={<IconFileText size={13} />}
onClick={() => deps.onDownload(row.original)}
>
{t('waiver.letter', 'Letter')}
</Button>
),
cell: ({ row }) =>
deps.can([PORTAL_PERMISSIONS.VIEW_WAIVER_LETTER]) ? (
<Button
size="compact-xs"
variant="light"
leftSection={<IconFileText size={13} />}
onClick={() => deps.onDownload(row.original)}
>
{t('waiver.letter', 'Letter')}
</Button>
) : null,
},
];
}

View File

@@ -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.',
)}
</Text>
<Button
rightSection={<IconArrowRight size={16} />}
onClick={() => navigate('/licensing/PRE_WAIVER/apply')}
>
{t('waiver.preWaiver.apply', 'Apply for a pre-waiver')}
</Button>
<RequirePermission anyOf={[PORTAL_PERMISSIONS.APPLY_WAIVER]} hideOnly>
<Button
rightSection={<IconArrowRight size={16} />}
onClick={() => navigate('/licensing/PRE_WAIVER/apply')}
>
{t('waiver.preWaiver.apply', 'Apply for a pre-waiver')}
</Button>
</RequirePermission>
</Card>
<Card withBorder radius="md" p="lg">
@@ -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.',
)}
</Text>
<Button
variant="light"
rightSection={<IconArrowRight size={16} />}
onClick={() => navigate('/licensing/POST_WAIVER/apply')}
>
{t('waiver.postWaiver.apply', 'Apply for a post-waiver')}
</Button>
<RequirePermission anyOf={[PORTAL_PERMISSIONS.APPLY_WAIVER]} hideOnly>
<Button
variant="light"
rightSection={<IconArrowRight size={16} />}
onClick={() => navigate('/licensing/POST_WAIVER/apply')}
>
{t('waiver.postWaiver.apply', 'Apply for a post-waiver')}
</Button>
</RequirePermission>
</Card>
</SimpleGrid>
@@ -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),

View File

@@ -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);

View File

@@ -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: <OperationsOnboardingPage /> },
// Config-driven licensing: one set of pages serves every licence type.
{ path: "/licensing/applications", element: <MyApplicationsPage /> },
{
path: "/licensing/applications",
element: (
<RequirePermission anyOf={[L.VIEW_OWN_APPLICATIONS]}>
<MyApplicationsPage />
</RequirePermission>
),
},
// Telebirr returns the applicant to these.
{ path: "/payments/check", element: <PaymentCheckPage /> },
@@ -117,19 +132,29 @@ export const router = createBrowserRouter([
path: "/licensing/:typeCode/apply",
element: (
<RequireSeafarerProfile>
<LicenseApplicationPage />
<RequirePermission anyOf={[L.CREATE_APPLICATION]}>
<LicenseApplicationPage />
</RequirePermission>
</RequireSeafarerProfile>
),
},
{
path: "/licensing/:typeCode/applications/:applicationId",
element: <LicenseApplicationPage />,
element: (
<RequirePermission anyOf={[L.VIEW_OWN_APPLICATIONS]}>
<LicenseApplicationPage />
</RequirePermission>
),
},
// Notification / email deep links arrive as /applications/<id>; resolve the
// licence type and forward to the canonical route.
{
path: "/applications/:applicationId",
element: <ApplicationRedirectPage />,
element: (
<RequirePermission anyOf={[L.VIEW_OWN_APPLICATIONS]}>
<ApplicationRedirectPage />
</RequirePermission>
),
},
// Seafarer
@@ -137,12 +162,28 @@ export const router = createBrowserRouter([
path: "/seafarer-registration",
element: (
<RequireSeafarerProfile>
<SeafarerRegistrationPage />
<RequirePermission anyOf={[P.APPLY_SEAFARER_REGISTRATION]}>
<SeafarerRegistrationPage />
</RequirePermission>
</RequireSeafarerProfile>
),
},
{ path: "/seafarer/records", element: <MySeaRecordsPage /> },
{ path: "/exams", element: <ExamsPage /> },
{
path: "/seafarer/records",
element: (
<RequirePermission anyOf={[P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL]}>
<MySeaRecordsPage />
</RequirePermission>
),
},
{
path: "/exams",
element: (
<RequirePermission anyOf={[P.VIEW_OWN_EXAM, P.APPLY_EXAM]}>
<ExamsPage />
</RequirePermission>
),
},
// 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: <DocumentVaultPage /> },
{ path: "/seaman-book", element: <SeamanBookPage /> },
{ path: "/seaman-book/apply", element: <SeamanBookApplicationPage /> },
{
path: "/seaman-book",
element: (
<RequirePermission anyOf={[P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL]}>
<SeamanBookPage />
</RequirePermission>
),
},
{
path: "/seaman-book/apply",
element: (
<RequirePermission anyOf={[P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL]}>
<SeamanBookApplicationPage />
</RequirePermission>
),
},
{ path: "/notifications", element: <NotificationsPage /> },
// Phase 2 — CoC / CoP
{ path: "/certificates", element: <CertificatesPage /> },
{ path: "/certificates/apply", element: <CoCApplicationPage /> },
{
path: "/certificates",
element: (
<RequirePermission anyOf={[P.VIEW_OWN_CERTIFICATES]}>
<CertificatesPage />
</RequirePermission>
),
},
{
path: "/certificates/apply",
element: (
<RequirePermission anyOf={[P.VIEW_OWN_CERTIFICATES]}>
<CoCApplicationPage />
</RequirePermission>
),
},
// Phase 3 — Endorsement
{ path: "/endorsements", element: <EndorsementPage /> },
{ path: "/vessel-registration", element: <VesselRegistrationPage /> },
{
path: "/endorsements",
element: (
<RequirePermission anyOf={[P.VIEW_OWN_CERTIFICATES]}>
<EndorsementPage />
</RequirePermission>
),
},
{
path: "/vessel-registration",
element: (
<RequirePermission anyOf={[P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS]}>
<VesselRegistrationPage />
</RequirePermission>
),
},
// 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: <Navigate to="/vessel-registration" replace />,
},
{ path: "/vessel-ownership-transfer", element: <VesselTransferPage /> },
{
path: "/vessel-ownership-transfer",
element: (
<RequirePermission anyOf={[P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS]}>
<VesselTransferPage />
</RequirePermission>
),
},
// 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: <Navigate to="/licensing/applications" replace />,
},
// Waiver has no backend yet, so it says so rather than pretending.
{ path: "/waiver", element: <WaiverPage /> },
{
path: "/waiver",
element: (
<RequirePermission anyOf={[P.APPLY_WAIVER, P.VIEW_WAIVER_LETTER]}>
<WaiverPage />
</RequirePermission>
),
},
{ path: "/waiver/apply", element: <Navigate to="/waiver" replace /> },
// Vessel Registration
{ path: "/vessel-registrations", element: <VesselRegistrationPage /> },
{
path: "/vessel-registrations",
element: (
<RequirePermission anyOf={[P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS]}>
<VesselRegistrationPage />
</RequirePermission>
),
},
// { path: '/vessel-registrations/apply', element: <VesselRegistrationPage /> },
{
path: "/vessel-registrations/:id",
element: <VesselRegistrationStatusPage />,
element: (
<RequirePermission anyOf={[P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS]}>
<VesselRegistrationStatusPage />
</RequirePermission>
),
},
// General

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;