From 2d235e82f60bfe84ed8618d3d782489f2ddbc80d Mon Sep 17 00:00:00 2001 From: Ahmet Abdullah Gultekin Date: Fri, 12 Jun 2026 19:53:23 +0000 Subject: [PATCH] feat(face): client-side embedding for FACE enrollment (audit H2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the client-side-embedding FACE path to ENROLLMENT so enroll and verify share ONE aligned embedding space. Today FACE verify computes the Facenet512 vector in-browser and uploads only the embedding, but FACE *enrollment* still uploaded raw images (server-side MTCNN+Facenet512 on the CPU-only box) — so a user's enrolled server template and their client verify probe lived in slightly different preprocessing spaces. When VITE_CLIENT_SIDE_EMBEDDING is ON, FaceEnrollmentDialog now computes the embedding for the best frontal capture (the centered index-0 frame) via the EXISTING embedCapturedFace module and submits ONLY the 512-vector to the new BiometricService.enrollFaceEmbedding(), which POSTs JSON { embedding, tenant_id? } to the existing api route POST /biometric/enroll-embedding/{userId} (EnrollEmbeddingRequest). The raw image never leaves the device. Mirrors MfaStepRenderer's verify path exactly: compute -> if vector submit embedding, else show a retryable error and NEVER fall back to an image upload while the flag is ON (the CPU-only server 400s the image path when its flag is on). Flag OFF is byte-identical to the legacy image enroll (enrollFace). - BiometricService.enrollFaceEmbedding(userId, embedding, tenantId?): JSON POST, tenant_id omitted when blank (mirrors the multipart helper). - useFaceChallenge: snapshots the 478-pt FaceLandmarker mesh WITH each capture (new optional, defensive updateChallenge param + ChallengeState .captureLandmarks) so the enroll embedding is eye-aligned identically to the verify probe (self-consistency). Backend backend lacking dense landmarks (BlazeFace) -> null -> embedder uses the unaligned crop. - FaceEnrollmentFlow threads detection.captureLandmarks through to onComplete. - i18n: enrollmentPage.faceClientPrepFailed (en + tr). - Tests: BiometricService.enrollFaceEmbedding (JSON body, tenant omit, success shape) + FaceEnrollmentDialog (flag OFF/ON/null routing). Reversible: flag OFF (default) = unchanged server-side enroll. Vitest + tsc + eslint green. --- src/core/services/BiometricService.ts | 60 ++++++ .../__tests__/BiometricService.test.ts | 44 +++++ .../auth/components/FaceEnrollmentFlow.tsx | 7 +- .../methods/face/FaceEnrollmentDialog.tsx | 41 +++- .../__tests__/FaceEnrollmentDialog.test.tsx | 178 ++++++++++++++++++ src/features/auth/hooks/useFaceChallenge.ts | 14 ++ src/i18n/locales/en.json | 1 + src/i18n/locales/tr.json | 1 + 8 files changed, 336 insertions(+), 10 deletions(-) create mode 100644 src/features/auth/components/enrollment/methods/face/__tests__/FaceEnrollmentDialog.test.tsx diff --git a/src/core/services/BiometricService.ts b/src/core/services/BiometricService.ts index c067260..3e3b799 100644 --- a/src/core/services/BiometricService.ts +++ b/src/core/services/BiometricService.ts @@ -174,6 +174,66 @@ export class BiometricService { } } + /** + * Enroll a face by submitting ONLY its 512-d Facenet512 embedding (privacy- + * preserving counterpart of {@link enrollFace}). + * + * The raw face image NEVER leaves the device: the browser computes the + * authoritative Facenet512 vector locally (via `embedCapturedFace`) and posts + * just the vector, exactly mirroring how the FACE *verify* path submits + * `{ embedding }` instead of an image. This closes audit item H2 — FACE + * enrollment previously always uploaded raw images even when client-side + * embedding was enabled for verify. + * + * Wire contract (FROZEN, mirrors api `EnrollEmbeddingRequest`): + * POST /api/v1/biometric/enroll-embedding/{userId} + * Content-Type: application/json + * body: { embedding: number[512], tenant_id?: string } + * `tenant_id` (snake_case) is added ONLY when `tenantId` is a non-blank + * string (defense-in-depth — the backend already derives tenant from the + * authenticated principal, but explicit forwarding disambiguates admins + * linked to multiple tenants). + * + * The api gate is FAIL-CLOSED: it returns 403 when the tenant's + * `app.auth.client-side-embedding` flag is off (so the caller must flip the + * identity flag before the web flag), and 400 on a wrong-length vector. There + * is intentionally no `optimize`/template-fusion field on this route — a + * re-enroll replaces the stored template. + * + * @param userId The user whose face template is being (re)enrolled. + * @param embedding The L2-normalized 512-float Facenet512 vector. + * @param tenantId Optional tenant scope (forwarded as `tenant_id`). + */ + async enrollFaceEmbedding( + userId: string, + embedding: number[], + tenantId?: string, + ): Promise { + try { + const body: { embedding: number[]; tenant_id?: string } = { embedding } + // Mirror appendTenantAndEmbeddings' blank check — only forward a real tenant. + if (tenantId && tenantId.trim().length > 0) { + body.tenant_id = tenantId + } + + const response = await this.client.post( + `/biometric/enroll-embedding/${encodeURIComponent(userId)}`, + body, + { headers: { 'Content-Type': 'application/json' } }, + ) + + // Proxy returns BiometricVerificationResponse: {verified, confidence, message}. + return { + success: response.data.verified !== false, + userId, + confidence: typeof response.data.confidence === 'number' ? response.data.confidence : 1.0, + message: response.data.message ?? 'Face enrolled successfully', + } + } catch (error) { + throw this.mapEnrollmentError(error) + } + } + /** * Enroll using multiple images with quality-weighted fusion. */ diff --git a/src/core/services/__tests__/BiometricService.test.ts b/src/core/services/__tests__/BiometricService.test.ts index 571c798..e60c297 100644 --- a/src/core/services/__tests__/BiometricService.test.ts +++ b/src/core/services/__tests__/BiometricService.test.ts @@ -154,6 +154,50 @@ describe('BiometricService — multipart wire contract', () => { }) }) + describe('enrollFaceEmbedding', () => { + // The embedding enroll route posts a plain JSON object (NOT FormData), so + // read the body straight off the last post call's 2nd arg. + function lastJsonBody(): { embedding?: unknown; tenant_id?: unknown } { + expect(post).toHaveBeenCalled() + const call = post.mock.calls[post.mock.calls.length - 1] + return call[1] as { embedding?: unknown; tenant_id?: unknown } + } + + const embedding = Array.from({ length: 512 }, (_, i) => (i % 5) / 5) + + it('posts JSON { embedding } to /biometric/enroll-embedding/ and includes tenant_id', async () => { + post.mockResolvedValue({ data: { verified: true, confidence: 0.97 } }) + + const tenantId = 'tenant-marmara-uuid' + await service.enrollFaceEmbedding('user-1', embedding, tenantId) + + const call = post.mock.calls[post.mock.calls.length - 1] + expect(call[0]).toBe('/biometric/enroll-embedding/user-1') + const body = lastJsonBody() + expect(Array.isArray(body.embedding)).toBe(true) + expect((body.embedding as number[]).length).toBe(512) + expect(body.tenant_id).toBe(tenantId) + }) + + it('omits tenant_id when blank/whitespace-only', async () => { + post.mockResolvedValue({ data: { verified: true } }) + await service.enrollFaceEmbedding('user-1', embedding, ' ') + + const body = lastJsonBody() + expect(Array.isArray(body.embedding)).toBe(true) + expect('tenant_id' in body).toBe(false) + }) + + it('returns { success: true } on a { verified: true } response', async () => { + post.mockResolvedValue({ data: { verified: true, confidence: 0.91, message: 'ok' } }) + const result = await service.enrollFaceEmbedding('user-1', embedding) + + expect(result.success).toBe(true) + expect(result.userId).toBe('user-1') + expect(result.confidence).toBe(0.91) + }) + }) + describe('verifyFace', () => { it('forwards tenant_id', async () => { post.mockResolvedValue({ diff --git a/src/features/auth/components/FaceEnrollmentFlow.tsx b/src/features/auth/components/FaceEnrollmentFlow.tsx index 86ca6cd..e1f9807 100644 --- a/src/features/auth/components/FaceEnrollmentFlow.tsx +++ b/src/features/auth/components/FaceEnrollmentFlow.tsx @@ -16,11 +16,12 @@ import { motion, AnimatePresence } from 'framer-motion' import { useFaceDetection } from '../hooks/useFaceDetection' import { useFaceChallenge, ChallengeStage } from '../hooks/useFaceChallenge' import FaceOvalGuide from './FaceOvalGuide' +import type { NormalizedLandmark } from '../../../lib/biometric-engine/types' interface FaceEnrollmentFlowProps { open: boolean onClose: () => void - onComplete: (images: string[], clientEmbeddings?: (number[] | null)[]) => void + onComplete: (images: string[], clientEmbeddings?: (number[] | null)[], captureLandmarks?: (NormalizedLandmark[] | null)[]) => void } const STAGE_ICONS: Record = { @@ -113,7 +114,7 @@ export default function FaceEnrollmentFlow({ open, onClose, onComplete }: FaceEn const loop = () => { try { const d = detectionRef.current - updateChallenge(d, d.cropFace, canvasRef) + updateChallenge(d, d.cropFace, canvasRef, d.captureLandmarks) } catch { // Silently ignore individual frame errors to keep the loop running } @@ -151,7 +152,7 @@ export default function FaceEnrollmentFlow({ open, onClose, onComplete }: FaceEn setSubmitting(true) // Don't close here — the parent (EnrollmentPage) will close the dialog // after the biometric API call completes (success or failure) - onComplete(challengeState.captures, challengeState.clientEmbeddings) + onComplete(challengeState.captures, challengeState.clientEmbeddings, challengeState.captureLandmarks) } const handleRetry = () => { diff --git a/src/features/auth/components/enrollment/methods/face/FaceEnrollmentDialog.tsx b/src/features/auth/components/enrollment/methods/face/FaceEnrollmentDialog.tsx index ffc07fe..03368e7 100644 --- a/src/features/auth/components/enrollment/methods/face/FaceEnrollmentDialog.tsx +++ b/src/features/auth/components/enrollment/methods/face/FaceEnrollmentDialog.tsx @@ -14,6 +14,9 @@ import { AuthMethodType } from '@domain/models/AuthMethod' import { getBiometricService } from '@core/services/BiometricService' import type { IHttpClient } from '@domain/interfaces/IHttpClient' import { formatApiError } from '@utils/formatApiError' +import { isClientSideEmbeddingEnabled } from '@features/biometrics/embedding/clientEmbeddingFlag' +import { embedCapturedFace } from '@features/biometrics/embedding/embedCapturedFace' +import type { NormalizedLandmark } from '@/lib/biometric-engine/types' import type { ShowSnackbar } from '../../types' interface Props { @@ -48,17 +51,41 @@ export default function FaceEnrollmentDialog({ const { t } = useTranslation() const handleComplete = useCallback( - async (images: string[], clientEmbeddings?: (number[] | null)[]) => { + async (images: string[], clientEmbeddings?: (number[] | null)[], captureLandmarks?: (NormalizedLandmark[] | null)[]) => { if (!userId || images.length === 0) return setActionLoading(AuthMethodType.FACE) try { const biometric = getBiometricService() - // Send all captured images — enrollFace will use /enroll/multi - // for 2+ images (quality-weighted template fusion). - // clientEmbeddings are 512-dim landmark-geometry vectors computed in-browser - // via EmbeddingComputer (MediaPipe, log-only per D2). Server stores them for - // offline analysis only — never used for auth decisions. - await biometric.enrollFace(userId, images, tenantId, clientEmbeddings, optimize) + if (isClientSideEmbeddingEnabled()) { + // Client-side-embedding ON: compute the Facenet512 embedding for the best + // frontal capture (index 0 = the centered 'position' frame) entirely in the + // browser and submit ONLY the vector — the raw image never leaves the device. + // The captured 478-pt mesh is threaded so the embedder ALIGNS the face (eyes → + // canonical) first, the same as the verify probe → enroll and verify share one + // aligned space (audit H2). GPU-LESS ENFORCEMENT: the CPU-only server 400s the + // image path when its flag is on, so a null embedding must NOT fall back to an + // image upload — surface a retryable error instead (mirrors MfaStepRenderer). + // NOTE: optimize/template-fusion is not yet supported on the embedding enroll + // route; a re-enroll replaces the template. + const frontalImage = images[0] + const frontalLandmarks = captureLandmarks?.[0] ?? undefined + const embedding = await embedCapturedFace(frontalImage, frontalLandmarks ?? undefined) + if (!embedding) { + // On-device prep failed — retryable, never upload the image while the flag is on. + onClose() + showSnackbar(t('enrollmentPage.faceClientPrepFailed'), 'error') + return + } + await biometric.enrollFaceEmbedding(userId, embedding, tenantId) + } else { + // Flag OFF: legacy image upload, byte-identical to before. + // Send all captured images — enrollFace will use /enroll/multi + // for 2+ images (quality-weighted template fusion). + // clientEmbeddings are 512-dim landmark-geometry vectors computed in-browser + // via EmbeddingComputer (MediaPipe, log-only per D2). Server stores them for + // offline analysis only — never used for auth decisions. + await biometric.enrollFace(userId, images, tenantId, clientEmbeddings, optimize) + } // Create enrollment record and explicitly complete it (FACE is ASYNC_ENROLLMENT_TYPE) await createEnrollment({ diff --git a/src/features/auth/components/enrollment/methods/face/__tests__/FaceEnrollmentDialog.test.tsx b/src/features/auth/components/enrollment/methods/face/__tests__/FaceEnrollmentDialog.test.tsx new file mode 100644 index 0000000..b371f8a --- /dev/null +++ b/src/features/auth/components/enrollment/methods/face/__tests__/FaceEnrollmentDialog.test.tsx @@ -0,0 +1,178 @@ +/** + * FaceEnrollmentDialog — enroll submit path: client-side embedding vs legacy image. + * + * Audit item H2: extend the client-side-embedding FACE path to ENROLLMENT. Behind + * the `VITE_CLIENT_SIDE_EMBEDDING` flag (mirrors the server flag + * `app.auth.client-side-embedding`), the enroll dialog computes the Facenet512 + * embedding for the best frontal capture (index 0) in the browser and submits ONLY + * the vector via `enrollFaceEmbedding` — the raw image never leaves the device. + * + * Contract verified here (deterministic — embedder + flag + service are mocked): + * - Flag OFF → enrollFace(userId, images, tenantId, clientEmbeddings, optimize), + * never enrollFaceEmbedding, never embedCapturedFace (byte-identical legacy). + * - Flag ON + embedding returns a 512-array → embedCapturedFace(firstImage, + * firstLandmarks) then enrollFaceEmbedding(userId, embedding, tenantId), never + * enrollFace. + * - Flag ON + embedding null → showSnackbar(prep-failed), NEVER enrollFaceEmbedding, + * NEVER enrollFace (no image fallback — GPU-less enforcement), NO onEnrolled. + * + * FaceEnrollmentFlow is mocked to a single button that hands a captured image + + * landmarks up to onComplete, so this suite asserts ONLY the dialog's submit routing. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import '../../../../../../../i18n' + +// Inert biometric engine (transitive imports may pull it at module load). +vi.mock('@/lib/biometric-engine/core/BiometricEngine', () => ({ + BiometricEngine: { + getInstance: () => ({ + initialize: () => Promise.resolve(), + }), + }, +})) + +// The captured frame + 478-pt mesh the (mocked) FaceEnrollmentFlow hands up. +const CAPTURED_IMAGE = 'data:image/jpeg;base64,AAAA' +const CAPTURED_LANDMARKS = [ + { x: 0.38, y: 0.42, z: 0 }, + { x: 0.62, y: 0.42, z: 0 }, + { x: 0.5, y: 0.6, z: 0 }, +] + +// Mock FaceEnrollmentFlow down to a single button so we drive ONLY the dialog's +// onComplete routing — no camera / detection in jsdom. It hands up the image, a +// per-capture clientEmbeddings array, and the per-capture landmarks (mirrors the +// real flow's 3-arg onComplete). +vi.mock('../../../../FaceEnrollmentFlow', () => ({ + __esModule: true, + default: ({ + onComplete, + }: { + onComplete: ( + images: string[], + clientEmbeddings?: (number[] | null)[], + captureLandmarks?: (unknown[] | null)[], + ) => void + }) => ( + + ), +})) + +// Injectable flag + embed function so the test is deterministic (no ONNX). +const flagState = { enabled: false } +const fakeEmbedding = Array.from({ length: 512 }, (_, i) => (i % 7) / 7) +const embedCapturedFaceMock = vi.fn(async () => fakeEmbedding as number[] | null) + +vi.mock('@features/biometrics/embedding/clientEmbeddingFlag', () => ({ + isClientSideEmbeddingEnabled: () => flagState.enabled, +})) +vi.mock('@features/biometrics/embedding/embedCapturedFace', () => ({ + embedCapturedFace: (...args: unknown[]) => embedCapturedFaceMock(...(args as [])), +})) + +// Mock the biometric service so we assert which enroll method is called. +const enrollFace = vi.fn().mockResolvedValue({ success: true }) +const enrollFaceEmbedding = vi.fn().mockResolvedValue({ success: true }) +vi.mock('@core/services/BiometricService', () => ({ + getBiometricService: () => ({ enrollFace, enrollFaceEmbedding }), +})) + +// Mock the DI container — the dialog resolves an httpClient to PUT the +// enrollment-complete call after a successful enroll. +const httpPut = vi.fn().mockResolvedValue({}) +vi.mock('@core/di/container', () => ({ + container: { get: () => ({ put: httpPut }) }, +})) + +import FaceEnrollmentDialog from '../FaceEnrollmentDialog' + +const USER_ID = 'user-1' +const TENANT_ID = 'tenant-marmara-uuid' + +function renderDialog() { + const onClose = vi.fn() + const onEnrolled = vi.fn() + const showSnackbar = vi.fn() + const setActionLoading = vi.fn() + const createEnrollment = vi.fn().mockResolvedValue({}) + render( + , + ) + return { onClose, onEnrolled, showSnackbar, setActionLoading, createEnrollment } +} + +describe('FaceEnrollmentDialog — enroll submit: client embedding vs legacy image', () => { + beforeEach(() => { + flagState.enabled = false + embedCapturedFaceMock.mockClear() + embedCapturedFaceMock.mockResolvedValue(fakeEmbedding) + enrollFace.mockClear() + enrollFaceEmbedding.mockClear() + httpPut.mockClear() + }) + + it('flag OFF (default): calls enrollFace with images, never enrollFaceEmbedding/embedCapturedFace', async () => { + flagState.enabled = false + const { onEnrolled } = renderDialog() + + await userEvent.click(screen.getByRole('button', { name: 'enroll-complete' })) + + await waitFor(() => expect(enrollFace).toHaveBeenCalledTimes(1)) + expect(enrollFace).toHaveBeenCalledWith( + USER_ID, + [CAPTURED_IMAGE], + TENANT_ID, + [null], + false, + ) + expect(enrollFaceEmbedding).not.toHaveBeenCalled() + expect(embedCapturedFaceMock).not.toHaveBeenCalled() + await waitFor(() => expect(onEnrolled).toHaveBeenCalledTimes(1)) + }) + + it('flag ON + embedding returns 512-array: embeds first image+landmarks then enrollFaceEmbedding, never enrollFace', async () => { + flagState.enabled = true + const { onEnrolled } = renderDialog() + + await userEvent.click(screen.getByRole('button', { name: 'enroll-complete' })) + + await waitFor(() => expect(enrollFaceEmbedding).toHaveBeenCalledTimes(1)) + expect(embedCapturedFaceMock).toHaveBeenCalledWith(CAPTURED_IMAGE, CAPTURED_LANDMARKS) + expect(enrollFaceEmbedding).toHaveBeenCalledWith(USER_ID, fakeEmbedding, TENANT_ID) + expect(enrollFace).not.toHaveBeenCalled() + await waitFor(() => expect(onEnrolled).toHaveBeenCalledTimes(1)) + }) + + it('flag ON + embedding null: shows prep-failed snackbar, no enroll call, no onEnrolled (no image fallback)', async () => { + flagState.enabled = true + embedCapturedFaceMock.mockResolvedValue(null) + const { onEnrolled, showSnackbar } = renderDialog() + + await userEvent.click(screen.getByRole('button', { name: 'enroll-complete' })) + + await waitFor(() => expect(showSnackbar).toHaveBeenCalledTimes(1)) + expect(showSnackbar).toHaveBeenCalledWith( + 'Couldn\'t prepare on-device face enrollment. Please check your connection and try again.', + 'error', + ) + expect(enrollFaceEmbedding).not.toHaveBeenCalled() + expect(enrollFace).not.toHaveBeenCalled() + expect(onEnrolled).not.toHaveBeenCalled() + }) +}) diff --git a/src/features/auth/hooks/useFaceChallenge.ts b/src/features/auth/hooks/useFaceChallenge.ts index b504f27..cbb1bc6 100644 --- a/src/features/auth/hooks/useFaceChallenge.ts +++ b/src/features/auth/hooks/useFaceChallenge.ts @@ -3,6 +3,7 @@ import { FaceDetectionState } from './useFaceDetection' import { BiometricEngine } from '../../../lib/biometric-engine/core/BiometricEngine' import { BlinkTransitionTracker } from '../../../lib/biometric-engine/core/challenges' import { dataURLToImageData } from '../utils/faceCropper' +import type { NormalizedLandmark } from '../../../lib/biometric-engine/types' /** * Client-side passive liveness pre-filter threshold. @@ -82,6 +83,7 @@ export interface ChallengeState { instruction: string captures: string[] // base64 images captured at each stage clientEmbeddings: (number[] | null)[] // 512-dim landmark-geometry embedding per capture (null if landmarks unavailable); log-only per D2 + captureLandmarks: (NormalizedLandmark[] | null)[] // 478-pt mesh snapshotted WITH each capture, for the client-side embedder's aligner (null when the active backend lacks dense landmarks, e.g. BlazeFace fallback) } export interface VerificationState { @@ -167,12 +169,14 @@ export function useFaceChallenge() { instruction: ENROLLMENT_STAGES[0].instructionKey, captures: [], clientEmbeddings: [], + captureLandmarks: [], }) const holdStartRef = useRef(null) const stageIndexRef = useRef(0) const capturesRef = useRef([]) const clientEmbeddingsRef = useRef<(number[] | null)[]>([]) + const captureLandmarksRef = useRef<(NormalizedLandmark[] | null)[]>([]) // Canonical close→re-open blink transition tracker — the SAME implementation // the puzzle BlinkDetector uses, so enrollment and puzzles agree on what a // blink is. Replaces the old face-detection-confidence-dip heuristic. @@ -191,6 +195,7 @@ export function useFaceChallenge() { stageIndexRef.current = 0 capturesRef.current = [] clientEmbeddingsRef.current = [] + captureLandmarksRef.current = [] holdStartRef.current = null blinkTrackerRef.current.reset() blinkDetectedRef.current = false @@ -204,6 +209,7 @@ export function useFaceChallenge() { instruction: ENROLLMENT_STAGES[0].instructionKey, captures: [], clientEmbeddings: [], + captureLandmarks: [], }) }, []) @@ -274,6 +280,7 @@ export function useFaceChallenge() { detection: FaceDetectionState, cropFace: (canvas: HTMLCanvasElement) => string | null, canvasRef: React.RefObject, + captureLandmarks?: () => NormalizedLandmark[] | null, ) => { const idx = stageIndexRef.current if (idx >= ENROLLMENT_STAGES.length) return @@ -464,6 +471,11 @@ export function useFaceChallenge() { // ───────────────────────────────────────────────────────────────────── capturesRef.current = [...capturesRef.current, capturedImage] + // Snapshot the 478-pt mesh for THIS exact frame so the + // client-side embedder's aligner uses landmarks that match the + // captured crop (null when the active backend lacks dense + // landmarks, e.g. the BlazeFace fallback). + captureLandmarksRef.current = [...captureLandmarksRef.current, typeof captureLandmarks === 'function' ? captureLandmarks() : null] // Async: extract client-side landmark-geometry embedding (log-only per D2). // Returns null when landmarks are unavailable; server ignores the field. @@ -510,6 +522,7 @@ export function useFaceChallenge() { instruction: 'faceChallenge.enrolledSuccess', captures: capturesRef.current, clientEmbeddings: clientEmbeddingsRef.current, + captureLandmarks: captureLandmarksRef.current, }) } else { setChallengeState({ @@ -522,6 +535,7 @@ export function useFaceChallenge() { instruction: ENROLLMENT_STAGES[nextIdx].instructionKey, captures: capturesRef.current, clientEmbeddings: clientEmbeddingsRef.current, + captureLandmarks: captureLandmarksRef.current, }) } } else { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 7d38166..c6d67d4 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1490,6 +1490,7 @@ "faceEnrolled": "Face Recognition enrolled successfully", "faceReEnrolled": "Face Recognition re-enrolled — recognition improved", "faceEnrollFailed": "Face enrollment failed. Please try again.", + "faceClientPrepFailed": "Couldn't prepare on-device face enrollment. Please check your connection and try again.", "voiceEnrolled": "Voice Recognition enrolled successfully", "voiceReEnrolled": "Voice Recognition re-enrolled — recognition improved", "voiceEnrollRecordFailed": "Your voice was captured, but saving the enrollment record failed: {{reason}}", diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index 7f4b2ac..46f9613 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -1490,6 +1490,7 @@ "faceEnrolled": "Yüz Tanıma başarıyla kaydedildi", "faceReEnrolled": "Yüz Tanıma yeniden kaydedildi — tanıma iyileştirildi", "faceEnrollFailed": "Yüz kaydı başarısız. Lütfen tekrar deneyin.", + "faceClientPrepFailed": "Yüz kaydı cihazda hazırlanamadı. Lütfen bağlantınızı kontrol edip tekrar deneyin.", "voiceEnrolled": "Ses Tanıma başarıyla kaydedildi", "voiceReEnrolled": "Ses Tanıma yeniden kaydedildi — tanıma iyileştirildi", "voiceEnrollRecordFailed": "Sesiniz alındı ancak kayıt kaydı saklanamadı: {{reason}}",