From 5006e4685bce3c394c75aa4d5d26c36939a3efa7 Mon Sep 17 00:00:00 2001 From: Nati Date: Fri, 28 Aug 2026 08:15:39 +0000 Subject: [PATCH] feat: add biometric enrollment and viewing capabilities with API integration --- .../pages/BiometricEnrollmentPage.tsx | 34 +++- .../seafarer/pages/Biometrics/index.tsx | 180 ++++++++++++++++++ apps/portal/src/app/layouts/PortalLayout.tsx | 9 + apps/portal/src/app/router.tsx | 9 + .../biometric-enrollment-api.ts | 13 ++ libs/auth/src/lib/permissions.constants.ts | 1 + 6 files changed, 236 insertions(+), 10 deletions(-) create mode 100644 apps/portal/src/app/features/seafarer/pages/Biometrics/index.tsx diff --git a/apps/backoffice/src/app/features/biometric-enrollment/pages/BiometricEnrollmentPage.tsx b/apps/backoffice/src/app/features/biometric-enrollment/pages/BiometricEnrollmentPage.tsx index 87f56fbfa..4b6e1e5e1 100644 --- a/apps/backoffice/src/app/features/biometric-enrollment/pages/BiometricEnrollmentPage.tsx +++ b/apps/backoffice/src/app/features/biometric-enrollment/pages/BiometricEnrollmentPage.tsx @@ -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() { Capture - } mb="sm" variant="light"> - No scanner is wired yet — this simulates a capture so the rest of the flow can be tested. - - - setModality((v as BiometricModality) ?? 'FINGERPRINT')} w={160} /> + setDeviceId(e.currentTarget.value)} w={180} /> + + + + ) : ( + } variant="light"> + No scanner is wired yet, and capture simulation is off in this environment. + + )} diff --git a/apps/portal/src/app/features/seafarer/pages/Biometrics/index.tsx b/apps/portal/src/app/features/seafarer/pages/Biometrics/index.tsx new file mode 100644 index 000000000..522a07ba1 --- /dev/null +++ b/apps/portal/src/app/features/seafarer/pages/Biometrics/index.tsx @@ -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 }).env?.['VITE_BASE_API_URL'] ?? + 'http://localhost:3000/api'; + +async function fetchCertificate(): Promise { + 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 = { + 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(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 ( + + + + {t('biometrics.title', 'Biometrics')} + + }> + {t( + 'biometrics.pageIntro', + 'Fingerprint and face enrollment happen in person at an EMA counter. This page shows what is on file for you.', + )} + + + + {isLoading ? ( + + + + ) : rows.length === 0 ? ( + }> + {t('biometrics.empty', 'No biometric enrollment on file yet.')} + + ) : ( + + + {rows.map((e) => ( + + + + + + +
+ + {MODALITY_LABEL[e.modality] ?? e.modality} + + + Enrolled {new Date(e.enrolledAt).toLocaleDateString('en-GB', { + day: '2-digit', + month: 'short', + year: 'numeric', + })} + +
+
+ + {e.status} + +
+
+ ))} +
+ + + + +
+ )} +
+ + setPreviewUrl(null)} + url={previewUrl ?? ''} + title={t('biometrics.title', 'Biometrics')} + /> +
+ ); +} diff --git a/apps/portal/src/app/layouts/PortalLayout.tsx b/apps/portal/src/app/layouts/PortalLayout.tsx index c6ffb7fb4..cc5313e68 100644 --- a/apps/portal/src/app/layouts/PortalLayout.tsx +++ b/apps/portal/src/app/layouts/PortalLayout.tsx @@ -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 = { "/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" }, diff --git a/apps/portal/src/app/router.tsx b/apps/portal/src/app/router.tsx index a8c20ea4f..70938af0e 100644 --- a/apps/portal/src/app/router.tsx +++ b/apps/portal/src/app/router.tsx @@ -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([ ), }, + { + path: "/seafarer/biometrics", + element: ( + + + + ), + }, { path: "/seafarer/records", element: }, { path: "/exams", diff --git a/libs/api/src/lib/features/biometric-enrollment/biometric-enrollment-api.ts b/libs/api/src/lib/features/biometric-enrollment/biometric-enrollment-api.ts index fd10cec4f..d6117dd25 100644 --- a/libs/api/src/lib/features/biometric-enrollment/biometric-enrollment-api.ts +++ b/libs/api/src/lib/features/biometric-enrollment/biometric-enrollment-api.ts @@ -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({ + 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; diff --git a/libs/auth/src/lib/permissions.constants.ts b/libs/auth/src/lib/permissions.constants.ts index b09917649..e17d8885d 100644 --- a/libs/auth/src/lib/permissions.constants.ts +++ b/libs/auth/src/lib/permissions.constants.ts @@ -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",