feat: add biometric enrollment and viewing capabilities with API integration

This commit is contained in:
Nati
2026-08-28 08:15:39 +00:00
parent ea0a458ed0
commit 5006e4685b
6 changed files with 236 additions and 10 deletions

View File

@@ -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 &amp; 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 &amp; 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">

View 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>
);
}

View File

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

View File

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

View File

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

View File

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