mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-28 18:20:59 +00:00
feat: add biometric enrollment and viewing capabilities with API integration
This commit is contained in:
@@ -21,6 +21,7 @@ import {
|
||||
openAuthedDocument,
|
||||
useEnrollBiometricMutation,
|
||||
useGetBiometricEnrollmentsQuery,
|
||||
useGetBiometricSimulateCapabilitiesQuery,
|
||||
useListSeafarerRegistrationsQuery,
|
||||
useRevokeBiometricEnrollmentMutation,
|
||||
type BiometricModality,
|
||||
@@ -102,6 +103,11 @@ export function BiometricEnrollmentPage() {
|
||||
|
||||
const profileId = selected?.profileId ?? '';
|
||||
const { data: enrollments, isLoading } = useGetBiometricEnrollmentsQuery(profileId, { skip: !profileId });
|
||||
// No vendor SDK integrated yet — "Simulate Scan" fakes a capture so the
|
||||
// rest of the flow is exercisable. Reports false in production unless
|
||||
// ALLOW_BIOMETRIC_SIMULATION=true, same shortcut the payment bypass uses.
|
||||
const { data: capabilities } = useGetBiometricSimulateCapabilitiesQuery();
|
||||
const simulateEnabled = capabilities?.simulateEnabled ?? false;
|
||||
const [enroll, { isLoading: enrolling }] = useEnrollBiometricMutation();
|
||||
const [revoke, { isLoading: revoking }] = useRevokeBiometricEnrollmentMutation();
|
||||
const [printing, setPrinting] = useState(false);
|
||||
@@ -178,16 +184,24 @@ export function BiometricEnrollmentPage() {
|
||||
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Text fz="sm" fw={600} mb="sm">Capture</Text>
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={16} />} mb="sm" variant="light">
|
||||
No scanner is wired yet — this simulates a capture so the rest of the flow can be tested.
|
||||
</Alert>
|
||||
<Group align="flex-end">
|
||||
<Select label="Modality" data={MODALITIES} value={modality} onChange={(v) => setModality((v as BiometricModality) ?? 'FINGERPRINT')} w={160} />
|
||||
<TextInput label="Device (optional)" placeholder="scanner-01" value={deviceId} onChange={(e) => setDeviceId(e.currentTarget.value)} w={180} />
|
||||
<Button leftSection={<IconScan size={16} />} onClick={handleEnroll} loading={enrolling}>
|
||||
Simulate Scan & Enroll
|
||||
</Button>
|
||||
</Group>
|
||||
{simulateEnabled ? (
|
||||
<>
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={16} />} mb="sm" variant="light">
|
||||
No scanner is wired yet — this simulates a capture so the rest of the flow can be tested.
|
||||
</Alert>
|
||||
<Group align="flex-end">
|
||||
<Select label="Modality" data={MODALITIES} value={modality} onChange={(v) => setModality((v as BiometricModality) ?? 'FINGERPRINT')} w={160} />
|
||||
<TextInput label="Device (optional)" placeholder="scanner-01" value={deviceId} onChange={(e) => setDeviceId(e.currentTarget.value)} w={180} />
|
||||
<Button leftSection={<IconScan size={16} />} onClick={handleEnroll} loading={enrolling}>
|
||||
Simulate Scan & Enroll
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
) : (
|
||||
<Alert color="gray" icon={<IconAlertTriangle size={16} />} variant="light">
|
||||
No scanner is wired yet, and capture simulation is off in this environment.
|
||||
</Alert>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card withBorder radius="md" p="md">
|
||||
|
||||
180
apps/portal/src/app/features/seafarer/pages/Biometrics/index.tsx
Normal file
180
apps/portal/src/app/features/seafarer/pages/Biometrics/index.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
IconDownload,
|
||||
IconFingerprint,
|
||||
IconInfoCircle,
|
||||
IconScan,
|
||||
} from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { authStorage } from '@ema-platform/auth';
|
||||
import { useGetMyBiometricEnrollmentsQuery } from '@ema-platform/api';
|
||||
import { PdfPreviewModal } from '@ema-platform/ui';
|
||||
|
||||
const API_BASE =
|
||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
|
||||
'http://localhost:3000/api';
|
||||
|
||||
async function fetchCertificate(): Promise<Blob> {
|
||||
const token = authStorage.getToken();
|
||||
if (!token) throw new Error('No auth token found');
|
||||
const res = await fetch(`${API_BASE}/biometric-enrollments/mine/certificate`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to fetch certificate (${res.status})`);
|
||||
return res.blob();
|
||||
}
|
||||
|
||||
function downloadBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
const MODALITY_LABEL: Record<string, string> = {
|
||||
FINGERPRINT: 'Fingerprint',
|
||||
FACE: 'Face',
|
||||
};
|
||||
|
||||
/**
|
||||
* View-only: what's enrolled, plus a printable slip. Capture stays
|
||||
* counter-side with a scanner — there is no self-enrollment flow here.
|
||||
*/
|
||||
export function BiometricsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { data: enrollments, isLoading } = useGetMyBiometricEnrollmentsQuery();
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const openPreview = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
setPreviewUrl(URL.createObjectURL(await fetchCertificate()));
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: 'Error',
|
||||
message: err instanceof Error ? err.message : 'Could not load certificate',
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
downloadBlob(await fetchCertificate(), 'biometric-enrollment-certificate.pdf');
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: 'Error',
|
||||
message: err instanceof Error ? err.message : 'Could not download certificate',
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const rows = enrollments ?? [];
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Group gap="xs">
|
||||
<IconFingerprint size={22} />
|
||||
<Title order={2}>{t('biometrics.title', 'Biometrics')}</Title>
|
||||
</Group>
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
{t(
|
||||
'biometrics.pageIntro',
|
||||
'Fingerprint and face enrollment happen in person at an EMA counter. This page shows what is on file for you.',
|
||||
)}
|
||||
</Alert>
|
||||
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="lg">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : rows.length === 0 ? (
|
||||
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
|
||||
{t('biometrics.empty', 'No biometric enrollment on file yet.')}
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
{rows.map((e) => (
|
||||
<Card key={e.id} withBorder radius="md" p="md">
|
||||
<Group justify="space-between">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={36} radius="md" color="blue" variant="light">
|
||||
<IconScan size={18} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="sm">
|
||||
{MODALITY_LABEL[e.modality] ?? e.modality}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
Enrolled {new Date(e.enrolledAt).toLocaleDateString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge color="teal" variant="light">
|
||||
{e.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<Group>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={busy ? <Loader size={12} /> : <IconInfoCircle size={12} />}
|
||||
onClick={openPreview}
|
||||
disabled={busy}
|
||||
>
|
||||
{t('biometrics.view', 'View certificate')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<IconDownload size={12} />}
|
||||
onClick={handleDownload}
|
||||
disabled={busy}
|
||||
>
|
||||
{t('biometrics.download', 'Download')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<PdfPreviewModal
|
||||
opened={!!previewUrl}
|
||||
onClose={() => setPreviewUrl(null)}
|
||||
url={previewUrl ?? ''}
|
||||
title={t('biometrics.title', 'Biometrics')}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
IconArrowsExchange,
|
||||
IconBell,
|
||||
IconBook2,
|
||||
IconFingerprint,
|
||||
IconFolderOpen,
|
||||
IconHeadset,
|
||||
IconHome2,
|
||||
@@ -131,6 +132,13 @@ const NAV_SECTIONS: { label?: string; items: PortalNavItem[] }[] = [
|
||||
icon: IconShieldCheck,
|
||||
permissions: [P.VIEW_OWN_CERTIFICATES],
|
||||
},
|
||||
{
|
||||
to: "/seafarer/biometrics",
|
||||
label: "Biometrics",
|
||||
i18nKey: "nav.biometrics",
|
||||
icon: IconFingerprint,
|
||||
permissions: [P.VIEW_OWN_BIOMETRICS],
|
||||
},
|
||||
{
|
||||
to: "/exams",
|
||||
label: "Examinations",
|
||||
@@ -206,6 +214,7 @@ const PAGE_META: Record<string, { i18nKey: string }> = {
|
||||
"/seaman-book": { i18nKey: "nav.seamanBook" },
|
||||
"/basic-training-certificate": { i18nKey: "nav.btc" },
|
||||
"/certificates": { i18nKey: "nav.certificates" },
|
||||
"/seafarer/biometrics": { i18nKey: "nav.biometrics" },
|
||||
"/exams": { i18nKey: "nav.exams" },
|
||||
"/endorsements": { i18nKey: "nav.endorsements" },
|
||||
"/documents": { i18nKey: "nav.documents" },
|
||||
|
||||
@@ -28,6 +28,7 @@ import { OperationsOnboardingPage } from "./features/onboarding/pages/Operations
|
||||
import { ProfilePage } from "./features/profile/pages/ProfilePage";
|
||||
import { SupportPage } from "./features/support/pages/SupportPage";
|
||||
import { MedicalRecordsPage, SeaServicePage } from "./features/seafarer/pages/SeaRecords";
|
||||
import { BiometricsPage } from "./features/seafarer/pages/Biometrics";
|
||||
import { SeafarerRegistrationPage } from "./features/seafarer-registration/pages/SeafarerRegistrationPage";
|
||||
import { ExamsPage } from "./features/exams/pages/ExamsPage";
|
||||
import { ExamAttemptPage } from "./features/exam-attempt/pages/ExamAttemptPage";
|
||||
@@ -205,6 +206,14 @@ export const router = createBrowserRouter([
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/seafarer/biometrics",
|
||||
element: (
|
||||
<RequirePermission anyOf={[P.VIEW_OWN_BIOMETRICS]}>
|
||||
<BiometricsPage />
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{ path: "/seafarer/records", element: <Navigate to="/seafarer/sea-service" replace /> },
|
||||
{
|
||||
path: "/exams",
|
||||
|
||||
@@ -22,6 +22,17 @@ export const biometricEnrollmentApi = baseApi
|
||||
providesTags: (_r, _e, profileId) => [forProfile(profileId)],
|
||||
}),
|
||||
|
||||
/** Self-service, view-only: the caller's own live enrollments. */
|
||||
getMyBiometricEnrollments: builder.query<BiometricEnrollment[], void>({
|
||||
query: () => ({ url: '/biometric-enrollments/mine' }),
|
||||
providesTags: [{ type: TAG, id: 'MINE' }],
|
||||
}),
|
||||
|
||||
/** Dev/test only — the API reports false in production. */
|
||||
getBiometricSimulateCapabilities: builder.query<{ simulateEnabled: boolean }, void>({
|
||||
query: () => ({ url: '/biometric-enrollments/simulate/capabilities' }),
|
||||
}),
|
||||
|
||||
revokeBiometricEnrollment: builder.mutation<
|
||||
BiometricEnrollment,
|
||||
{ id: string; profileId: string; reason: string }
|
||||
@@ -40,5 +51,7 @@ export const biometricEnrollmentApi = baseApi
|
||||
export const {
|
||||
useEnrollBiometricMutation,
|
||||
useGetBiometricEnrollmentsQuery,
|
||||
useGetMyBiometricEnrollmentsQuery,
|
||||
useGetBiometricSimulateCapabilitiesQuery,
|
||||
useRevokeBiometricEnrollmentMutation,
|
||||
} = biometricEnrollmentApi;
|
||||
|
||||
@@ -83,6 +83,7 @@ export const PORTAL_PERMISSIONS = {
|
||||
APPLY_EXAM: "can:apply:exam",
|
||||
VIEW_OWN_EXAM: "can:View:own-exam",
|
||||
VIEW_OWN_CERTIFICATES: "can:View:own-certificates",
|
||||
VIEW_OWN_BIOMETRICS: "can:View:own-biometrics",
|
||||
APPLY_VESSEL_REGISTRATION: "can:apply:vessel-registration",
|
||||
VIEW_OWN_VESSELS: "can:View:own-vessels",
|
||||
REPORT_VESSEL_INCIDENT: "can:report:own-vessel-incident",
|
||||
|
||||
Reference in New Issue
Block a user