feat: enhance biometric enrollment with Mantra device integration and finger position support

This commit is contained in:
nati
2026-09-07 08:57:57 +00:00
parent 29990d3142
commit 5dbc6b27de
4 changed files with 323 additions and 33 deletions

View File

@@ -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<SeafarerRegistration, 'firstName' | 'middleName' | 'lastName'>): 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<MantraDeviceInfo | null>(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<BiometricPosition>('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<string | null>(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() {
<Card withBorder radius="md" p="md">
<Text fz="sm" fw={600} mb="sm">Capture</Text>
{simulateEnabled ? (
{probingDevice ? (
<Group gap="xs"><Loader size="sm" /><Text fz="sm" c="dimmed">Looking for a scanner</Text></Group>
) : device ? (
<>
<Alert color="teal" icon={<IconFingerprint size={16} />} mb="sm" variant="light">
Mantra scanner detected ({device.deviceId}). Place the finger below and capture.
</Alert>
<Group align="flex-end">
<Select
label="Finger"
data={FINGER_POSITIONS}
value={position}
onChange={(v) => setPosition((v as BiometricPosition) ?? 'RIGHT_THUMB')}
w={180}
/>
<Button leftSection={<IconFingerprint size={16} />} onClick={handleCaptureFromDevice} loading={capturing || enrolling}>
{positionEnrolled(position) ? 'Re-capture & Enroll' : 'Capture & 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.
No scanner detected 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} />
{modality === 'FINGERPRINT' && (
<Select
label="Finger"
data={FINGER_POSITIONS}
value={position}
onChange={(v) => setPosition((v as BiometricPosition) ?? 'RIGHT_THUMB')}
w={180}
/>
)}
<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
@@ -221,7 +319,7 @@ export function BiometricEnrollmentPage() {
</>
) : (
<Alert color="gray" icon={<IconAlertTriangle size={16} />} variant="light">
No scanner is wired yet, and capture simulation is off in this environment.
No scanner detected, and capture simulation is off in this environment.
</Alert>
)}
</Card>
@@ -265,32 +363,37 @@ export function BiometricEnrollmentPage() {
</ThemeIcon>
<Text fz="sm">{m.label}</Text>
</Group>
{hasActive(m.value) ? (
<Group gap="xs">
<StatusBadge tone="success" label="Enrolled" />
<Button
size="xs"
color="red"
variant="subtle"
loading={revoking}
onClick={() => {
const row = (enrollments ?? []).find((e) => e.modality === m.value);
if (row) handleRevoke(row.id);
}}
>
Revoke
</Button>
</Group>
) : (
<Badge color="gray" variant="light">Not enrolled</Badge>
)}
<Badge color={hasActive(m.value) ? 'teal' : 'gray'} variant="light">
{m.value === 'FINGERPRINT'
? `${(enrollments ?? []).filter((e) => e.modality === 'FINGERPRINT').length} finger(s) enrolled`
: hasActive(m.value) ? 'Enrolled' : 'Not enrolled'}
</Badge>
</Group>
))}
{/*
One row per capture, not one per modality: a profile can hold
up to ten live FINGERPRINT rows (one per finger) plus one
FACE row, so revoke has to target this specific row's id —
never "the" FINGERPRINT enrollment, which no longer exists
as a singular thing.
*/}
{(enrollments ?? []).map((e) => (
<Text key={e.id} fz="xs" c="dimmed">
{e.modality} captured {showDate(e.enrolledAt)}{e.deviceId ? ` · ${e.deviceId}` : ''}
</Text>
<Group key={e.id} justify="space-between" p="xs" style={{ borderRadius: 8, background: 'var(--mantine-color-default-hover)' }}>
<Text fz="xs" c="dimmed">
{e.modality}
{e.position && e.position !== 'UNSPECIFIED'
? ` (${FINGER_POSITIONS.find((f) => f.value === e.position)?.label ?? e.position})`
: ''}{' '}
captured {showDate(e.enrolledAt)}{e.deviceId ? ` · ${e.deviceId}` : ''}
</Text>
<Button size="xs" color="red" variant="subtle" loading={revoking} onClick={() => handleRevoke(e.id)}>
Revoke
</Button>
</Group>
))}
{(enrollments ?? []).length === 0 && (
<Text fz="xs" c="dimmed">Nothing captured yet.</Text>
)}
</Stack>
)}
</Card>