Merge pull request #12 from Tria-plc/Refactor

feat: implement permission-based access control across various pages …
This commit is contained in:
Nati Nigussie
2026-08-13 16:12:03 +03:00
committed by GitHub
46 changed files with 1093 additions and 581 deletions

View File

@@ -47,7 +47,7 @@ import {
} from '@ema-platform/api'; } from '@ema-platform/api';
import { EmptyState, ErrorState, ModalFooter, PageHeader } from '@ema-platform/ui'; import { EmptyState, ErrorState, ModalFooter, PageHeader } from '@ema-platform/ui';
import { authStorage, usePermissions } from '@ema-platform/auth'; 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. */ /** Same resolution the shared RTK Query baseQuery uses. */
const API_BASE_URL = const API_BASE_URL =

View File

@@ -2,6 +2,7 @@ import { Badge, Button, Text } from '@mantine/core';
import { IconUserCheck } from '@tabler/icons-react'; import { IconUserCheck } from '@tabler/icons-react';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui'; import type { AdvancedColumn } from '@ema-platform/ui';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
import type { AttendanceStatus, ExamRegistration } from '../../types/exam'; import type { AttendanceStatus, ExamRegistration } from '../../types/exam';
const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = { const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
@@ -78,14 +79,19 @@ export function examCandidateColumns(
label: t('exam.candidates.record'), label: t('exam.candidates.record'),
align: 'right', align: 'right',
cell: ({ row }) => ( cell: ({ row }) => (
<Button <RequirePermission
size="compact-xs" anyOf={[LICENSE_PERMISSIONS.RECORD_EXAM_ATTENDANCE]}
variant="light" hideOnly
leftSection={<IconUserCheck size={12} />}
onClick={() => handlers.onRecord(row.original)}
> >
{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 { IconAlertTriangle } from '@tabler/icons-react';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui'; import type { AdvancedColumn } from '@ema-platform/ui';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
import type { ExamIncident, ExamIncidentStatus } from '../../types/exam'; import type { ExamIncident, ExamIncidentStatus } from '../../types/exam';
const STATUS_COLOR: Record<ExamIncidentStatus, string> = { const STATUS_COLOR: Record<ExamIncidentStatus, string> = {
@@ -70,14 +71,19 @@ export function examIncidentColumns(
align: 'right', align: 'right',
cell: ({ row }) => cell: ({ row }) =>
row.original.status === 'OPEN' || row.original.status === 'UNDER_REVIEW' ? ( row.original.status === 'OPEN' || row.original.status === 'UNDER_REVIEW' ? (
<Button <RequirePermission
size="compact-xs" anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAM_INCIDENTS]}
variant="light" hideOnly
leftSection={<IconAlertTriangle size={12} />}
onClick={() => handlers.onResolve(row.original)}
> >
{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, ) : null,
}, },
]; ];

View File

@@ -16,6 +16,7 @@ import { IconInfoCircle, IconPlus } from '@tabler/icons-react';
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui'; import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared'; import { useDateDisplayer } from '@ema-platform/shared';
import { extractErrorMessage } from '@ema-platform/api'; import { extractErrorMessage } from '@ema-platform/api';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
import { import {
useGetExamIncidentsQuery, useGetExamIncidentsQuery,
useGetExamRegistrationsQuery, useGetExamRegistrationsQuery,
@@ -122,15 +123,20 @@ export function ExamIncidentsPanel({ examId }: { examId: string }) {
<Paper withBorder radius="lg" p="lg"> <Paper withBorder radius="lg" p="lg">
<Group justify="space-between" mb="md"> <Group justify="space-between" mb="md">
<Title order={5}>{t('exam.incidents.section')}</Title> <Title order={5}>{t('exam.incidents.section')}</Title>
<Button <RequirePermission
size="xs" anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAM_INCIDENTS]}
variant="light" hideOnly
color="orange"
leftSection={<IconPlus size={14} />}
onClick={() => setAddOpen(true)}
> >
{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> </Group>
{(incidents ?? []).length === 0 ? ( {(incidents ?? []).length === 0 ? (

View File

@@ -41,6 +41,7 @@ import {
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { ModalFooter, notify, useErrorHandler } from '@ema-platform/ui'; import { ModalFooter, notify, useErrorHandler } from '@ema-platform/ui';
import { extractErrorMessage } from '@ema-platform/api'; import { extractErrorMessage } from '@ema-platform/api';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
import { import {
useGetExamQuery, useGetExamQuery,
useUpdateExamMutation, useUpdateExamMutation,
@@ -393,14 +394,16 @@ export function ExamDetailPage() {
<Title order={5}> <Title order={5}>
{t("exam.detail.questionsSection", { pts: totalPoints })} {t("exam.detail.questionsSection", { pts: totalPoints })}
</Title> </Title>
<Button <RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
variant="light" <Button
size="xs" variant="light"
leftSection={<IconPlus size={14} />} size="xs"
onClick={openAssignModal} leftSection={<IconPlus size={14} />}
> onClick={openAssignModal}
{t("exam.manageQuestions")} >
</Button> {t("exam.manageQuestions")}
</Button>
</RequirePermission>
</Group> </Group>
{(exam.questions ?? []).length === 0 ? ( {(exam.questions ?? []).length === 0 ? (
<Alert color="gray" icon={<IconInfoCircle size={16} />}> <Alert color="gray" icon={<IconInfoCircle size={16} />}>
@@ -461,9 +464,11 @@ export function ExamDetailPage() {
<Button variant="default" onClick={closeAssign} size="sm"> <Button variant="default" onClick={closeAssign} size="sm">
{t("exam.cancel")} {t("exam.cancel")}
</Button> </Button>
<Button onClick={handleAssign} size="sm" loading={isAssigning}> <RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
{t("exam.saveAssignments")} <Button onClick={handleAssign} size="sm" loading={isAssigning}>
</Button> {t("exam.saveAssignments")}
</Button>
</RequirePermission>
</ModalFooter> </ModalFooter>
</> </>
) : ( ) : (
@@ -478,9 +483,11 @@ export function ExamDetailPage() {
size="xs" size="xs"
style={{ width: 80 }} style={{ width: 80 }}
/> />
<Button size="xs" variant="light" loading={isDrawing} onClick={handleRandomSelect}> <RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
{t('exam.randomSelect')} <Button size="xs" variant="light" loading={isDrawing} onClick={handleRandomSelect}>
</Button> {t('exam.randomSelect')}
</Button>
</RequirePermission>
</Group> </Group>
<QuestionAssigner <QuestionAssigner
available={eligibleQuestions} available={eligibleQuestions}
@@ -492,9 +499,11 @@ export function ExamDetailPage() {
<Button variant="default" onClick={closeAssign} size="sm"> <Button variant="default" onClick={closeAssign} size="sm">
{t("exam.cancel")} {t("exam.cancel")}
</Button> </Button>
<Button onClick={handleAssign} size="sm" loading={isAssigning}> <RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
{t("exam.saveAssignments")} <Button onClick={handleAssign} size="sm" loading={isAssigning}>
</Button> {t("exam.saveAssignments")}
</Button>
</RequirePermission>
</ModalFooter> </ModalFooter>
</> </>
)} )}

View File

@@ -2,6 +2,7 @@ import { ActionIcon, Group } from "@mantine/core";
import { IconEdit, IconTrash, IconDetails } from "@tabler/icons-react"; import { IconEdit, IconTrash, IconDetails } from "@tabler/icons-react";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import type { AdvancedColumn } from "@ema-platform/ui"; import type { AdvancedColumn } from "@ema-platform/ui";
import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
import type { Exam } from "../../types/exam"; import type { Exam } from "../../types/exam";
export function examActionsColumn( export function examActionsColumn(
@@ -17,22 +18,24 @@ export function examActionsColumn(
align: "right", align: "right",
cell: ({ row }) => ( cell: ({ row }) => (
<Group gap="xs"> <Group gap="xs">
<ActionIcon <RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
variant="subtle" <ActionIcon
color="blue" variant="subtle"
size="sm" color="blue"
onClick={() => handlers.onEdit(row.original)} size="sm"
> onClick={() => handlers.onEdit(row.original)}
<IconEdit size={14} /> >
</ActionIcon> <IconEdit size={14} />
<ActionIcon </ActionIcon>
variant="subtle" <ActionIcon
color="red" variant="subtle"
size="sm" color="red"
onClick={() => handlers.onDelete(row.original)} size="sm"
> onClick={() => handlers.onDelete(row.original)}
<IconTrash size={14} /> >
</ActionIcon> <IconTrash size={14} />
</ActionIcon>
</RequirePermission>
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color="red" color="red"

View File

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

View File

@@ -2,6 +2,7 @@ import { Badge, Button, Text, Tooltip } from '@mantine/core';
import { IconShieldCog } from '@tabler/icons-react'; import { IconShieldCog } from '@tabler/icons-react';
import type { AdvancedColumn } from '@ema-platform/ui'; import type { AdvancedColumn } from '@ema-platform/ui';
import type { Bilingual, IssuedLicense } from '@ema-platform/api'; import type { Bilingual, IssuedLicense } from '@ema-platform/api';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
const LICENSE_STATUS_COLORS: Record<string, string> = { const LICENSE_STATUS_COLORS: Record<string, string> = {
ACTIVE: 'green', ACTIVE: 'green',
@@ -84,16 +85,24 @@ export function licenseRegisterColumns(
align: 'right', align: 'right',
cell: ({ row }) => cell: ({ row }) =>
actionsFor(row.original).length > 0 ? ( actionsFor(row.original).length > 0 ? (
<Tooltip label="Suspend / revoke / reinstate"> <RequirePermission
<Button anyOf={[
size="compact-xs" LICENSE_PERMISSIONS.SUSPEND_LICENSE,
variant="subtle" LICENSE_PERMISSIONS.CANCEL_LICENSE,
leftSection={<IconShieldCog size={14} />} ]}
onClick={() => handlers.onStatus(row.original)} hideOnly
> >
Status <Tooltip label="Suspend / revoke / reinstate">
</Button> <Button
</Tooltip> size="compact-xs"
variant="subtle"
leftSection={<IconShieldCog size={14} />}
onClick={() => handlers.onStatus(row.original)}
>
Status
</Button>
</Tooltip>
</RequirePermission>
) : null, ) : null,
}, },
]; ];

View File

@@ -1,5 +1,5 @@
import type { ApplicationDetail, LicenseStatus } from '@ema-platform/api'; 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 * 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 { TFunction } from "i18next";
import type { LicenseApplication } from "@ema-platform/api"; import type { LicenseApplication } from "@ema-platform/api";
import type { AdvancedColumn } from "@ema-platform/ui"; import type { AdvancedColumn } from "@ema-platform/ui";
import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
export function licenseQueueActionsColumn( export function licenseQueueActionsColumn(
t: TFunction, t: TFunction,
@@ -19,13 +20,18 @@ export function licenseQueueActionsColumn(
cell: ({ row }) => cell: ({ row }) =>
row.original.assignedOfficerId === null && row.original.assignedOfficerId === null &&
row.original.status === "SUBMITTED" ? ( row.original.status === "SUBMITTED" ? (
<Button <RequirePermission
size="xs" anyOf={[LICENSE_PERMISSIONS.CLAIM_APPLICATION]}
loading={handlers.claiming} hideOnly
onClick={() => handlers.onClaim(row.original.id)}
> >
{t("queue.claim", "Claim")} <Button
</Button> size="xs"
loading={handlers.claiming}
onClick={() => handlers.onClaim(row.original.id)}
>
{t("queue.claim", "Claim")}
</Button>
</RequirePermission>
) : ( ) : (
<Button <Button
size="xs" size="xs"

View File

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

View File

@@ -3,6 +3,7 @@ import { IconCheck, IconPaperclip, IconX } from '@tabler/icons-react';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui'; import type { AdvancedColumn } from '@ema-platform/ui';
import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api'; import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
export function medicalActionsColumn( export function medicalActionsColumn(
t: TFunction, t: TFunction,
@@ -28,24 +29,29 @@ export function medicalActionsColumn(
> >
{t('recordVerification.evidence', 'Evidence')} {t('recordVerification.evidence', 'Evidence')}
</Button> </Button>
<Button <RequirePermission
size="compact-xs" anyOf={[LICENSE_PERMISSIONS.VERIFY_SEAFARER_RECORDS]}
color="teal" hideOnly
leftSection={<IconCheck size={14} />}
loading={handlers.ruling}
onClick={() => handlers.onVerify(row.original)}
> >
{t('recordVerification.verify', 'Verify')} <Button
</Button> size="compact-xs"
<Button color="teal"
size="compact-xs" leftSection={<IconCheck size={14} />}
color="red" loading={handlers.ruling}
variant="light" onClick={() => handlers.onVerify(row.original)}
leftSection={<IconX size={14} />} >
onClick={() => handlers.onReject(row.original)} {t('recordVerification.verify', 'Verify')}
> </Button>
{t('recordVerification.reject', 'Reject')} <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> </Group>
), ),
}; };
@@ -75,24 +81,29 @@ export function seaServiceActionsColumn(
> >
{t('recordVerification.evidence', 'Evidence')} {t('recordVerification.evidence', 'Evidence')}
</Button> </Button>
<Button <RequirePermission
size="compact-xs" anyOf={[LICENSE_PERMISSIONS.VERIFY_SEAFARER_RECORDS]}
color="teal" hideOnly
leftSection={<IconCheck size={14} />}
loading={handlers.ruling}
onClick={() => handlers.onVerify(row.original)}
> >
{t('recordVerification.verify', 'Verify')} <Button
</Button> size="compact-xs"
<Button color="teal"
size="compact-xs" leftSection={<IconCheck size={14} />}
color="red" loading={handlers.ruling}
variant="light" onClick={() => handlers.onVerify(row.original)}
leftSection={<IconX size={14} />} >
onClick={() => handlers.onReject(row.original)} {t('recordVerification.verify', 'Verify')}
> </Button>
{t('recordVerification.reject', 'Reject')} <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> </Group>
), ),
}; };

View File

@@ -3,6 +3,7 @@ import { IconEdit } from '@tabler/icons-react';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import type { LicenseType } from '@ema-platform/api'; import type { LicenseType } from '@ema-platform/api';
import type { AdvancedColumn } from '@ema-platform/ui'; import type { AdvancedColumn } from '@ema-platform/ui';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
export function paymentConfigActionsColumn( export function paymentConfigActionsColumn(
t: TFunction, t: TFunction,
@@ -13,14 +14,19 @@ export function paymentConfigActionsColumn(
size: 90, size: 90,
align: 'right', align: 'right',
cell: ({ row }) => ( cell: ({ row }) => (
<Button <RequirePermission
size="xs" anyOf={[LICENSE_PERMISSIONS.UPDATE_LICENSE_TYPE]}
variant="light" hideOnly
leftSection={<IconEdit size={14} />}
onClick={() => handlers.onEdit(row.original)}
> >
{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 { IconEdit, IconGavel, IconSend, IconTrash } from '@tabler/icons-react';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui'; import type { AdvancedColumn } from '@ema-platform/ui';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
import type { Question } from '../../types/question'; import type { Question } from '../../types/question';
export function questionActionsColumn( export function questionActionsColumn(
@@ -22,43 +23,49 @@ export function questionActionsColumn(
return ( return (
<Group gap="xs"> <Group gap="xs">
{(q.status === 'DRAFT' || q.status === 'REJECTED') && ( {(q.status === 'DRAFT' || q.status === 'REJECTED') && (
<Button <RequirePermission anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]} hideOnly>
size="compact-xs" <Button
variant="light" size="compact-xs"
leftSection={<IconSend size={12} />} variant="light"
loading={handlers.isSubmittingReview} leftSection={<IconSend size={12} />}
onClick={() => handlers.onSubmitForApproval(q)} loading={handlers.isSubmittingReview}
> onClick={() => handlers.onSubmitForApproval(q)}
{t('question.qc.submit')} >
</Button> {t('question.qc.submit')}
</Button>
</RequirePermission>
)} )}
{q.status === 'PENDING_APPROVAL' && ( {q.status === 'PENDING_APPROVAL' && (
<> <RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_QUESTION]} hideOnly>
<Button size="compact-xs" variant="light" color="teal" onClick={() => handlers.onReview(q, 'APPROVED')}> <Button size="compact-xs" variant="light" color="teal" onClick={() => handlers.onReview(q, 'APPROVED')}>
{t('question.qc.approve')} {t('question.qc.approve')}
</Button> </Button>
<Button size="compact-xs" variant="light" color="red" onClick={() => handlers.onReview(q, 'REJECTED')}> <Button size="compact-xs" variant="light" color="red" onClick={() => handlers.onReview(q, 'REJECTED')}>
{t('question.qc.reject')} {t('question.qc.reject')}
</Button> </Button>
</> </RequirePermission>
)} )}
{q.status === 'APPROVED' && ( {q.status === 'APPROVED' && (
<Button <RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_QUESTION]} hideOnly>
size="compact-xs" <Button
variant="subtle" size="compact-xs"
color="dark" variant="subtle"
leftSection={<IconGavel size={12} />} color="dark"
onClick={() => handlers.onReview(q, 'RETIRED')} leftSection={<IconGavel size={12} />}
> onClick={() => handlers.onReview(q, 'RETIRED')}
{t('question.qc.retire')} >
</Button> {t('question.qc.retire')}
</Button>
</RequirePermission>
)} )}
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => handlers.onEdit(q)}> <RequirePermission anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]} hideOnly>
<IconEdit size={14} /> <ActionIcon variant="subtle" color="blue" size="sm" onClick={() => handlers.onEdit(q)}>
</ActionIcon> <IconEdit size={14} />
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => handlers.onDelete(q)}> </ActionIcon>
<IconTrash size={14} /> <ActionIcon variant="subtle" color="red" size="sm" onClick={() => handlers.onDelete(q)}>
</ActionIcon> <IconTrash size={14} />
</ActionIcon>
</RequirePermission>
</Group> </Group>
); );
}, },

View File

@@ -19,6 +19,7 @@ import { useTranslation } from 'react-i18next';
import { IconPlus, IconInfoCircle } from '@tabler/icons-react'; import { IconPlus, IconInfoCircle } from '@tabler/icons-react';
import { AdvancedColumn, AdvancedTable, ModalFooter, notify, useErrorHandler, useServerTable } from '@ema-platform/ui'; import { AdvancedColumn, AdvancedTable, ModalFooter, notify, useErrorHandler, useServerTable } from '@ema-platform/ui';
import { extractErrorMessage } from '@ema-platform/api'; import { extractErrorMessage } from '@ema-platform/api';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
import { useGetCertificationsQuery } from '../../../certification/api/certification-api'; import { useGetCertificationsQuery } from '../../../certification/api/certification-api';
import { import {
useGetQuestionsQuery, useGetQuestionsQuery,
@@ -219,9 +220,11 @@ export function QuestionPage() {
<Group justify="space-between" align="flex-end"> <Group justify="space-between" align="flex-end">
<Title order={2}>{t('question.title')}</Title> <Title order={2}>{t('question.title')}</Title>
{!showForm && ( {!showForm && (
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm"> <RequirePermission anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]} hideOnly>
{t('question.addQuestion')} <Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
</Button> {t('question.addQuestion')}
</Button>
</RequirePermission>
)} )}
</Group> </Group>

View File

@@ -17,6 +17,7 @@ import {
import { IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react'; import { IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
import { AdvancedTable, ModalFooter, notify, useServerTable } from '@ema-platform/ui'; import { AdvancedTable, ModalFooter, notify, useServerTable } from '@ema-platform/ui';
import { extractErrorMessage } from '@ema-platform/api'; import { extractErrorMessage } from '@ema-platform/api';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
import { recordResultColumns } from './columns'; import { recordResultColumns } from './columns';
import { useCreateResultMutation } from '../../api/result-api'; import { useCreateResultMutation } from '../../api/result-api';
import { useGetExamRegistrationsQuery } from '../../../exam/api/exam-api'; import { useGetExamRegistrationsQuery } from '../../../exam/api/exam-api';
@@ -207,9 +208,11 @@ export function RecordResultModal({
<ModalFooter> <ModalFooter>
<Button variant="default" onClick={onClose} size="sm">{t('result.cancel')}</Button> <Button variant="default" onClick={onClose} size="sm">{t('result.cancel')}</Button>
<Button onClick={handleSave} size="sm" loading={isSaving}> <RequirePermission anyOf={[LICENSE_PERMISSIONS.RECORD_EXAM_RESULT]} hideOnly>
{t('result.saveResult')} <Button onClick={handleSave} size="sm" loading={isSaving}>
</Button> {t('result.saveResult')}
</Button>
</RequirePermission>
</ModalFooter> </ModalFooter>
</> </>
)} )}

View File

@@ -2,6 +2,7 @@ import { Badge, Button, Text } from '@mantine/core';
import { IconGavel } from '@tabler/icons-react'; import { IconGavel } from '@tabler/icons-react';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui'; import type { AdvancedColumn } from '@ema-platform/ui';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
import type { ExamAppeal } from '../../types/result'; import type { ExamAppeal } from '../../types/result';
export function examAppealsColumns( export function examAppealsColumns(
@@ -59,15 +60,17 @@ export function examAppealsColumns(
label: t('result.appeals.decide'), label: t('result.appeals.decide'),
align: 'right', align: 'right',
cell: ({ row }) => ( cell: ({ row }) => (
<Button <RequirePermission anyOf={[LICENSE_PERMISSIONS.DECIDE_EXAM_APPEAL]} hideOnly>
size="compact-xs" <Button
variant="light" size="compact-xs"
color="grape" variant="light"
leftSection={<IconGavel size={12} />} color="grape"
onClick={() => handlers.onDecide(row.original)} leftSection={<IconGavel size={12} />}
> onClick={() => handlers.onDecide(row.original)}
{t('result.appeals.decide')} >
</Button> {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 { IconEye, IconTrash } from '@tabler/icons-react';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui'; import type { AdvancedColumn } from '@ema-platform/ui';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
import type { Result } from '../../types/result'; import type { Result } from '../../types/result';
export type QcAction = 'moderate' | 'approve' | 'return'; export type QcAction = 'moderate' | 'approve' | 'return';
@@ -23,31 +24,45 @@ export function resultActionsColumn(
<Group gap="xs"> <Group gap="xs">
{(r.reviewStatus === 'MARKED' || r.reviewStatus === 'MODERATED') && ( {(r.reviewStatus === 'MARKED' || r.reviewStatus === 'MODERATED') && (
<> <>
<Button size="compact-xs" variant="light" color="yellow" onClick={() => handlers.onQc(r, 'moderate')}> <RequirePermission anyOf={[LICENSE_PERMISSIONS.MODERATE_EXAM_RESULT]} hideOnly>
{t('result.review.moderate')} <Button size="compact-xs" variant="light" color="yellow" onClick={() => handlers.onQc(r, 'moderate')}>
</Button> {t('result.review.moderate')}
<Button size="compact-xs" variant="light" color="blue" onClick={() => handlers.onQc(r, 'approve')}> </Button>
{t('result.review.approve')} </RequirePermission>
</Button> <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') && ( {(r.reviewStatus === 'APPROVED' || r.reviewStatus === 'MODERATED') && (
<Button size="compact-xs" variant="subtle" color="orange" onClick={() => handlers.onQc(r, 'return')}> <RequirePermission
{t('result.review.return')} anyOf={[
</Button> 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)}> <Button size="xs" variant="subtle" leftSection={<IconEye size={13} />} onClick={() => handlers.onViewDetail(r)}>
{t('result.action.viewEdit')} {t('result.action.viewEdit')}
</Button> </Button>
<Button <RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT]} hideOnly>
size="xs" <Button
variant="subtle" size="xs"
color="red" variant="subtle"
leftSection={<IconTrash size={13} />} color="red"
onClick={() => handlers.onDelete(r)} leftSection={<IconTrash size={13} />}
> onClick={() => handlers.onDelete(r)}
{t('result.action.delete')} >
</Button> {t('result.action.delete')}
</Button>
</RequirePermission>
</Group> </Group>
); );
}, },

View File

@@ -38,6 +38,7 @@ import { notify, BilingualInput, useErrorHandler, AdvancedTable, useServerTable,
import { useDateDisplayer } from '@ema-platform/shared'; import { useDateDisplayer } from '@ema-platform/shared';
import type { BilingualValue } from '@ema-platform/ui'; import type { BilingualValue } from '@ema-platform/ui';
import { extractErrorMessage, useLocalized } from '@ema-platform/api'; import { extractErrorMessage, useLocalized } from '@ema-platform/api';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
import { import {
useGetResultsQuery, useGetResultsQuery,
useLazyGetResultQuery, useLazyGetResultQuery,
@@ -300,17 +301,19 @@ export function ResultPage() {
<Text fz="sm" c="dimmed">{t('result.subtitle')}</Text> <Text fz="sm" c="dimmed">{t('result.subtitle')}</Text>
</div> </div>
<Group gap="sm"> <Group gap="sm">
<Button <RequirePermission anyOf={[LICENSE_PERMISSIONS.PUBLISH_EXAM_RESULT]} hideOnly>
variant="light" <Button
color="teal" variant="light"
size="sm" color="teal"
loading={isPublishing} size="sm"
disabled={!examFilter} loading={isPublishing}
leftSection={<IconSend size={15} />} disabled={!examFilter}
onClick={handlePublish} leftSection={<IconSend size={15} />}
> onClick={handlePublish}
{t('result.review.publish')} >
</Button> {t('result.review.publish')}
</Button>
</RequirePermission>
<Button leftSection={<IconPlus size={15} />} onClick={openPicker} size="sm"> <Button leftSection={<IconPlus size={15} />} onClick={openPicker} size="sm">
{t('result.record')} {t('result.record')}
</Button> </Button>

View File

@@ -2,6 +2,7 @@ import { Button, Tooltip } from '@mantine/core';
import { IconShieldCog } from '@tabler/icons-react'; import { IconShieldCog } from '@tabler/icons-react';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui'; import type { AdvancedColumn } from '@ema-platform/ui';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
import type { ProfileRow } from './columns'; import type { ProfileRow } from './columns';
export function seafarerStatusActionColumn( export function seafarerStatusActionColumn(
@@ -13,16 +14,21 @@ export function seafarerStatusActionColumn(
label: t('seafarerRegistry.columns.actions', 'Actions'), label: t('seafarerRegistry.columns.actions', 'Actions'),
cell: ({ row }) => cell: ({ row }) =>
row.original.seafarerNumber ? ( row.original.seafarerNumber ? (
<Tooltip label={t('seafarerRegistry.statusActionTooltip', 'Suspend / reinstate / close')}> <RequirePermission
<Button anyOf={[LICENSE_PERMISSIONS.MANAGE_SEAFARER_STATUS]}
size="compact-xs" hideOnly
variant="subtle" >
leftSection={<IconShieldCog size={14} />} <Tooltip label={t('seafarerRegistry.statusActionTooltip', 'Suspend / reinstate / close')}>
onClick={() => handlers.onStatus(row.original)} <Button
> size="compact-xs"
{t('seafarerRegistry.statusAction', 'Status')} variant="subtle"
</Button> leftSection={<IconShieldCog size={14} />}
</Tooltip> onClick={() => handlers.onStatus(row.original)}
>
{t('seafarerRegistry.statusAction', 'Status')}
</Button>
</Tooltip>
</RequirePermission>
) : null, ) : null,
}; };
} }

View File

@@ -2,6 +2,7 @@ import { Badge, Button, Text, Tooltip } from '@mantine/core';
import { IconShieldCog } from '@tabler/icons-react'; import { IconShieldCog } from '@tabler/icons-react';
import type { AdvancedColumn } from '@ema-platform/ui'; import type { AdvancedColumn } from '@ema-platform/ui';
import type { Vessel } from '@ema-platform/api'; import type { Vessel } from '@ema-platform/api';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
const VESSEL_STATUS_COLORS: Record<string, string> = { const VESSEL_STATUS_COLORS: Record<string, string> = {
REGISTERED: 'green', REGISTERED: 'green',
@@ -76,19 +77,24 @@ export function vesselRegistrationQueueColumns(
label: 'Actions', label: 'Actions',
align: 'right', align: 'right',
cell: ({ row }) => ( cell: ({ row }) => (
<Tooltip label="Suspend / deregister / reinstate"> <RequirePermission
<Button anyOf={[LICENSE_PERMISSIONS.MANAGE_VESSEL_STATUS]}
size="compact-xs" hideOnly
variant="subtle" >
leftSection={<IconShieldCog size={14} />} <Tooltip label="Suspend / deregister / reinstate">
onClick={(e) => { <Button
e.stopPropagation(); size="compact-xs"
handlers.onStatus(row.original); variant="subtle"
}} leftSection={<IconShieldCog size={14} />}
> onClick={(e) => {
Status e.stopPropagation();
</Button> handlers.onStatus(row.original);
</Tooltip> }}
>
Status
</Button>
</Tooltip>
</RequirePermission>
), ),
}, },
]; ];

View File

@@ -26,24 +26,13 @@ import {
IconUserShield, IconUserShield,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import type { NavSection } from '@ema-platform/ui'; 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`. * Every licence-type queue and its review workspace share one gate: the
* * licence application queue permissions, granted as any-of.
* 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.
*/ */
export const PERMISSIONS = { const APPLICATION_QUEUE = [P.VIEW_APPLICATION_QUEUE, P.VIEW_APPLICATIONS];
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;
/** /**
* The backoffice information architecture. * The backoffice information architecture.
@@ -63,80 +52,92 @@ export const NAV_SECTIONS: NavSection[] = [
to: '/licence-review', to: '/licence-review',
label: 'nav.allApplications', label: 'nav.allApplications',
icon: IconListCheck, icon: IconListCheck,
permissions: [PERMISSIONS.VIEW_APPLICATION_QUEUE], permissions: APPLICATION_QUEUE,
}, },
{ {
// A disclosure, not a destination — each child deep-links the grid to // A disclosure, not a destination — each child deep-links the grid to
// one type, which is a facet of the same workspace. // one type, which is a facet of the same workspace.
label: 'nav.byType', label: 'nav.byType',
icon: IconTruck, icon: IconTruck,
permissions: [PERMISSIONS.VIEW_APPLICATIONS], permissions: APPLICATION_QUEUE,
children: [ children: [
{ to: '/licence-review/type/FREIGHT_FORWARDER', label: 'nav.typeFreightForwarder', icon: IconTruck }, { 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 }, { 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 }, { 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 }, { 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 }, { to: '/licence-review/type/MULTIMODAL_TRANSPORT_OPERATOR', label: 'nav.typeMto', icon: IconAnchor, permissions: APPLICATION_QUEUE },
], ],
}, },
{ {
to: '/licence-register', to: '/licence-register',
label: 'nav.licenceRegister', label: 'nav.licenceRegister',
icon: IconListCheck, icon: IconListCheck,
permissions: [PERMISSIONS.VIEW_LICENSES], permissions: [P.VIEW_LICENSES],
}, },
{ {
to: '/certificate-designer', to: '/certificate-designer',
label: 'nav.certificateDesigner', label: 'nav.certificateDesigner',
icon: IconRosetteDiscountCheck, 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/PRE_WAIVER', label: 'nav.preWaiverQueue', icon: IconShieldOff, permissions: APPLICATION_QUEUE },
{ to: '/licence-review/type/POST_WAIVER', label: 'nav.postWaiverQueue', icon: IconShieldOff }, { 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', to: '/logistics-head-dashboard',
label: 'nav.logisticsHeadDashboard', label: 'nav.logisticsHeadDashboard',
icon: IconGauge, icon: IconGauge,
permissions: APPLICATION_QUEUE,
}, },
{ {
to: '/payment-config', to: '/payment-config',
label: 'nav.paymentConfig', label: 'nav.paymentConfig',
icon: IconCreditCard, icon: IconCreditCard,
permissions: [PERMISSIONS.VIEW_PAYMENTS], permissions: [P.VIEW_PAYMENTS],
}, },
], ],
}, },
{ {
label: 'nav.groupSeafarer', label: 'nav.groupSeafarer',
items: [ items: [
{ to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers }, { to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers, permissions: [P.VIEW_SEAFARER_REGISTRY] },
{ to: '/licence-review/type/SEAFARER_REGISTRATION', label: 'nav.seafarerRegistrationQueue', icon: IconId }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { to: '/licence-review/type/ENDORSEMENT_GOC', label: 'nav.endorsementGocQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE },
{ to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart }, { to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart, permissions: [P.VERIFY_SEAFARER_RECORDS] },
], ],
}, },
{ {
label: 'nav.groupVessels', label: 'nav.groupVessels',
items: [ items: [
{ to: '/licence-review/type/VESSEL_REGISTRATION', label: 'nav.vesselRegistrationApplicationQueue', icon: IconAnchor }, { to: '/licence-review/type/VESSEL_REGISTRATION', label: 'nav.vesselRegistrationApplicationQueue', icon: IconAnchor, permissions: APPLICATION_QUEUE },
{ to: '/vessel-registration-queue', label: 'nav.vesselRegistrationQueue', icon: IconAnchor }, { 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 }, { 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 }, { 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 }, { 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 }, { to: '/vessel-registration-head-dashboard', label: 'nav.vesselRegistrationHeadDashboard', icon: IconGauge, soon: true, permissions: [P.VIEW_VESSEL_REGISTRY] },
], ],
}, },
{ {
label: 'nav.groupExaminations', label: 'nav.groupExaminations',
items: [ items: [
{ to: '/questions', label: 'nav.questions', icon: IconQuestionMark }, { to: '/questions', label: 'nav.questions', icon: IconQuestionMark, permissions: [P.APPROVE_QUESTION, P.AUTHOR_QUESTION] },
{ to: '/exams', label: 'nav.exams', icon: IconClipboardList }, {
{ to: '/exam-results', label: 'nav.examResults', icon: IconReport }, to: '/exams',
{ to: '/exam-appeals', label: 'nav.examAppeals', icon: IconGavel }, 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: [ items: [
{ to: '/um/user-management/dashboard', label: 'nav.userManagement', icon: IconUserShield }, { 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', to: '/configuration',
label: 'nav.configuration', label: 'nav.configuration',
icon: IconSettings, icon: IconSettings,
permissions: [PERMISSIONS.VIEW_LICENSE_TYPES], permissions: [P.VIEW_LICENSE_TYPES, P.VIEW_TEMPLATES],
}, },
{ to: '/analytics', label: 'nav.analytics', icon: IconChartBar, soon: true }, { to: '/analytics', label: 'nav.analytics', icon: IconChartBar, soon: true },
], ],

View File

@@ -1,3 +1,4 @@
import type { ReactNode } from 'react';
import { import {
createBrowserRouter, createBrowserRouter,
RouterProvider, RouterProvider,
@@ -8,6 +9,8 @@ import {
ForgotPasswordPage, ForgotPasswordPage,
SetPasswordPage, SetPasswordPage,
OTPVerificationPage, OTPVerificationPage,
RequirePermission,
LICENSE_PERMISSIONS as P,
} from '@ema-platform/auth'; } from '@ema-platform/auth';
import { AuthLayout } from '../layouts/AuthLayout'; import { AuthLayout } from '../layouts/AuthLayout';
import { BackofficeLayout } from '../layouts/BackofficeLayout'; 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 { LogisticsHeadDashboardPage } from '../features/logistics-head/pages/LogisticsHeadDashboardPage';
import { CertificateDesignerPage } from '../features/certificate-designer/pages/CertificateDesignerPage'; 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([ const router = createBrowserRouter([
{ {
element: <AuthLayout />, element: <AuthLayout />,
@@ -60,41 +71,41 @@ const router = createBrowserRouter([
children: [ children: [
{ index: true, element: <Navigate to="/dashboard" replace /> }, { index: true, element: <Navigate to="/dashboard" replace /> },
{ path: 'dashboard', element: <DashboardPage /> }, { path: 'dashboard', element: <DashboardPage /> },
{ path: 'vessel-registration-head-dashboard', element: <VesselRegistrationHeadDashboardPage /> }, { path: 'vessel-registration-head-dashboard', element: guard([P.VIEW_VESSEL_REGISTRY], <VesselRegistrationHeadDashboardPage />) },
{ path: 'logistics-head-dashboard', element: <LogisticsHeadDashboardPage /> }, { path: 'logistics-head-dashboard', element: guard(APPLICATION_QUEUE, <LogisticsHeadDashboardPage />) },
{ path: 'profile', element: <ProfilePage /> }, { 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: '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. // 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', element: <Navigate to="/licence-review/type/CERTIFICATE_OF_COMPETENCY" replace /> },
{ path: 'coc-queue/:id', element: <Navigate to="/licence-review" replace /> }, { path: 'coc-queue/:id', element: <Navigate to="/licence-review" replace /> },
// Endorsement review happens in the config-driven licence queue. // Endorsement review happens in the config-driven licence queue.
{ path: 'endorsement-queue', element: <Navigate to="/licence-review/type/ENDORSEMENT_COC" replace /> }, { path: 'endorsement-queue', element: <Navigate to="/licence-review/type/ENDORSEMENT_COC" replace /> },
{ path: 'endorsement-queue/:id', element: <Navigate to="/licence-review" replace /> }, { path: 'endorsement-queue/:id', element: <Navigate to="/licence-review" replace /> },
{ path: 'medical-verification', element: <MedicalVerificationPage /> }, { path: 'medical-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], <MedicalVerificationPage />) },
{ path: 'payment-config', element: <PaymentConfigPage /> }, { path: 'payment-config', element: guard([P.VIEW_PAYMENTS], <PaymentConfigPage />) },
{ path: 'seafarer-registry', element: <SeafarerRegistryPage /> }, { path: 'seafarer-registry', element: guard([P.VIEW_SEAFARER_REGISTRY], <SeafarerRegistryPage />) },
{ path: 'seaman-book-queue', element: <SeamanBookQueuePage /> }, { path: 'seaman-book-queue', element: guard(APPLICATION_QUEUE, <SeamanBookQueuePage />) },
{ path: 'questions', element: <QuestionPage /> }, { path: 'questions', element: guard([P.APPROVE_QUESTION, P.AUTHOR_QUESTION], <QuestionPage />) },
{ path: 'exams', element: <ExamPage /> }, { path: 'exams', element: guard([P.MANAGE_EXAMS, P.RECORD_EXAM_ATTENDANCE, P.MANAGE_EXAM_INCIDENTS, P.PUBLISH_EXAM_RESULT], <ExamPage />) },
{ path: 'exams/:id', element: <ExamDetailPage /> }, { 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: <ResultPage /> }, { 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: <ExamAppealsPage /> }, { path: 'exam-appeals', element: guard([P.DECIDE_EXAM_APPEAL], <ExamAppealsPage />) },
{ path: 'vessel-registration-queue', element: <VesselRegistrationQueuePage /> }, { path: 'vessel-registration-queue', element: guard([P.VIEW_VESSEL_REGISTRY], <VesselRegistrationQueuePage />) },
{ path: 'vessel-registration-queue/new', element: <VesselRegistrationFormBuilderPage /> }, { path: 'vessel-registration-queue/new', element: guard([P.VIEW_VESSEL_REGISTRY], <VesselRegistrationFormBuilderPage />) },
// { path: 'vessel-registration-queue/:id', element: <VesselRegistrationReviewPage /> }, // { path: 'vessel-registration-queue/:id', element: <VesselRegistrationReviewPage /> },
//{ path: 'vessel-registration-report', element: <VesselRegistrationReportPage /> }, //{ path: 'vessel-registration-report', element: <VesselRegistrationReportPage /> },
{ path: 'vessel-ownership-transfer', element: <Navigate to="/licence-review/type/VESSEL_OWNERSHIP_TRANSFER" replace /> }, { 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 /> }, { path: 'vessel-ownership-transfer/:id', element: <Navigate to="/licence-review" replace /> },
// Config-driven review workspace, shared by every licence type. // Config-driven review workspace, shared by every licence type.
{ path: 'certificate-designer', element: <CertificateDesignerPage /> }, { path: 'certificate-designer', element: guard([P.VIEW_TEMPLATES], <CertificateDesignerPage />) },
{ path: 'licence-review', element: <LicenseQueuePage /> }, { path: 'licence-review', element: guard(APPLICATION_QUEUE, <LicenseQueuePage />) },
{ path: 'licence-register', element: <LicenseRegisterPage /> }, { path: 'licence-register', element: guard([P.VIEW_LICENSES], <LicenseRegisterPage />) },
// Deep link into the grid with the type facet pinned, so "Freight // Deep link into the grid with the type facet pinned, so "Freight
// Forwarder" in the nav is a filtered view rather than a page. // Forwarder" in the nav is a filtered view rather than a page.
{ path: 'licence-review/type/:typeCode', element: <LicenseQueuePage /> }, { path: 'licence-review/type/:typeCode', element: guard(APPLICATION_QUEUE, <LicenseQueuePage />) },
{ path: 'licence-review/:id', element: <LicenseReviewPage /> }, { path: 'licence-review/:id', element: guard(APPLICATION_QUEUE, <LicenseReviewPage />) },
{ path: 'freight-forwarder-license', element: <Navigate to="/licence-review" replace /> }, { path: 'freight-forwarder-license', element: <Navigate to="/licence-review" replace /> },
{ path: 'freight-forwarder-license/:id', 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 /> }, { 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 { IconCertificate } from '@tabler/icons-react';
import type { AdvancedColumn } from '@ema-platform/ui'; import type { AdvancedColumn } from '@ema-platform/ui';
import type { Bilingual, IssuedLicense } from '@ema-platform/api'; import type { Bilingual, IssuedLicense } from '@ema-platform/api';
import { PORTAL_PERMISSIONS } from '@ema-platform/auth';
export function certificateColumns(deps: { export function certificateColumns(deps: {
/** Permission check from usePermissions() — hooks can't run in a cell. */
can: (required?: string[]) => boolean;
localized: (value: Bilingual | undefined) => string; localized: (value: Bilingual | undefined) => string;
showDate: (value: string | null | undefined) => string; showDate: (value: string | null | undefined) => string;
onDownload: (license: IssuedLicense) => void; onDownload: (license: IssuedLicense) => void;
@@ -45,16 +48,17 @@ export function certificateColumns(deps: {
header: '', header: '',
label: 'Actions', label: 'Actions',
align: 'right', align: 'right',
cell: ({ row }) => ( cell: ({ row }) =>
<Button deps.can([PORTAL_PERMISSIONS.VIEW_OWN_CERTIFICATES]) ? (
size="compact-xs" <Button
variant="subtle" size="compact-xs"
leftSection={<IconCertificate size={14} />} variant="subtle"
onClick={() => deps.onDownload(row.original)} leftSection={<IconCertificate size={14} />}
> onClick={() => deps.onDownload(row.original)}
Download >
</Button> Download
), </Button>
) : null,
}, },
]; ];
} }

View File

@@ -30,7 +30,7 @@ import {
useGetMyMedicalCertificatesQuery, useGetMyMedicalCertificatesQuery,
useGetMySeaTimeQuery, useGetMySeaTimeQuery,
} from '@ema-platform/api'; } 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 { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared'; import { useDateDisplayer } from '@ema-platform/shared';
import { certificateColumns } from './columns'; import { certificateColumns } from './columns';
@@ -76,6 +76,7 @@ export function CertificatesPage() {
const showDate = useDateDisplayer(); const showDate = useDateDisplayer();
const localized = useLocalized(); const localized = useLocalized();
const issuedTable = useServerTable(); const issuedTable = useServerTable();
const { can } = usePermissions();
const registered = const registered =
Boolean(profile?.seafarerNumber) && profile?.seafarerStatus === 'ACTIVE'; Boolean(profile?.seafarerNumber) && profile?.seafarerStatus === 'ACTIVE';
@@ -225,6 +226,7 @@ export function CertificatesPage() {
<AdvancedTable <AdvancedTable
tableName="Issued certificates" tableName="Issued certificates"
columns={certificateColumns({ columns={certificateColumns({
can,
localized, localized,
showDate, showDate,
onDownload: (license) => download(license.id), 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 { IconFileText, IconGavel } from '@tabler/icons-react';
import type { AdvancedColumn } from '@ema-platform/ui'; import type { AdvancedColumn } from '@ema-platform/ui';
import type { Bilingual } from '@ema-platform/api'; import type { Bilingual } from '@ema-platform/api';
import { PORTAL_PERMISSIONS } from '@ema-platform/auth';
import type { import type {
AttendanceStatus, AttendanceStatus,
MyAppeal, MyAppeal,
@@ -19,6 +20,8 @@ const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
}; };
export function registrationColumns(deps: { export function registrationColumns(deps: {
/** Permission check from usePermissions() — hooks can't run in a cell. */
can: (required?: string[]) => boolean;
localized: (value: Bilingual | undefined) => string; localized: (value: Bilingual | undefined) => string;
showDate: (value: string | null | undefined) => string; showDate: (value: string | null | undefined) => string;
onDownloadSlip: (registration: MyRegistration) => void; onDownloadSlip: (registration: MyRegistration) => void;
@@ -72,21 +75,24 @@ export function registrationColumns(deps: {
}, },
{ {
header: 'Slip', header: 'Slip',
cell: ({ row }) => ( cell: ({ row }) =>
<Button deps.can([PORTAL_PERMISSIONS.VIEW_OWN_EXAM]) ? (
size="compact-xs" <Button
variant="light" size="compact-xs"
leftSection={<IconFileText size={13} />} variant="light"
onClick={() => deps.onDownloadSlip(row.original)} leftSection={<IconFileText size={13} />}
> onClick={() => deps.onDownloadSlip(row.original)}
Slip >
</Button> Slip
), </Button>
) : null,
}, },
]; ];
} }
export function resultColumns(deps: { export function resultColumns(deps: {
/** Permission check from usePermissions() — hooks can't run in a cell. */
can: (required?: string[]) => boolean;
localized: (value: Bilingual | undefined) => string; localized: (value: Bilingual | undefined) => string;
showDate: (value: string | null | undefined) => string; showDate: (value: string | null | undefined) => string;
appeals: MyAppeal[]; appeals: MyAppeal[];
@@ -128,7 +134,7 @@ export function resultColumns(deps: {
<Badge size="sm" variant="light" color="grape"> <Badge size="sm" variant="light" color="grape">
{appeal.appealNumber} · {appeal.status} {appeal.appealNumber} · {appeal.status}
</Badge> </Badge>
) : ( ) : deps.can([PORTAL_PERMISSIONS.VIEW_OWN_EXAM]) ? (
<Button <Button
size="compact-xs" size="compact-xs"
variant="light" variant="light"
@@ -138,7 +144,7 @@ export function resultColumns(deps: {
> >
Appeal Appeal
</Button> </Button>
); ) : null;
}, },
}, },
]; ];

View File

@@ -21,6 +21,11 @@ import {
openAuthedDocument, openAuthedDocument,
useLocalized, useLocalized,
} from '@ema-platform/api'; } from '@ema-platform/api';
import {
PORTAL_PERMISSIONS,
RequirePermission,
usePermissions,
} from '@ema-platform/auth';
import { registrationColumns, resultColumns } from './columns'; import { registrationColumns, resultColumns } from './columns';
export interface OpenExam { export interface OpenExam {
@@ -107,6 +112,7 @@ export function ExamsPage() {
const [appealTrigger, { isLoading: appealing }] = useApiMutation(); const [appealTrigger, { isLoading: appealing }] = useApiMutation();
const registrationTable = useServerTable(); const registrationTable = useServerTable();
const resultTable = useServerTable(); const resultTable = useServerTable();
const { can } = usePermissions();
const registeredExamIds = new Set((mine ?? []).map((r) => r.exam?.id)); const registeredExamIds = new Set((mine ?? []).map((r) => r.exam?.id));
@@ -211,14 +217,16 @@ export function ExamsPage() {
Registered Registered
</Badge> </Badge>
) : ( ) : (
<Button <RequirePermission anyOf={[PORTAL_PERMISSIONS.APPLY_EXAM]} hideOnly>
size="compact-sm" <Button
loading={registering} size="compact-sm"
leftSection={<IconClipboardList size={14} />} loading={registering}
onClick={() => register(exam)} leftSection={<IconClipboardList size={14} />}
> onClick={() => register(exam)}
Register >
</Button> Register
</Button>
</RequirePermission>
)} )}
</Group> </Group>
</Card> </Card>
@@ -231,6 +239,7 @@ export function ExamsPage() {
<AdvancedTable<MyRegistration> <AdvancedTable<MyRegistration>
tableName="My registrations" tableName="My registrations"
columns={registrationColumns({ columns={registrationColumns({
can,
localized, localized,
showDate, showDate,
onDownloadSlip: downloadSlip, onDownloadSlip: downloadSlip,
@@ -250,6 +259,7 @@ export function ExamsPage() {
<AdvancedTable<MyResult> <AdvancedTable<MyResult>
tableName="My results" tableName="My results"
columns={resultColumns({ columns={resultColumns({
can,
localized, localized,
showDate, showDate,
appeals: appeals ?? [], appeals: appeals ?? [],

View File

@@ -19,6 +19,11 @@ import {
} from '@ema-platform/api'; } from '@ema-platform/api';
import { notify } from '@ema-platform/ui'; import { notify } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared'; 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 * Renewal reuses the ordinary application wizard — a renewal is an
@@ -109,36 +114,46 @@ export function LicenseCard({
{showDate(license.expiryDate)} {showDate(license.expiryDate)}
</Text> </Text>
</Box> </Box>
<Tooltip label="Download certificate"> <RequirePermission
<ActionIcon anyOf={[
variant="light" PORTAL_PERMISSIONS.VIEW_OWN_CERTIFICATES,
radius="md" PORTAL_PERMISSIONS.VIEW_COMPANY_LICENSES,
size="lg" ]}
loading={isDownloading} hideOnly
onClick={onDownload} >
> <Tooltip label="Download certificate">
<IconDownload size={16} /> <ActionIcon
</ActionIcon> variant="light"
</Tooltip> radius="md"
size="lg"
loading={isDownloading}
onClick={onDownload}
>
<IconDownload size={16} />
</ActionIcon>
</Tooltip>
</RequirePermission>
</Group> </Group>
{/* Renewal opens inside the licence type's window and stays open after {/* Renewal opens inside the licence type's window and stays open after
expiry, so a lapsed licence is renewed rather than applied for afresh. */} expiry, so a lapsed licence is renewed rather than applied for afresh. */}
{renewable && ( {renewable && (
<Button <RequirePermission anyOf={[LICENSE_PERMISSIONS.CREATE_APPLICATION]} hideOnly>
fullWidth <Button
mt="sm" fullWidth
size="xs" mt="sm"
variant={expired ? 'filled' : 'light'} size="xs"
color={expired ? 'orange' : undefined} variant={expired ? 'filled' : 'light'}
loading={isRenewing} color={expired ? 'orange' : undefined}
leftSection={<IconRefresh size={14} />} loading={isRenewing}
onClick={onRenew} leftSection={<IconRefresh size={14} />}
> onClick={onRenew}
{expired >
? 'Renew — this licence has expired' {expired
: `Renew — expires in ${days} day${days === 1 ? '' : 's'}`} ? 'Renew — this licence has expired'
</Button> : `Renew — expires in ${days} day${days === 1 ? '' : 's'}`}
</Button>
</RequirePermission>
)} )}
</Card> </Card>
); );

View File

@@ -29,6 +29,7 @@ import {
useGetMyOperatorTypesQuery, useGetMyOperatorTypesQuery,
} from '@ema-platform/api'; } from '@ema-platform/api';
import type { LicenseCategory, LicenseType } 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. * The licence catalogue an applicant chooses from, grouped by category.
@@ -308,16 +309,18 @@ function LicenseTypeCard({
</Tooltip> </Tooltip>
)} )}
</Group> </Group>
<Button <RequirePermission anyOf={[LICENSE_PERMISSIONS.CREATE_APPLICATION]} hideOnly>
fullWidth <Button
mt="sm" fullWidth
size="xs" mt="sm"
variant="light" size="xs"
color={canApply ? undefined : 'gray'} variant="light"
rightSection={<IconArrowRight size={14} />} color={canApply ? undefined : 'gray'}
> rightSection={<IconArrowRight size={14} />}
{canApply ? 'Start application' : 'Add to my operations'} >
</Button> {canApply ? 'Start application' : 'Add to my operations'}
</Button>
</RequirePermission>
</Box> </Box>
</Stack> </Stack>
</Card> </Card>

View File

@@ -54,7 +54,12 @@ import {
type Vessel, type Vessel,
} from '@ema-platform/api'; } from '@ema-platform/api';
import { getCountryCode, getCountryName, ModalFooter } from '@ema-platform/ui'; 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 { ConfigDrivenSection, fillFromVessel } from '../components/ConfigDrivenSection';
import { DocumentSlots } from '../components/DocumentSlots'; import { DocumentSlots } from '../components/DocumentSlots';
import { StaffEvidence } from '../components/StaffEvidence'; import { StaffEvidence } from '../components/StaffEvidence';
@@ -675,14 +680,23 @@ export function LicenseApplicationPage() {
{active < steps.length - 1 ? ( {active < steps.length - 1 ? (
<Button onClick={handleContinue}>Continue</Button> <Button onClick={handleContinue}>Continue</Button>
) : ( ) : (
<Button <RequirePermission
color="teal" anyOf={
loading={submitting || resubmitting} isAdjusting
disabled={readOnly} ? [PORTAL_PERMISSIONS.RESUBMIT_APPLICATION]
onClick={handleSubmit} : [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> </Group>
</Paper> </Paper>

View File

@@ -3,10 +3,13 @@ import { IconDownload } from '@tabler/icons-react';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui'; import type { AdvancedColumn } from '@ema-platform/ui';
import type { LicenseApplication } from '@ema-platform/api'; import type { LicenseApplication } from '@ema-platform/api';
import { PORTAL_PERMISSIONS } from '@ema-platform/auth';
export function applicationActionsColumn( export function applicationActionsColumn(
t: TFunction, t: TFunction,
deps: { deps: {
/** Permission check from usePermissions() — hooks can't run in a cell. */
can: (required?: string[]) => boolean;
bypassEnabled: boolean; bypassEnabled: boolean;
bypassing: boolean; bypassing: boolean;
isPaying: boolean; isPaying: boolean;
@@ -39,7 +42,11 @@ export function applicationActionsColumn(
used to be "View", which opened the application wizard — so used to be "View", which opened the application wizard — so
the one thing the applicant came back for was the one thing the one thing the applicant came back for was the one thing
the button did not do. */} 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 <Button
size="xs" size="xs"
leftSection={<IconDownload size={14} />} leftSection={<IconDownload size={14} />}
@@ -48,6 +55,10 @@ export function applicationActionsColumn(
{t('applications.actions.certificate')} {t('applications.actions.certificate')}
</Button> </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 <Button
size="xs" size="xs"
loading={deps.isPaying && app.status === 'PAYMENT_PENDING'} loading={deps.isPaying && app.status === 'PAYMENT_PENDING'}
@@ -80,6 +91,7 @@ export function applicationActionsColumn(
? t('applications.actions.certificate') ? t('applications.actions.certificate')
: t('applications.actions.view')} : t('applications.actions.view')}
</Button> </Button>
)}
</Group> </Group>
); );
}, },

View File

@@ -48,6 +48,11 @@ import {
useGetPaymentCapabilitiesQuery, useGetPaymentCapabilitiesQuery,
type LicenseStatus, type LicenseStatus,
} from '@ema-platform/api'; } from '@ema-platform/api';
import {
LICENSE_PERMISSIONS,
RequirePermission,
usePermissions,
} from '@ema-platform/auth';
import { applicationColumns } from './columns'; import { applicationColumns } from './columns';
import { applicationActionsColumn } from './actions'; import { applicationActionsColumn } from './actions';
import classes from '../MyApplicationsPage.module.css'; import classes from '../MyApplicationsPage.module.css';
@@ -96,6 +101,7 @@ export function MyApplicationsPage() {
const [getCertificateUrl] = useGetCertificateUrlMutation(); const [getCertificateUrl] = useGetCertificateUrlMutation();
const { renewLicense, isRenewing } = useRenewLicense(); const { renewLicense, isRenewing } = useRenewLicense();
const [isDownloadingCert, setIsDownloadingCert] = useState(false); const [isDownloadingCert, setIsDownloadingCert] = useState(false);
const { can } = usePermissions();
const [tab, setTab] = useState<Tab>(() => const [tab, setTab] = useState<Tab>(() =>
typeof window !== 'undefined' ? tabFromHash(window.location.hash) : 'applications', typeof window !== 'undefined' ? tabFromHash(window.location.hash) : 'applications',
@@ -237,6 +243,7 @@ export function MyApplicationsPage() {
const columns = [ const columns = [
...applicationColumns(t, { language: i18n.language, showDate, statusLabel }), ...applicationColumns(t, { language: i18n.language, showDate, statusLabel }),
applicationActionsColumn(t, { applicationActionsColumn(t, {
can,
bypassEnabled: capabilities?.bypassEnabled ?? false, bypassEnabled: capabilities?.bypassEnabled ?? false,
bypassing, bypassing,
isPaying, isPaying,
@@ -258,9 +265,11 @@ export function MyApplicationsPage() {
{t('applications.subtitle')} {t('applications.subtitle')}
</Text> </Text>
</Box> </Box>
<Button leftSection={<IconPlus size={16} />} onClick={() => changeTab('apply')}> <RequirePermission anyOf={[LICENSE_PERMISSIONS.CREATE_APPLICATION]} hideOnly>
{t('applications.newApplication')} <Button leftSection={<IconPlus size={16} />} onClick={() => changeTab('apply')}>
</Button> {t('applications.newApplication')}
</Button>
</RequirePermission>
</Group> </Group>
<SimpleGrid cols={{ base: 2, md: 4 }} spacing="md"> <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 { IconEdit, IconPaperclip, IconTrash } from '@tabler/icons-react';
import type { AdvancedColumn } from '@ema-platform/ui'; import type { AdvancedColumn } from '@ema-platform/ui';
import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api'; import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api';
import { PORTAL_PERMISSIONS } from '@ema-platform/auth';
export function seaServiceActionsColumn(handlers: { export function seaServiceActionsColumn(handlers: {
/** Permission check from usePermissions() — hooks can't run in a cell. */
can: (required?: string[]) => boolean;
onEvidence: (record: SeaServiceRecord) => void; onEvidence: (record: SeaServiceRecord) => void;
onEdit: (record: SeaServiceRecord) => void; onEdit: (record: SeaServiceRecord) => void;
onDelete: (record: SeaServiceRecord) => void; onDelete: (record: SeaServiceRecord) => void;
@@ -25,25 +28,29 @@ export function seaServiceActionsColumn(handlers: {
<IconPaperclip size={16} /> <IconPaperclip size={16} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip label={locked ? 'Verified records are frozen' : 'Edit'}> {handlers.can([PORTAL_PERMISSIONS.EDIT_SEA_SERVICE]) && (
<ActionIcon <>
variant="subtle" <Tooltip label={locked ? 'Verified records are frozen' : 'Edit'}>
disabled={locked} <ActionIcon
onClick={() => handlers.onEdit(record)} variant="subtle"
> disabled={locked}
<IconEdit size={16} /> onClick={() => handlers.onEdit(record)}
</ActionIcon> >
</Tooltip> <IconEdit size={16} />
<Tooltip label={locked ? 'Verified records are frozen' : 'Delete'}> </ActionIcon>
<ActionIcon </Tooltip>
variant="subtle" <Tooltip label={locked ? 'Verified records are frozen' : 'Delete'}>
color="red" <ActionIcon
disabled={locked} variant="subtle"
onClick={() => handlers.onDelete(record)} color="red"
> disabled={locked}
<IconTrash size={16} /> onClick={() => handlers.onDelete(record)}
</ActionIcon> >
</Tooltip> <IconTrash size={16} />
</ActionIcon>
</Tooltip>
</>
)}
</Group> </Group>
); );
}, },
@@ -51,6 +58,8 @@ export function seaServiceActionsColumn(handlers: {
} }
export function medicalActionsColumn(handlers: { export function medicalActionsColumn(handlers: {
/** Permission check from usePermissions() — hooks can't run in a cell. */
can: (required?: string[]) => boolean;
onEvidence: (certificate: MedicalCertificate) => void; onEvidence: (certificate: MedicalCertificate) => void;
onEdit: (certificate: MedicalCertificate) => void; onEdit: (certificate: MedicalCertificate) => void;
onDelete: (certificate: MedicalCertificate) => void; onDelete: (certificate: MedicalCertificate) => void;
@@ -72,25 +81,29 @@ export function medicalActionsColumn(handlers: {
<IconPaperclip size={16} /> <IconPaperclip size={16} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip label={locked ? 'Verified certificates are frozen' : 'Edit'}> {handlers.can([PORTAL_PERMISSIONS.UPLOAD_MEDICAL]) && (
<ActionIcon <>
variant="subtle" <Tooltip label={locked ? 'Verified certificates are frozen' : 'Edit'}>
disabled={locked} <ActionIcon
onClick={() => handlers.onEdit(certificate)} variant="subtle"
> disabled={locked}
<IconEdit size={16} /> onClick={() => handlers.onEdit(certificate)}
</ActionIcon> >
</Tooltip> <IconEdit size={16} />
<Tooltip label={locked ? 'Verified certificates are frozen' : 'Delete'}> </ActionIcon>
<ActionIcon </Tooltip>
variant="subtle" <Tooltip label={locked ? 'Verified certificates are frozen' : 'Delete'}>
color="red" <ActionIcon
disabled={locked} variant="subtle"
onClick={() => handlers.onDelete(certificate)} color="red"
> disabled={locked}
<IconTrash size={16} /> onClick={() => handlers.onDelete(certificate)}
</ActionIcon> >
</Tooltip> <IconTrash size={16} />
</ActionIcon>
</Tooltip>
</>
)}
</Group> </Group>
); );
}, },

View File

@@ -44,6 +44,11 @@ import {
useUpdateSeaServiceRecordMutation, useUpdateSeaServiceRecordMutation,
} from '@ema-platform/api'; } from '@ema-platform/api';
import type { MedicalCertificate, SeaServiceRecord } 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 { seaServiceColumns, medicalColumns, FITNESS_OPTIONS } from './columns';
import { seaServiceActionsColumn, medicalActionsColumn } from './actions'; import { seaServiceActionsColumn, medicalActionsColumn } from './actions';
@@ -112,18 +117,20 @@ function EvidenceModal({
</Group> </Group>
)) ))
)} )}
<FileButton onChange={upload} accept="image/*,application/pdf"> <RequirePermission anyOf={[PORTAL_PERMISSIONS.UPLOAD_DOCUMENTS]} hideOnly>
{(props) => ( <FileButton onChange={upload} accept="image/*,application/pdf">
<Button {(props) => (
{...props} <Button
variant="light" {...props}
loading={uploading} variant="light"
leftSection={<IconFileUpload size={16} />} loading={uploading}
> leftSection={<IconFileUpload size={16} />}
Upload evidence >
</Button> Upload evidence
)} </Button>
</FileButton> )}
</FileButton>
</RequirePermission>
</Stack> </Stack>
</Modal> </Modal>
); );
@@ -144,6 +151,7 @@ const EMPTY_SEA_SERVICE = {
function SeaServiceTab() { function SeaServiceTab() {
const showDate = useDateDisplayer(); const showDate = useDateDisplayer();
const { can } = usePermissions();
const { data: records, isLoading, refetch } = useGetMySeaServiceRecordsQuery(); const { data: records, isLoading, refetch } = useGetMySeaServiceRecordsQuery();
const { data: seaTime } = useGetMySeaTimeQuery(); const { data: seaTime } = useGetMySeaTimeQuery();
const [createRecord, { isLoading: creating }] = const [createRecord, { isLoading: creating }] =
@@ -231,6 +239,7 @@ function SeaServiceTab() {
const columns = [ const columns = [
...seaServiceColumns(showDate), ...seaServiceColumns(showDate),
seaServiceActionsColumn({ seaServiceActionsColumn({
can,
onEvidence: (record) => setEvidenceFor(record.id), onEvidence: (record) => setEvidenceFor(record.id),
onEdit: openEdit, onEdit: openEdit,
onDelete: remove, onDelete: remove,
@@ -251,9 +260,11 @@ function SeaServiceTab() {
</Badge> </Badge>
)} )}
</Group> </Group>
<Button leftSection={<IconPlus size={16} />} onClick={openCreate}> <RequirePermission anyOf={[PORTAL_PERMISSIONS.ADD_SEA_SERVICE]} hideOnly>
Add sea service <Button leftSection={<IconPlus size={16} />} onClick={openCreate}>
</Button> Add sea service
</Button>
</RequirePermission>
</Group> </Group>
{(records ?? []).length === 0 ? ( {(records ?? []).length === 0 ? (
<Paper withBorder p="xl" radius="md"> <Paper withBorder p="xl" radius="md">
@@ -387,6 +398,7 @@ const EMPTY_MEDICAL = {
function MedicalTab() { function MedicalTab() {
const showDate = useDateDisplayer(); const showDate = useDateDisplayer();
const { can } = usePermissions();
const { data: certificates, isLoading, refetch } = useGetMyMedicalCertificatesQuery(); const { data: certificates, isLoading, refetch } = useGetMyMedicalCertificatesQuery();
const [createCertificate, { isLoading: creating }] = const [createCertificate, { isLoading: creating }] =
useCreateMedicalCertificateMutation(); useCreateMedicalCertificateMutation();
@@ -466,6 +478,7 @@ function MedicalTab() {
const columns = [ const columns = [
...medicalColumns(showDate), ...medicalColumns(showDate),
medicalActionsColumn({ medicalActionsColumn({
can,
onEvidence: (certificate) => setEvidenceFor(certificate.id), onEvidence: (certificate) => setEvidenceFor(certificate.id),
onEdit: openEdit, onEdit: openEdit,
onDelete: remove, onDelete: remove,
@@ -479,9 +492,11 @@ function MedicalTab() {
STCW medical fitness certificates. An expired certificate blocks new STCW medical fitness certificates. An expired certificate blocks new
applications that require one. applications that require one.
</Text> </Text>
<Button leftSection={<IconPlus size={16} />} onClick={openCreate}> <RequirePermission anyOf={[PORTAL_PERMISSIONS.UPLOAD_MEDICAL]} hideOnly>
Add certificate <Button leftSection={<IconPlus size={16} />} onClick={openCreate}>
</Button> Add certificate
</Button>
</RequirePermission>
</Group> </Group>
{(certificates ?? []).length === 0 ? ( {(certificates ?? []).length === 0 ? (
<Paper withBorder p="xl" radius="md"> <Paper withBorder p="xl" radius="md">

View File

@@ -28,7 +28,11 @@ import {
TERMINAL_STATUSES, TERMINAL_STATUSES,
useGetMyApplicationsQuery, useGetMyApplicationsQuery,
} from '@ema-platform/api'; } 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'; const REGISTRATION_TYPE_KEY = 'SEAFARER_REGISTRATION';
@@ -244,13 +248,18 @@ export function SeafarerRegistrationPage() {
</List.Item> </List.Item>
</List> </List>
<Group mt="md"> <Group mt="md">
<Button <RequirePermission
size="md" anyOf={[PORTAL_PERMISSIONS.APPLY_SEAFARER_REGISTRATION]}
rightSection={<IconArrowRight size={18} />} hideOnly
onClick={() => navigate(`/licensing/${REGISTRATION_TYPE_KEY}/apply`)}
> >
Start registration <Button
</Button> size="md"
rightSection={<IconArrowRight size={18} />}
onClick={() => navigate(`/licensing/${REGISTRATION_TYPE_KEY}/apply`)}
>
Start registration
</Button>
</RequirePermission>
</Group> </Group>
</Stack> </Stack>
</Card> </Card>

View File

@@ -6,6 +6,7 @@ import {
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import type { AdvancedColumn } from '@ema-platform/ui'; import type { AdvancedColumn } from '@ema-platform/ui';
import type { IssuedLicense, Vessel } from '@ema-platform/api'; import type { IssuedLicense, Vessel } from '@ema-platform/api';
import { PORTAL_PERMISSIONS } from '@ema-platform/auth';
const CATEGORY_LABELS: Record<string, string> = { const CATEGORY_LABELS: Record<string, string> = {
INLAND_WATERWAY: 'Inland Waterway', INLAND_WATERWAY: 'Inland Waterway',
@@ -19,6 +20,8 @@ const VESSEL_STATUS_COLORS: Record<string, string> = {
}; };
export function vesselColumns(handlers: { export function vesselColumns(handlers: {
/** Permission check from usePermissions() — hooks can't run in a cell. */
can: (required?: string[]) => boolean;
licenseById: Map<string, IssuedLicense>; licenseById: Map<string, IssuedLicense>;
onDownloadCertificate: (vessel: Vessel) => void; onDownloadCertificate: (vessel: Vessel) => void;
onRenew: (vessel: Vessel) => void; onRenew: (vessel: Vessel) => void;
@@ -102,39 +105,45 @@ export function vesselColumns(handlers: {
handlers.licenseById.get(vessel.licenseId)?.renewable ?? false; handlers.licenseById.get(vessel.licenseId)?.renewable ?? false;
return ( return (
<Group gap="xs" justify="flex-end" wrap="nowrap"> <Group gap="xs" justify="flex-end" wrap="nowrap">
<Tooltip label="Download certificate"> {handlers.can([PORTAL_PERMISSIONS.VIEW_OWN_CERTIFICATES]) && (
<Button <Tooltip label="Download certificate">
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">
<Button <Button
size="compact-xs" size="compact-xs"
variant="light" variant="subtle"
leftSection={<IconRefresh size={14} />} leftSection={<IconCertificate size={14} />}
onClick={() => handlers.onRenew(vessel)} 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> </Button>
</Tooltip> </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> </Group>
); );
}, },

View File

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

View File

@@ -3,9 +3,12 @@ import { IconFileText } from '@tabler/icons-react';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui'; import type { AdvancedColumn } from '@ema-platform/ui';
import type { Bilingual, IssuedLicense } from '@ema-platform/api'; import type { Bilingual, IssuedLicense } from '@ema-platform/api';
import { PORTAL_PERMISSIONS } from '@ema-platform/auth';
export function waiverLetterColumns(deps: { export function waiverLetterColumns(deps: {
t: TFunction; t: TFunction;
/** Permission check from usePermissions() — hooks can't run in a cell. */
can: (required?: string[]) => boolean;
localized: (value: Bilingual | undefined) => string; localized: (value: Bilingual | undefined) => string;
showDate: (value: string | null | undefined) => string; showDate: (value: string | null | undefined) => string;
onDownload: (license: IssuedLicense) => void; onDownload: (license: IssuedLicense) => void;
@@ -44,16 +47,17 @@ export function waiverLetterColumns(deps: {
header: '', header: '',
label: t('waiver.columns.actions', 'Actions'), label: t('waiver.columns.actions', 'Actions'),
align: 'right', align: 'right',
cell: ({ row }) => ( cell: ({ row }) =>
<Button deps.can([PORTAL_PERMISSIONS.VIEW_WAIVER_LETTER]) ? (
size="compact-xs" <Button
variant="light" size="compact-xs"
leftSection={<IconFileText size={13} />} variant="light"
onClick={() => deps.onDownload(row.original)} leftSection={<IconFileText size={13} />}
> onClick={() => deps.onDownload(row.original)}
{t('waiver.letter', 'Letter')} >
</Button> {t('waiver.letter', 'Letter')}
), </Button>
) : null,
}, },
]; ];
} }

View File

@@ -25,6 +25,11 @@ import {
} from '@ema-platform/api'; } from '@ema-platform/api';
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui'; import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared'; import { useDateDisplayer } from '@ema-platform/shared';
import {
PORTAL_PERMISSIONS,
RequirePermission,
usePermissions,
} from '@ema-platform/auth';
import { waiverLetterColumns } from './columns'; import { waiverLetterColumns } from './columns';
const WAIVER_TYPE_KEYS = ['PRE_WAIVER', 'POST_WAIVER']; const WAIVER_TYPE_KEYS = ['PRE_WAIVER', 'POST_WAIVER'];
@@ -46,6 +51,7 @@ export function WaiverPage() {
const showDate = useDateDisplayer(); const showDate = useDateDisplayer();
const localized = useLocalized(); const localized = useLocalized();
const lettersTable = useServerTable(); const lettersTable = useServerTable();
const { can } = usePermissions();
const waiverApplications = (applications?.items ?? []).filter((app) => const waiverApplications = (applications?.items ?? []).filter((app) =>
WAIVER_TYPE_KEYS.includes(app.licenseType?.key ?? ''), 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.', 'The cargo has not yet arrived. Applying before arrival avoids the post-waiver penalty.',
)} )}
</Text> </Text>
<Button <RequirePermission anyOf={[PORTAL_PERMISSIONS.APPLY_WAIVER]} hideOnly>
rightSection={<IconArrowRight size={16} />} <Button
onClick={() => navigate('/licensing/PRE_WAIVER/apply')} rightSection={<IconArrowRight size={16} />}
> onClick={() => navigate('/licensing/PRE_WAIVER/apply')}
{t('waiver.preWaiver.apply', 'Apply for a pre-waiver')} >
</Button> {t('waiver.preWaiver.apply', 'Apply for a pre-waiver')}
</Button>
</RequirePermission>
</Card> </Card>
<Card withBorder radius="md" p="lg"> <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.', 'The cargo has already arrived. Granted once per shipment, and only against a settled penalty with the receipt attached.',
)} )}
</Text> </Text>
<Button <RequirePermission anyOf={[PORTAL_PERMISSIONS.APPLY_WAIVER]} hideOnly>
variant="light" <Button
rightSection={<IconArrowRight size={16} />} variant="light"
onClick={() => navigate('/licensing/POST_WAIVER/apply')} rightSection={<IconArrowRight size={16} />}
> onClick={() => navigate('/licensing/POST_WAIVER/apply')}
{t('waiver.postWaiver.apply', 'Apply for a post-waiver')} >
</Button> {t('waiver.postWaiver.apply', 'Apply for a post-waiver')}
</Button>
</RequirePermission>
</Card> </Card>
</SimpleGrid> </SimpleGrid>
@@ -172,6 +182,7 @@ export function WaiverPage() {
tableName={t('waiver.issuedLetters', 'Issued waiver letters')} tableName={t('waiver.issuedLetters', 'Issued waiver letters')}
columns={waiverLetterColumns({ columns={waiverLetterColumns({
t, t,
can,
localized, localized,
showDate, showDate,
onDownload: (license) => download(license.id), onDownload: (license) => download(license.id),

View File

@@ -19,9 +19,15 @@ import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useDispatch } from "react-redux"; 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 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 { baseApi, useGetUnseenNotificationsQuery } from "@ema-platform/api";
import { SUPPORTED_LANGUAGES } from "../i18n/config"; import { SUPPORTED_LANGUAGES } from "../i18n/config";
import { useAppSelector } from "../store/hooks"; import { useAppSelector } from "../store/hooks";
@@ -30,6 +36,11 @@ const BADGE_POLL_MS = 60_000;
type PortalNavItem = NavItem & { i18nKey: string }; 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 * Grouped for scannability, with `soon` marking screens that have no backend
* behind them yet. There are no per-licence-type entries: applicants pick a * 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", label: "nav.groupLicensing",
items: [ items: [
{ to: '/licensing/applications', label: 'My Applications', i18nKey: 'nav.myApplications', icon: IconTruck }, { 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 }, { to: '/waiver', label: 'Waiver', i18nKey: 'nav.waiver', icon: IconShieldOff, permissions: [P.APPLY_WAIVER, P.VIEW_WAIVER_LETTER] },
], ],
}, },
{ {
label: "nav.groupSeafarer", label: "nav.groupSeafarer",
items: [ items: [
{ to: '/seafarer-registration', label: 'Seafarer Registration', i18nKey: 'nav.seafarerRegistration', icon: IconList }, { 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 }, { 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 }, { 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 }, { to: '/certificates', label: 'Certificates', i18nKey: 'nav.certificates', icon: IconShieldCheck, permissions: [P.VIEW_OWN_CERTIFICATES] },
{ to: '/exams', label: 'Examinations', i18nKey: 'nav.exams', icon: IconList }, { 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 }, { to: '/endorsements', label: 'Endorsements', i18nKey: 'nav.endorsements', icon: IconRubberStamp, permissions: [P.VIEW_OWN_CERTIFICATES] },
], ],
}, },
{ {
label: "nav.groupVessels", label: "nav.groupVessels",
items: [ items: [
{ to: '/vessel-registration', label: 'Vessel Registration', i18nKey: 'nav.vesselRegistration', icon: IconShip }, { 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 }, { 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, pollingInterval: BADGE_POLL_MS,
refetchOnMountOrArgChange: false, refetchOnMountOrArgChange: false,
}); });
const { permissions: granted, known } = usePermissions();
const sections = useMemo( const sections = useMemo(() => {
() => const translated = NAV_SECTIONS.map((section) => ({
NAV_SECTIONS.map((section) => ({ label: section.label,
label: section.label, items: section.items.map(({ i18nKey, ...rest }) => ({
items: section.items.map(({ i18nKey, ...rest }) => ({ ...rest,
...rest, label: t(i18nKey),
label: t(i18nKey), badge:
badge: rest.to === "/notifications" && unseen?.count ? unseen.count : undefined,
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 // Breadcrumb trail
const segments = location.pathname.split("/").filter(Boolean); const segments = location.pathname.split("/").filter(Boolean);

View File

@@ -11,8 +11,16 @@ import {
OTPVerificationPage, OTPVerificationPage,
ForgotPasswordPage, ForgotPasswordPage,
SetPasswordPage, SetPasswordPage,
RequirePermission,
LICENSE_PERMISSIONS,
PORTAL_PERMISSIONS,
} from "@ema-platform/auth"; } 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 // Portal feature pages
import { DashboardPage } from "./features/dashboard/pages/DashboardPage"; import { DashboardPage } from "./features/dashboard/pages/DashboardPage";
import { RequireOperations } from "./features/onboarding/components/RequireOperations"; import { RequireOperations } from "./features/onboarding/components/RequireOperations";
@@ -107,7 +115,14 @@ export const router = createBrowserRouter([
{ path: "/onboarding/operations", element: <OperationsOnboardingPage /> }, { path: "/onboarding/operations", element: <OperationsOnboardingPage /> },
// Config-driven licensing: one set of pages serves every licence type. // 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. // Telebirr returns the applicant to these.
{ path: "/payments/check", element: <PaymentCheckPage /> }, { path: "/payments/check", element: <PaymentCheckPage /> },
@@ -117,19 +132,29 @@ export const router = createBrowserRouter([
path: "/licensing/:typeCode/apply", path: "/licensing/:typeCode/apply",
element: ( element: (
<RequireSeafarerProfile> <RequireSeafarerProfile>
<LicenseApplicationPage /> <RequirePermission anyOf={[L.CREATE_APPLICATION]}>
<LicenseApplicationPage />
</RequirePermission>
</RequireSeafarerProfile> </RequireSeafarerProfile>
), ),
}, },
{ {
path: "/licensing/:typeCode/applications/:applicationId", 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 // Notification / email deep links arrive as /applications/<id>; resolve the
// licence type and forward to the canonical route. // licence type and forward to the canonical route.
{ {
path: "/applications/:applicationId", path: "/applications/:applicationId",
element: <ApplicationRedirectPage />, element: (
<RequirePermission anyOf={[L.VIEW_OWN_APPLICATIONS]}>
<ApplicationRedirectPage />
</RequirePermission>
),
}, },
// Seafarer // Seafarer
@@ -137,12 +162,28 @@ export const router = createBrowserRouter([
path: "/seafarer-registration", path: "/seafarer-registration",
element: ( element: (
<RequireSeafarerProfile> <RequireSeafarerProfile>
<SeafarerRegistrationPage /> <RequirePermission anyOf={[P.APPLY_SEAFARER_REGISTRATION]}>
<SeafarerRegistrationPage />
</RequirePermission>
</RequireSeafarerProfile> </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 public-facing registry was a hardcoded mock and does not belong in
// the applicant portal; officers browse seafarers in the backoffice. // the applicant portal; officers browse seafarers in the backoffice.
{ {
@@ -156,17 +197,59 @@ export const router = createBrowserRouter([
// Phase 1 // Phase 1
{ path: "/documents", element: <DocumentVaultPage /> }, { 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 /> }, { path: "/notifications", element: <NotificationsPage /> },
// Phase 2 — CoC / CoP // 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 // 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 // The registration wizard is the config-driven licensing flow; the old
// standalone wizard posted to endpoints that never existed. // standalone wizard posted to endpoints that never existed.
{ {
@@ -177,7 +260,14 @@ export const router = createBrowserRouter([
path: "/vessel-registration-dashboard", path: "/vessel-registration-dashboard",
element: <Navigate to="/vessel-registration" replace />, 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 nav item used to nest this under /vessel-registration, which made
// the sidebar's prefix-match (nav-utils.ts isItemActive) light up both // the sidebar's prefix-match (nav-utils.ts isItemActive) light up both
// items at once. Kept as a redirect for old bookmarks/links. // 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 />, element: <Navigate to="/licensing/applications" replace />,
}, },
// Waiver has no backend yet, so it says so rather than pretending. // 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 /> }, { path: "/waiver/apply", element: <Navigate to="/waiver" replace /> },
// Vessel Registration // 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/apply', element: <VesselRegistrationPage /> },
{ {
path: "/vessel-registrations/:id", path: "/vessel-registrations/:id",
element: <VesselRegistrationStatusPage />, element: (
<RequirePermission anyOf={[P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS]}>
<VesselRegistrationStatusPage />
</RequirePermission>
),
}, },
// General // General

View File

@@ -26,6 +26,11 @@ export {
} from "./lib/store/signup.slice"; } from "./lib/store/signup.slice";
export { usePermissions } from "./lib/hooks/usePermissions"; export { usePermissions } from "./lib/hooks/usePermissions";
export type { PermissionSet } 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 { export {
useCurrentProfile, useCurrentProfile,
useGetMyProfileQuery, 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; profile: CurrentProfile;
completeness: number; completeness: number;
missing: ProfileField[]; 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 const profileApi = baseApi

View File

@@ -1,47 +1,15 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useSelector } from 'react-redux'; import { useGetMyProfileQuery } from './useCurrentProfile';
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;
}
}
export interface PermissionSet { export interface PermissionSet {
permissions: string[]; permissions: string[];
/** True if the user holds any one of `required`. Empty `required` = allowed. */ /** True if the user holds any one of `required`. Empty `required` = allowed. */
can: (required?: string[]) => boolean; can: (required?: string[]) => boolean;
/** /**
* Whether permissions could be read at all. When false, callers should show * Whether the effective permission list has loaded. While false, `can()`
* everything rather than hide the whole application from someone whose token * returns true so the shell does not flash an empty sidebar during the
* simply does not carry the claim. * first fetch; callers gating destructive actions should also check
* `known` and disable rather than hide.
*/ */
known: boolean; known: boolean;
} }
@@ -49,22 +17,22 @@ export interface PermissionSet {
/** /**
* What the signed-in user is allowed to do. * What the signed-in user is allowed to do.
* *
* Deliberately fails open: if the token carries no `permissions` claim we * The list comes from `GET /profiles/me` (`permissions: string[]`), which the
* report `known: false` and `can()` returns true. Hiding navigation on a * API computes per session: role grants, portal account-type grants and
* claim-shape mismatch would leave a legitimate officer staring at an empty * position grants combined. The access token itself carries no permission
* sidebar with no way to tell why, whereas failing open costs at most a 403 * claims, so nothing is decoded client-side any more.
* on a link they should not have seen. *
* 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 { export function usePermissions(): PermissionSet {
// Re-read whenever the session changes rather than only on mount. const { data, isSuccess } = useGetMyProfileQuery();
const token = useSelector(
(state: { auth?: { token?: string | null } }) => state.auth?.token,
);
return useMemo(() => { return useMemo(() => {
const claims = decodeClaims(token ?? authStorage.getToken()); const permissions = data?.permissions ?? [];
const permissions = claims?.permissions ?? []; const known = isSuccess && Array.isArray(data?.permissions);
const known = Array.isArray(claims?.permissions);
const granted = new Set(permissions); const granted = new Set(permissions);
return { return {
@@ -76,5 +44,5 @@ export function usePermissions(): PermissionSet {
return required.some((permission) => granted.has(permission)); 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;