mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 20:05:42 +00:00
feat: enhance biometric enrollment with Mantra device integration and finger position support
This commit is contained in:
@@ -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 & 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>
|
||||
|
||||
Reference in New Issue
Block a user