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 3577cb379..fdb61a7ce 100644 --- a/apps/backoffice/src/app/features/biometric-enrollment/pages/BiometricEnrollmentPage.tsx +++ b/apps/backoffice/src/app/features/biometric-enrollment/pages/BiometricEnrollmentPage.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Alert, Badge, @@ -17,6 +17,9 @@ import { import { IconAlertTriangle, IconFingerprint, IconScan, IconSearch, IconX } from '@tabler/icons-react'; import { useDebouncedValue } from '@mantine/hooks'; import { + discoverMantraDevice, + captureFingerprint, + MantraCaptureFailedError, extractErrorMessage, useEnrollBiometricMutation, useGenerateBsidMutation, @@ -25,6 +28,8 @@ import { useListSeafarerRegistrationsQuery, useRevokeBiometricEnrollmentMutation, type BiometricModality, + type BiometricPosition, + type MantraDeviceInfo, type SeafarerRegistration, } from '@ema-platform/api'; import { notify, PageHeader, StatusBadge } from '@ema-platform/ui'; @@ -35,16 +40,30 @@ const MODALITIES: { value: BiometricModality; label: string }[] = [ { value: 'FACE', label: 'Face' }, ]; +const FINGER_POSITIONS: { value: BiometricPosition; label: string }[] = [ + { value: 'RIGHT_THUMB', label: 'Right thumb' }, + { value: 'RIGHT_INDEX', label: 'Right index' }, + { value: 'RIGHT_MIDDLE', label: 'Right middle' }, + { value: 'RIGHT_RING', label: 'Right ring' }, + { value: 'RIGHT_LITTLE', label: 'Right little' }, + { value: 'LEFT_THUMB', label: 'Left thumb' }, + { value: 'LEFT_INDEX', label: 'Left index' }, + { value: 'LEFT_MIDDLE', label: 'Left middle' }, + { value: 'LEFT_RING', label: 'Left ring' }, + { value: 'LEFT_LITTLE', label: 'Left little' }, +]; + function applicantName(r: Pick): string { return [r.firstName, r.middleName, r.lastName].filter(Boolean).join(' ') || '—'; } /** - * No scanner is wired yet (US-BIO placeholder): "Simulate Scan" stands in for - * the real vendor SDK capture, producing a random template so the rest of the - * pipeline — encrypt, store, print — is exercisable end to end. Swap the - * simulated bytes for the SDK's real template once a vendor is chosen; the - * API call shape (base64 template + format tag) does not change. + * Fallback only (US-BIO placeholder): when `discoverMantraDevice()` finds no + * RD Service on this machine, "Simulate Scan" stands in for a real capture so + * the rest of the pipeline — encrypt, store, print — stays exercisable. Once + * a Mantra scanner answers discovery, `handleCaptureFromDevice` is used + * instead — see `mantra-capture-agent.ts`. Same API call shape either way + * (base64 template + format tag). */ function fakeTemplate(): string { const bytes = crypto.getRandomValues(new Uint8Array(64)); @@ -112,23 +131,72 @@ 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 + // "Simulate Scan" fakes a capture so the rest of the flow is exercisable + // where no scanner is present. Reports false in production unless // ALLOW_BIOMETRIC_SIMULATION=true, same shortcut the payment bypass uses. const { data: capabilities } = useGetBiometricSimulateCapabilitiesQuery(); const simulateEnabled = capabilities?.simulateEnabled ?? false; + + // Probed once per page load: is Mantra's RD Service running on this + // counter PC? `null` means "not checked yet / not found" — capture then + // falls back to Simulate Scan, same as before a device was ever expected. + const [device, setDevice] = useState(null); + const [probingDevice, setProbingDevice] = useState(true); + useEffect(() => { + let cancelled = false; + setProbingDevice(true); + discoverMantraDevice() + .then((found) => { if (!cancelled) setDevice(found); }) + .finally(() => { if (!cancelled) setProbingDevice(false); }); + return () => { cancelled = true; }; + }, []); + + const [position, setPosition] = useState('RIGHT_THUMB'); const [generateBsid, { isLoading: generatingBsid }] = useGenerateBsidMutation(); // Seeded from the seafarer registration list (which does not carry BSID // yet) and updated locally once generated — this screen's only source of // truth for it until the registry surfaces the profile's BSID directly. const [bsid, setBsid] = useState(null); const [enroll, { isLoading: enrolling }] = useEnrollBiometricMutation(); + const [capturing, setCapturing] = useState(false); const [revoke, { isLoading: revoking }] = useRevokeBiometricEnrollmentMutation(); const hasActive = useMemo( () => (m: BiometricModality) => (enrollments ?? []).some((e) => e.modality === m), [enrollments], ); + const positionEnrolled = useMemo( + () => (p: BiometricPosition) => (enrollments ?? []).some((e) => e.modality === 'FINGERPRINT' && e.position === p), + [enrollments], + ); + + /** Real scanner path: capture from the device that answered discovery, then enroll exactly as Simulate Scan does. */ + async function handleCaptureFromDevice() { + if (!profileId || !device) return; + setCapturing(true); + try { + const capture = await captureFingerprint(device, position); + await enroll({ + profileId, + modality: 'FINGERPRINT', + position, + template: capture.template, + templateFormat: capture.templateFormat, + qualityScore: capture.qualityScore, + deviceId: capture.deviceId, + consentAt: new Date().toISOString(), + }).unwrap(); + notify.success(`Fingerprint (${FINGER_POSITIONS.find((f) => f.value === position)?.label}) enrolled.`); + } catch (err) { + notify.error( + err instanceof MantraCaptureFailedError + ? err.message + : extractErrorMessage(err, 'Enrollment failed.'), + ); + } finally { + setCapturing(false); + } + } async function handleEnroll() { if (!profileId) return; @@ -136,6 +204,7 @@ export function BiometricEnrollmentPage() { await enroll({ profileId, modality, + position: modality === 'FINGERPRINT' ? position : undefined, template: fakeTemplate(), templateFormat: 'SIMULATED', deviceId: deviceId || undefined, @@ -206,13 +275,42 @@ export function BiometricEnrollmentPage() { Capture - {simulateEnabled ? ( + {probingDevice ? ( + Looking for a scanner… + ) : device ? ( + <> + } mb="sm" variant="light"> + Mantra scanner detected ({device.deviceId}). Place the finger below and capture. + + + setModality((v as BiometricModality) ?? 'FINGERPRINT')} w={160} /> + {modality === 'FINGERPRINT' && ( +