mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 18:55:43 +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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './biometric-enrollment.types';
|
||||
export * from './biometric-enrollment-api';
|
||||
export * from './mantra-capture-agent';
|
||||
|
||||
@@ -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 11100–11105 (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, 0–100, 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user