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>

View File

@@ -1,10 +1,25 @@
export type BiometricModality = 'FINGERPRINT' | 'FACE';
export type BiometricEnrollmentStatus = 'ACTIVE' | 'REVOKED';
/** Which finger a FINGERPRINT template belongs to; UNSPECIFIED for FACE or an untracked capture. */
export type BiometricPosition =
| 'RIGHT_THUMB'
| 'RIGHT_INDEX'
| 'RIGHT_MIDDLE'
| 'RIGHT_RING'
| 'RIGHT_LITTLE'
| 'LEFT_THUMB'
| 'LEFT_INDEX'
| 'LEFT_MIDDLE'
| 'LEFT_RING'
| 'LEFT_LITTLE'
| 'UNSPECIFIED';
export interface BiometricEnrollment {
id: string;
profileId: string;
modality: BiometricModality;
position: BiometricPosition | null;
templateFormat: string;
qualityScore: number | null;
deviceId: string | null;
@@ -22,6 +37,8 @@ export interface BiometricEnrollment {
export interface EnrollBiometric {
profileId: string;
modality: BiometricModality;
/** Required in practice for FINGERPRINT captures; a real scanner always knows which finger it read. */
position?: BiometricPosition;
/** Vendor SDK template, base64. Never the raw scan image. */
template: string;
templateFormat: string;

View File

@@ -1,2 +1,3 @@
export * from './biometric-enrollment.types';
export * from './biometric-enrollment-api';
export * from './mantra-capture-agent';

View File

@@ -0,0 +1,169 @@
import type { BiometricPosition } from './biometric-enrollment.types';
/**
* Client for Mantra's Windows RD Service — the local agent that ships with
* MORPHS (and every other Mantra scanner) and is the only way a browser can
* reach a USB-attached device. It implements UIDAI's standard Registered
* Device Service contract (the same one every L1-certified scanner uses),
* exposing three verbs over plain HTTP on `127.0.0.1`:
*
* RDSERVICE http://127.0.0.1:<port>/ → discovery: is it running,
* what are the paths for
* DEVICEINFO and CAPTURE
* DEVICEINFO http://127.0.0.1:<port>/rd/info → device identity
* CAPTURE http://127.0.0.1:<port>/rd/capture → the actual scan
*
* The service binds one port out of 1110011105 (11100 unless something else
* already holds it), so discovery has to probe the range rather than assume
* a fixed port.
*
* This is the one piece "swap the simulated bytes for the real vendor SDK
* once a vendor is chosen" (see `BiometricEnrollmentPage.tsx`) always meant —
* everything else in the enroll pipeline (base64 template + format tag,
* encrypt, store) was already shaped for whatever this returns.
*/
const CANDIDATE_PORTS = [11100, 11101, 11102, 11103, 11104, 11105] as const;
const DISCOVERY_TIMEOUT_MS = 800;
const CAPTURE_TIMEOUT_MS = 30_000;
export interface MantraDeviceInfo {
/** Device provider/serial identity as reported by RD service — goes in `deviceId`. */
deviceId: string;
/** RD service version string, kept for support/troubleshooting, not sent to the API. */
rdsVersion: string;
/** The port discovery found it on. */
port: number;
}
export interface MantraCaptureResult {
/** Base64 PID template block — passed straight through as `EnrollBiometric.template`. */
template: string;
templateFormat: string;
/** RD service's own capture quality score, 0100, when the response carries one. */
qualityScore?: number;
deviceId: string;
}
export class MantraCaptureUnavailableError extends Error {
constructor(message = 'Mantra RD Service was not found on this machine.') {
super(message);
this.name = 'MantraCaptureUnavailableError';
}
}
export class MantraCaptureFailedError extends Error {
constructor(
message: string,
public readonly errCode?: string,
) {
super(message);
this.name = 'MantraCaptureFailedError';
}
}
function withTimeout(ms: number): { signal: AbortSignal; cancel: () => void } {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
return { signal: controller.signal, cancel: () => clearTimeout(timer) };
}
async function rdRequest(port: number, path: string, method: string, timeoutMs: number, body?: string): Promise<string> {
const { signal, cancel } = withTimeout(timeoutMs);
try {
// RD Service answers plain HTTP on localhost, and browsers block a page
// served over https:// from fetching http:// — this must be called from
// a counter page itself served over http://localhost or with the RD
// service's own local HTTPS certificate trusted, per Mantra's install guide.
const res = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
body,
headers: body ? { 'Content-Type': 'application/xml' } : undefined,
signal,
});
return await res.text();
} finally {
cancel();
}
}
/** Parses `attr="value"` out of one XML tag without pulling in an XML library for three fields. */
function attr(xml: string, tag: string, name: string): string | undefined {
const tagMatch = xml.match(new RegExp(`<${tag}\\b[^>]*>`, 'i'));
if (!tagMatch) return undefined;
const attrMatch = tagMatch[0].match(new RegExp(`${name}="([^"]*)"`, 'i'));
return attrMatch?.[1];
}
/**
* Probes the RD service port range and reads back device identity. Resolves
* to `null` (never throws) when nothing answers — callers use that to decide
* whether to show "Simulate Scan" instead, exactly like `simulateEnabled`
* already gates it server-side.
*/
export async function discoverMantraDevice(): Promise<MantraDeviceInfo | null> {
for (const port of CANDIDATE_PORTS) {
try {
const discovery = await rdRequest(port, '/', 'RDSERVICE', DISCOVERY_TIMEOUT_MS);
if (!/status="READY"/i.test(discovery)) continue;
const infoPath = attr(discovery, 'Interface', 'path') ?? '/rd/info';
const info = await rdRequest(port, infoPath, 'DEVICEINFO', DISCOVERY_TIMEOUT_MS);
const dpId = attr(info, 'DeviceInfo', 'dpId') ?? attr(info, 'DeviceInfo', 'dc');
const rdsVersion = attr(info, 'DeviceInfo', 'rdsVer') ?? 'unknown';
if (!dpId) continue;
return { deviceId: dpId, rdsVersion, port };
} catch {
// Nothing on this port (connection refused/timeout) — try the next one.
continue;
}
}
return null;
}
/**
* One fingerprint capture for a specific finger. `position` drives nothing
* in the RD-service request itself (a single-finger scanner like a slap
* reader doesn't need to be told which finger it is), but the caller must
* still know which finger was placed, since MORPHS's four-finger and
* two-thumb slaps arrive pre-segmented by the vendor SDK into one image per
* finger with no position label of their own.
*/
export async function captureFingerprint(
device: MantraDeviceInfo,
position: BiometricPosition,
): Promise<MantraCaptureResult> {
const pidOptions = `<PidOptions ver="1.0"><Opts fCount="1" fType="0" format="0" pidVer="2.0" timeout="${CAPTURE_TIMEOUT_MS}" posh="UNKNOWN" env="P" /></PidOptions>`;
let response: string;
try {
response = await rdRequest(device.port, '/rd/capture', 'CAPTURE', CAPTURE_TIMEOUT_MS, pidOptions);
} catch (err) {
throw new MantraCaptureFailedError(
err instanceof Error && err.name === 'AbortError' ? 'Capture timed out.' : 'Could not reach the scanner.',
);
}
const errCode = attr(response, 'Resp', 'errCode');
if (errCode && errCode !== '0') {
const errInfo = attr(response, 'Resp', 'errInfo') ?? `RD service error ${errCode}`;
throw new MantraCaptureFailedError(errInfo, errCode);
}
const dataMatch = response.match(/<Data[^>]*>([\s\S]*?)<\/Data>/i);
if (!dataMatch) {
throw new MantraCaptureFailedError('Capture response had no template data.');
}
const qScore = attr(response, 'Resp', 'qScore');
return {
template: dataMatch[1].trim(),
// PidData carries an encrypted PID block per UIDAI's spec, not a raw
// ISO/WSQ template — recorded as such so a real matcher/decryption step
// downstream isn't misled into treating it as plaintext ISO-19794-4.
templateFormat: 'UIDAI-PID-2.0',
qualityScore: qScore ? Number(qScore) : undefined,
deviceId: device.deviceId,
};
}