mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 20:05:42 +00:00
feat(biometric-enrollment): add biometric enrollment feature with API integration and UI components
This commit is contained in:
@@ -0,0 +1,253 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Container,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
ThemeIcon,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { IconAlertTriangle, IconFingerprint, IconPrinter, IconScan, IconSearch, IconX } from '@tabler/icons-react';
|
||||||
|
import { useDebouncedValue } from '@mantine/hooks';
|
||||||
|
import {
|
||||||
|
extractErrorMessage,
|
||||||
|
openAuthedDocument,
|
||||||
|
useEnrollBiometricMutation,
|
||||||
|
useGetBiometricEnrollmentsQuery,
|
||||||
|
useListSeafarerRegistrationsQuery,
|
||||||
|
useRevokeBiometricEnrollmentMutation,
|
||||||
|
type BiometricModality,
|
||||||
|
type SeafarerRegistration,
|
||||||
|
} from '@ema-platform/api';
|
||||||
|
import { notify, PageHeader, StatusBadge } from '@ema-platform/ui';
|
||||||
|
import { useDateDisplayer } from '@ema-platform/shared';
|
||||||
|
|
||||||
|
const MODALITIES: { value: BiometricModality; label: string }[] = [
|
||||||
|
{ value: 'FINGERPRINT', label: 'Fingerprint' },
|
||||||
|
{ value: 'FACE', label: 'Face' },
|
||||||
|
];
|
||||||
|
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
function fakeTemplate(): string {
|
||||||
|
const bytes = crypto.getRandomValues(new Uint8Array(64));
|
||||||
|
return btoa(String.fromCharCode(...bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pick a registered seafarer to enroll — approved registrations carry a profileId. */
|
||||||
|
function ProfilePicker({ onPick }: { onPick: (r: SeafarerRegistration) => void }) {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [debounced] = useDebouncedValue(search, 300);
|
||||||
|
const { data, isFetching } = useListSeafarerRegistrationsQuery({
|
||||||
|
status: 'APPROVED',
|
||||||
|
search: debounced || undefined,
|
||||||
|
take: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card withBorder radius="md" p="md">
|
||||||
|
<TextInput
|
||||||
|
placeholder="Search seafarer by name, ID or registration number…"
|
||||||
|
leftSection={<IconSearch size={14} />}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
|
mb="sm"
|
||||||
|
/>
|
||||||
|
{isFetching && <Loader size="sm" />}
|
||||||
|
<Table highlightOnHover fz="sm">
|
||||||
|
<Table.Tbody>
|
||||||
|
{(data?.items ?? []).map((r) => (
|
||||||
|
<Table.Tr key={r.id} onClick={() => onPick(r)} style={{ cursor: 'pointer' }}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz="sm" fw={600}>{applicantName(r)}</Text>
|
||||||
|
<Text fz="xs" c="dimmed" ff="monospace">{r.seafarerNumber}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
{!isFetching && (data?.items ?? []).length === 0 && (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz="sm" c="dimmed">No registered seafarer matches.</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
)}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Backoffice counter screen: enroll a scanner capture against a profile, view what's on file, print the slip. */
|
||||||
|
export function BiometricEnrollmentPage() {
|
||||||
|
const showDate = useDateDisplayer();
|
||||||
|
const [selected, setSelected] = useState<SeafarerRegistration | null>(null);
|
||||||
|
const [modality, setModality] = useState<BiometricModality>('FINGERPRINT');
|
||||||
|
const [deviceId, setDeviceId] = useState('');
|
||||||
|
|
||||||
|
const profileId = selected?.profileId ?? '';
|
||||||
|
const { data: enrollments, isLoading } = useGetBiometricEnrollmentsQuery(profileId, { skip: !profileId });
|
||||||
|
const [enroll, { isLoading: enrolling }] = useEnrollBiometricMutation();
|
||||||
|
const [revoke, { isLoading: revoking }] = useRevokeBiometricEnrollmentMutation();
|
||||||
|
const [printing, setPrinting] = useState(false);
|
||||||
|
|
||||||
|
const hasActive = useMemo(
|
||||||
|
() => (m: BiometricModality) => (enrollments ?? []).some((e) => e.modality === m),
|
||||||
|
[enrollments],
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleEnroll() {
|
||||||
|
if (!profileId) return;
|
||||||
|
try {
|
||||||
|
await enroll({
|
||||||
|
profileId,
|
||||||
|
modality,
|
||||||
|
template: fakeTemplate(),
|
||||||
|
templateFormat: 'SIMULATED',
|
||||||
|
deviceId: deviceId || undefined,
|
||||||
|
consentAt: new Date().toISOString(),
|
||||||
|
}).unwrap();
|
||||||
|
notify.success(`${modality === 'FINGERPRINT' ? 'Fingerprint' : 'Face'} enrolled.`);
|
||||||
|
} catch (err) {
|
||||||
|
notify.error(extractErrorMessage(err, 'Enrollment failed.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevoke(id: string) {
|
||||||
|
if (!profileId) return;
|
||||||
|
try {
|
||||||
|
await revoke({ id, profileId, reason: 'Withdrawn at counter' }).unwrap();
|
||||||
|
notify.success('Enrollment revoked.');
|
||||||
|
} catch (err) {
|
||||||
|
notify.error(extractErrorMessage(err, 'Could not revoke.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePrint() {
|
||||||
|
if (!profileId) return;
|
||||||
|
setPrinting(true);
|
||||||
|
try {
|
||||||
|
await openAuthedDocument(
|
||||||
|
`/biometric-enrollments/profile/${profileId}/certificate`,
|
||||||
|
`biometric-enrollment-${profileId}.pdf`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
notify.error(extractErrorMessage(err, 'Could not open the certificate.'));
|
||||||
|
} finally {
|
||||||
|
setPrinting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size="md" py="md">
|
||||||
|
<PageHeader
|
||||||
|
title="Biometric Enrollment"
|
||||||
|
subtitle="Capture a fingerprint or face template for a registered seafarer, and print the enrollment slip."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!selected ? (
|
||||||
|
<ProfilePicker onPick={setSelected} />
|
||||||
|
) : (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Card withBorder radius="md" p="md">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<div>
|
||||||
|
<Text fw={600}>{applicantName(selected)}</Text>
|
||||||
|
<Text fz="xs" c="dimmed" ff="monospace">{selected.seafarerNumber}</Text>
|
||||||
|
</div>
|
||||||
|
<Button variant="subtle" size="xs" leftSection={<IconX size={14} />} onClick={() => setSelected(null)}>
|
||||||
|
Change seafarer
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card withBorder radius="md" p="md">
|
||||||
|
<Text fz="sm" fw={600} mb="sm">Capture</Text>
|
||||||
|
<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.
|
||||||
|
</Alert>
|
||||||
|
<Group align="flex-end">
|
||||||
|
<Select label="Modality" data={MODALITIES} value={modality} onChange={(v) => setModality((v as BiometricModality) ?? 'FINGERPRINT')} w={160} />
|
||||||
|
<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
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card withBorder radius="md" p="md">
|
||||||
|
<Group justify="space-between" mb="sm">
|
||||||
|
<Text fz="sm" fw={600}>On file</Text>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
size="xs"
|
||||||
|
leftSection={<IconPrinter size={14} />}
|
||||||
|
onClick={handlePrint}
|
||||||
|
loading={printing}
|
||||||
|
>
|
||||||
|
Print certificate
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader size="sm" />
|
||||||
|
) : (
|
||||||
|
<Stack gap="xs">
|
||||||
|
{MODALITIES.map((m) => (
|
||||||
|
<Group key={m.value} justify="space-between" p="xs" style={{ borderRadius: 8, background: 'var(--mantine-color-default-hover)' }}>
|
||||||
|
<Group gap="xs">
|
||||||
|
<ThemeIcon variant="light" color={hasActive(m.value) ? 'teal' : 'gray'} size={30} radius="md">
|
||||||
|
<IconFingerprint size={15} />
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
{(enrollments ?? []).map((e) => (
|
||||||
|
<Text key={e.id} fz="xs" c="dimmed">
|
||||||
|
{e.modality} captured {showDate(e.enrolledAt)}{e.deviceId ? ` · ${e.deviceId}` : ''}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BiometricEnrollmentPage;
|
||||||
@@ -93,6 +93,7 @@ export const am: Translations = {
|
|||||||
// vesselRegistrationReport: 'የምዝገባ ሪፖርት',
|
// vesselRegistrationReport: 'የምዝገባ ሪፖርት',
|
||||||
vesselTransfers: "የመርከብ ባለቤትነት ዝውውር",
|
vesselTransfers: "የመርከብ ባለቤትነት ዝውውር",
|
||||||
seafarerRegistry: "የመርከበኞች መዝገብ",
|
seafarerRegistry: "የመርከበኞች መዝገብ",
|
||||||
|
biometricEnrollment: "ባዮሜትሪክ ምዝገባ",
|
||||||
seafarerRegistrationQueue: "የመርከበኞች ምዝገባ ወረፋ",
|
seafarerRegistrationQueue: "የመርከበኞች ምዝገባ ወረፋ",
|
||||||
applications: "ማመልከቻዎች",
|
applications: "ማመልከቻዎች",
|
||||||
paymentConfig: "የክፍያ ውቅረት",
|
paymentConfig: "የክፍያ ውቅረት",
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ export const en = {
|
|||||||
vesselRegistrations: 'Vessel Registration',
|
vesselRegistrations: 'Vessel Registration',
|
||||||
vesselTransfers: 'Vessel Ownership Transfer',
|
vesselTransfers: 'Vessel Ownership Transfer',
|
||||||
seafarerRegistry: 'Seafarer Registry',
|
seafarerRegistry: 'Seafarer Registry',
|
||||||
|
biometricEnrollment: 'Biometric Enrollment',
|
||||||
seafarerRegistrationQueue: 'Seafarer Registration Queue',
|
seafarerRegistrationQueue: 'Seafarer Registration Queue',
|
||||||
applications: 'Applications',
|
applications: 'Applications',
|
||||||
paymentConfig: 'Payment Config',
|
paymentConfig: 'Payment Config',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
IconCreditCard,
|
IconCreditCard,
|
||||||
IconFileDescription,
|
IconFileDescription,
|
||||||
IconFilePlus,
|
IconFilePlus,
|
||||||
|
IconFingerprint,
|
||||||
IconGauge,
|
IconGauge,
|
||||||
IconGavel,
|
IconGavel,
|
||||||
IconHeart,
|
IconHeart,
|
||||||
@@ -96,6 +97,7 @@ export const NAV_SECTIONS: NavSection[] = [
|
|||||||
label: 'nav.groupSeafarer',
|
label: 'nav.groupSeafarer',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers, permissions: [P.VIEW_SEAFARER_REGISTRY] },
|
{ to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers, permissions: [P.VIEW_SEAFARER_REGISTRY] },
|
||||||
|
{ to: '/biometric-enrollment', label: 'nav.biometricEnrollment', icon: IconFingerprint, permissions: [P.ENROLL_BIOMETRICS, P.VIEW_BIOMETRICS] },
|
||||||
{ to: '/seafarer-registrations', label: 'nav.seafarerRegistrationQueue', icon: IconId, permissions: [P.VIEW_SEAFARER_REGISTRY] },
|
{ to: '/seafarer-registrations', label: 'nav.seafarerRegistrationQueue', icon: IconId, permissions: [P.VIEW_SEAFARER_REGISTRY] },
|
||||||
{ to: '/licence-review/type/CERTIFICATE_OF_COMPETENCY', label: 'nav.cocQueue', icon: IconShieldCheck, permissions: [P.VIEW_SEAFARER_REGISTRY] },
|
{ to: '/licence-review/type/CERTIFICATE_OF_COMPETENCY', label: 'nav.cocQueue', icon: IconShieldCheck, permissions: [P.VIEW_SEAFARER_REGISTRY] },
|
||||||
{ to: '/licence-review/type/CERTIFICATE_OF_PROFICIENCY', label: 'nav.copQueue', icon: IconShieldCheck, permissions: [P.VIEW_SEAFARER_REGISTRY] },
|
{ to: '/licence-review/type/CERTIFICATE_OF_PROFICIENCY', label: 'nav.copQueue', icon: IconShieldCheck, permissions: [P.VIEW_SEAFARER_REGISTRY] },
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import { LicenseReviewPage } from '../features/license-review/pages/LicenseRevie
|
|||||||
import { LogisticsHeadDashboardPage } from '../features/logistics-head/pages/LogisticsHeadDashboardPage';
|
import { LogisticsHeadDashboardPage } from '../features/logistics-head/pages/LogisticsHeadDashboardPage';
|
||||||
import { CertificateDesignerPage } from '../features/certificate-designer/pages/CertificateDesignerPage';
|
import { CertificateDesignerPage } from '../features/certificate-designer/pages/CertificateDesignerPage';
|
||||||
import { CertificateRequirementsPage } from '../features/certificate-requirements/pages/CertificateRequirementsPage';
|
import { CertificateRequirementsPage } from '../features/certificate-requirements/pages/CertificateRequirementsPage';
|
||||||
|
import { BiometricEnrollmentPage } from '../features/biometric-enrollment/pages/BiometricEnrollmentPage';
|
||||||
|
|
||||||
/** Any-of gate shared by every licence-type queue and its review workspace. */
|
/** Any-of gate shared by every licence-type queue and its review workspace. */
|
||||||
const APPLICATION_QUEUE = [P.VIEW_APPLICATION_QUEUE, P.VIEW_APPLICATIONS];
|
const APPLICATION_QUEUE = [P.VIEW_APPLICATION_QUEUE, P.VIEW_APPLICATIONS];
|
||||||
@@ -100,6 +101,7 @@ const router = createBrowserRouter([
|
|||||||
{ path: 'sea-service-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], <SeaServiceVerificationPage />) },
|
{ path: 'sea-service-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], <SeaServiceVerificationPage />) },
|
||||||
{ path: 'payment-config', element: guard([P.VIEW_PAYMENTS], <PaymentConfigPage />) },
|
{ path: 'payment-config', element: guard([P.VIEW_PAYMENTS], <PaymentConfigPage />) },
|
||||||
{ path: 'seafarer-registry', element: guard([P.VIEW_SEAFARER_REGISTRY], <SeafarerRegistryPage />) },
|
{ path: 'seafarer-registry', element: guard([P.VIEW_SEAFARER_REGISTRY], <SeafarerRegistryPage />) },
|
||||||
|
{ path: 'biometric-enrollment', element: guard([P.ENROLL_BIOMETRICS, P.VIEW_BIOMETRICS], <BiometricEnrollmentPage />) },
|
||||||
// Seafarer registration is not a licence: own queue, own review.
|
// Seafarer registration is not a licence: own queue, own review.
|
||||||
{ path: 'seafarer-registrations', element: guard(APPLICATION_QUEUE, <SeafarerRegistrationQueuePage />) },
|
{ path: 'seafarer-registrations', element: guard(APPLICATION_QUEUE, <SeafarerRegistrationQueuePage />) },
|
||||||
{ path: 'seafarer-registrations/:id', element: guard(APPLICATION_QUEUE, <SeafarerRegistrationReviewPage />) },
|
{ path: 'seafarer-registrations/:id', element: guard(APPLICATION_QUEUE, <SeafarerRegistrationReviewPage />) },
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export * from './lib/features/location';
|
|||||||
export * from './lib/features/seafarer';
|
export * from './lib/features/seafarer';
|
||||||
export * from './lib/features/seafarer-registration';
|
export * from './lib/features/seafarer-registration';
|
||||||
export * from './lib/features/seafarer-document';
|
export * from './lib/features/seafarer-document';
|
||||||
|
export * from './lib/features/biometric-enrollment';
|
||||||
export * from './lib/features/vessel';
|
export * from './lib/features/vessel';
|
||||||
export { baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth';
|
export { baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth';
|
||||||
export { openAuthedDocument, downloadAuthedFile } from './lib/base-api/download';
|
export { openAuthedDocument, downloadAuthedFile } from './lib/base-api/download';
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { baseApi } from '../../base-api';
|
||||||
|
import type { BiometricEnrollment, EnrollBiometric } from './biometric-enrollment.types';
|
||||||
|
|
||||||
|
const TAG = 'BiometricEnrollment' as const;
|
||||||
|
const forProfile = (profileId: string) => ({ type: TAG, id: profileId }) as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scanner capture (fingerprint, face) stored per profile. No applicant-facing
|
||||||
|
* endpoint — enrollment happens at a counter with a scanner.
|
||||||
|
*/
|
||||||
|
export const biometricEnrollmentApi = baseApi
|
||||||
|
.enhanceEndpoints({ addTagTypes: [TAG] })
|
||||||
|
.injectEndpoints({
|
||||||
|
endpoints: (builder) => ({
|
||||||
|
enrollBiometric: builder.mutation<BiometricEnrollment, EnrollBiometric>({
|
||||||
|
query: (body) => ({ url: '/biometric-enrollments', method: 'POST', body }),
|
||||||
|
invalidatesTags: (r, error) => (error || !r ? [] : [forProfile(r.profileId)]),
|
||||||
|
}),
|
||||||
|
|
||||||
|
getBiometricEnrollments: builder.query<BiometricEnrollment[], string>({
|
||||||
|
query: (profileId) => ({ url: `/biometric-enrollments/profile/${profileId}` }),
|
||||||
|
providesTags: (_r, _e, profileId) => [forProfile(profileId)],
|
||||||
|
}),
|
||||||
|
|
||||||
|
revokeBiometricEnrollment: builder.mutation<
|
||||||
|
BiometricEnrollment,
|
||||||
|
{ id: string; profileId: string; reason: string }
|
||||||
|
>({
|
||||||
|
query: ({ id, reason }) => ({
|
||||||
|
url: `/biometric-enrollments/${id}/revoke`,
|
||||||
|
method: 'POST',
|
||||||
|
body: { reason },
|
||||||
|
}),
|
||||||
|
invalidatesTags: (_r, error, { profileId }) => (error ? [] : [forProfile(profileId)]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
overrideExisting: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const {
|
||||||
|
useEnrollBiometricMutation,
|
||||||
|
useGetBiometricEnrollmentsQuery,
|
||||||
|
useRevokeBiometricEnrollmentMutation,
|
||||||
|
} = biometricEnrollmentApi;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export type BiometricModality = 'FINGERPRINT' | 'FACE';
|
||||||
|
export type BiometricEnrollmentStatus = 'ACTIVE' | 'REVOKED';
|
||||||
|
|
||||||
|
export interface BiometricEnrollment {
|
||||||
|
id: string;
|
||||||
|
profileId: string;
|
||||||
|
modality: BiometricModality;
|
||||||
|
templateFormat: string;
|
||||||
|
qualityScore: number | null;
|
||||||
|
deviceId: string | null;
|
||||||
|
status: BiometricEnrollmentStatus;
|
||||||
|
enrolledById: string;
|
||||||
|
enrolledAt: string;
|
||||||
|
consentAt: string;
|
||||||
|
revokedReason: string | null;
|
||||||
|
revokedById: string | null;
|
||||||
|
revokedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnrollBiometric {
|
||||||
|
profileId: string;
|
||||||
|
modality: BiometricModality;
|
||||||
|
/** Vendor SDK template, base64. Never the raw scan image. */
|
||||||
|
template: string;
|
||||||
|
templateFormat: string;
|
||||||
|
qualityScore?: number;
|
||||||
|
deviceId?: string;
|
||||||
|
/** ISO 8601 — when the subject consented to capture. */
|
||||||
|
consentAt: string;
|
||||||
|
}
|
||||||
2
libs/api/src/lib/features/biometric-enrollment/index.ts
Normal file
2
libs/api/src/lib/features/biometric-enrollment/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './biometric-enrollment.types';
|
||||||
|
export * from './biometric-enrollment-api';
|
||||||
@@ -47,6 +47,8 @@ export const LICENSE_PERMISSIONS = {
|
|||||||
MANAGE_SEAFARER_STATUS: "can:manage:seafarer-status",
|
MANAGE_SEAFARER_STATUS: "can:manage:seafarer-status",
|
||||||
VIEW_SEAFARER_REGISTRY: "can:View:seafarer-registry",
|
VIEW_SEAFARER_REGISTRY: "can:View:seafarer-registry",
|
||||||
VERIFY_SEAFARER_RECORDS: "can:verify:seafarer-records",
|
VERIFY_SEAFARER_RECORDS: "can:verify:seafarer-records",
|
||||||
|
ENROLL_BIOMETRICS: "can:enroll:biometrics",
|
||||||
|
VIEW_BIOMETRICS: "can:View:biometrics",
|
||||||
VIEW_VESSEL_REGISTRY: "can:View:vessel-registry",
|
VIEW_VESSEL_REGISTRY: "can:View:vessel-registry",
|
||||||
MANAGE_VESSEL_STATUS: "can:manage:vessel-status",
|
MANAGE_VESSEL_STATUS: "can:manage:vessel-status",
|
||||||
APPROVE_QUESTION: "can:approve:exam-question",
|
APPROVE_QUESTION: "can:approve:exam-question",
|
||||||
|
|||||||
Reference in New Issue
Block a user