From cc8e0e52a63aad3ad82829d3d8bba6c531523f6a Mon Sep 17 00:00:00 2001 From: Ahmet Abdullah Gultekin Date: Fri, 12 Jun 2026 13:28:53 +0000 Subject: [PATCH 1/5] =?UTF-8?q?fix(login):=20F7=20=E2=80=94=20gate=20MFA?= =?UTF-8?q?=20picker=20to=20renderable=20methods=20+=20add=20passkey/appro?= =?UTF-8?q?ve=20icons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The arbitrary first-factor / MFA picker offered APPROVE_LOGIN and PASSKEY, which have NO MfaStepRenderer case → selecting one routed into the renderer's "Unknown authentication method: APPROVE_LOGIN" dead-end with a blank icon (F7). These two are device-implicit Layer-1 factors with DEDICATED first-factor entry points (PasskeyLoginButton / ApproveLoginPanel via Layer1Shortcuts) — not generic mid-flow MFA steps. The backend may still list them in availableMethods (e.g. ensureApproveLoginEnrollment auto-enrolls APPROVE_LOGIN), so MethodPickerStep now FILTERS them out of the offered list (NON_RENDERABLE_METHODS) rather than rendering an unusable card. The dedicated shortcuts are untouched. Also add passkey + approve_login icons to StepProgress.helpers METHOD_ICONS so any other place they render shows an icon instead of a blank avatar. Shared code → fixes BOTH app.fivucsas and verify.fivucsas. --- .../auth/components/StepProgress.helpers.tsx | 7 ++ .../components/steps/MethodPickerStep.tsx | 78 +++++++------------ .../MethodPickerStep.deviceImplicit.test.tsx | 45 +++++++---- 3 files changed, 67 insertions(+), 63 deletions(-) diff --git a/src/features/auth/components/StepProgress.helpers.tsx b/src/features/auth/components/StepProgress.helpers.tsx index 81a977cf..3f27877a 100644 --- a/src/features/auth/components/StepProgress.helpers.tsx +++ b/src/features/auth/components/StepProgress.helpers.tsx @@ -13,6 +13,7 @@ import { RecordVoiceOver, Nfc, Key, + VpnKey, } from '@mui/icons-material' import type { TFunction } from 'i18next' @@ -30,6 +31,12 @@ export const METHOD_ICONS: Record = { voice: , nfc_document: , hardware_key: , + // Device-implicit Layer-1 methods. They are NOT offered in the generic MFA + // picker (no MfaStepRenderer case — they have dedicated first-factor entry + // points), but they DO appear in StepProgress and any other place a method + // icon is looked up, so give them an icon instead of a blank avatar. + passkey: , + approve_login: , } /** diff --git a/src/features/auth/components/steps/MethodPickerStep.tsx b/src/features/auth/components/steps/MethodPickerStep.tsx index 6f2a83ce..d63e6694 100644 --- a/src/features/auth/components/steps/MethodPickerStep.tsx +++ b/src/features/auth/components/steps/MethodPickerStep.tsx @@ -20,24 +20,23 @@ const METHOD_I18N_KEYS: Record = { } /** - * Device-implicit methods have NO separate web enrollment step — their - * "enrollment" happens implicitly on the device (PASSKEY = the browser/OS - * creates the discoverable credential on first use; APPROVE_LOGIN = the user is - * already signed into the FIVUCSAS mobile app). The Enrollments page - * deliberately offers NO "Enroll" button for them, so a backend `enrolled:false` - * here is NOT something the user can fix in Settings. + * Methods this generic picker MUST NOT offer, because there is no + * `MfaStepRenderer` case for them — selecting one would route into the + * renderer's `default:` "Unknown authentication method" dead-end (the F7 bug: + * "Bilinmeyen yöntem: APPROVE_LOGIN" + blank icon). * - * Fix #3 (2026-06-03): for these methods we REPLACE the misleading - * "Not enrolled → Set up in Settings" dead-end (Settings has no enroll flow for - * them) with an accurate "Set up on your device" affordance. They remain - * NON-SELECTABLE as a MID-FLOW MFA step for now, because there is no - * `MfaStepRenderer` case nor a backend `VerifyMfaStepHandler` for - * APPROVE_LOGIN/PASSKEY — selecting one would route into the renderer's - * "unknown method" branch (a worse dead-end). Wiring them as a real in-flow - * factor needs that backend handler first (see report / Fix #4). Reversible: - * delete this set + the `deviceImplicit` branches to restore prior behaviour. + * PASSKEY and APPROVE_LOGIN are NOT generic mid-flow MFA steps: they are + * device-implicit Layer-1 factors with DEDICATED first-factor entry points + * (`PasskeyLoginButton`, `ApproveLoginPanel`, surfaced via `Layer1Shortcuts`). + * The backend may still list them in `availableMethods` (e.g. + * `UsernamelessLoginFlowService.ensureApproveLoginEnrollment` auto-enrolls + * APPROVE_LOGIN), so we FILTER them OUT here rather than render an unusable + * card. The dedicated shortcuts stay; this only stops the picker from offering + * a card that can't be rendered. + * + * Reversible: delete this set + the filter below to restore prior behaviour. */ -const DEVICE_IMPLICIT_METHODS: ReadonlySet = new Set([ +const NON_RENDERABLE_METHODS: ReadonlySet = new Set([ 'APPROVE_LOGIN', 'PASSKEY', ]) @@ -110,11 +109,16 @@ export default function MethodPickerStep({ - {/* Show ALL of the layer's configured methods — never hide. Each is - in one of three states: selectable (enrolled & not used), "Already - used" (a prior layer), or "Not enrolled". Order: actionable first, - then used, then not-enrolled. */} + {/* Show the layer's configured methods that CAN be rendered as a + generic MFA step. Methods without a `MfaStepRenderer` case + (PASSKEY / APPROVE_LOGIN — see NON_RENDERABLE_METHODS) are + filtered OUT so the picker never offers an "unknown method" + dead-end (F7); they have dedicated first-factor entry points. + The rest are each in one of three states: selectable (enrolled & + not used), "Already used" (a prior layer), or "Not enrolled". + Order: actionable first, then used, then not-enrolled. */} {[...availableMethods] + .filter((method) => !NON_RENDERABLE_METHODS.has(method.methodType)) .sort((a, b) => methodRank(a, usedMethods) - methodRank(b, usedMethods)) .map((method) => { const used = usedMethods.includes(method.methodType) @@ -125,11 +129,7 @@ export default function MethodPickerStep({ const labelKey = `enrollmentPage.methods.${method.methodType}.label` const translatedLabel = t(labelKey) const displayName = translatedLabel === labelKey ? method.name : translatedLabel - const deviceImplicit = DEVICE_IMPLICIT_METHODS.has(method.methodType) // Used (in a prior layer) OR not enrolled → not selectable. - // (Device-implicit methods are not yet wired as a mid-flow MFA - // step — see DEVICE_IMPLICIT_METHODS note — so they stay disabled - // here too; Fix #3 only corrects their misleading copy.) const disabled = used || !method.enrolled return ( @@ -205,24 +205,17 @@ export default function MethodPickerStep({ - {/* Status chip — Ready / Already used / Not enrolled. - Device-implicit methods (PASSKEY/APPROVE_LOGIN) read - "On your device" instead of the misleading - "Not enrolled" when the backend reports - enrolled:false, since there's no web enrollment for - them (Fix #3). */} + {/* Status chip — Ready / Already used / Not enrolled. */} - {/* Setup hint. For genuinely not-set-up methods → "set - up in Settings". For device-implicit methods we - instead tell the user it works from their device / - mobile app (no Settings enrollment exists), so they - don't hit the dead-end the user reported (Fix #3). */} - {deviceImplicit && !method.enrolled && !used && ( - - {t('mfa.deviceImplicitHint')} - - )} - {!deviceImplicit && !method.enrolled && !used && ( + {/* Setup hint for a genuinely not-set-up method. */} + {!method.enrolled && !used && ( ) => ({ ...over, }) -describe('MethodPickerStep — device-implicit copy (Fix #3)', () => { - it('shows "On your device" (not "Not enrolled") for APPROVE_LOGIN when not enrolled', () => { +describe('MethodPickerStep — non-renderable methods filtered (F7)', () => { + it('does NOT render an APPROVE_LOGIN card (no renderer case → would be a dead-end)', () => { render( {}} />, ) - expect(screen.getByText('On your device')).toBeInTheDocument() - expect(screen.queryByText('Not enrolled')).toBeNull() - // No misleading "Set up in Settings" dead-end. - expect(screen.queryByText('Set up in Settings')).toBeNull() + // APPROVE_LOGIN must be filtered out: its label must not appear. + expect(screen.queryByText('Approve on another device')).toBeNull() + // A renderable method still shows. + expect(screen.getByText('Authenticator App')).toBeInTheDocument() }) - it('still shows "Not enrolled" + Settings hint for a regular (non-device-implicit) method', () => { + it('does NOT render a PASSKEY card in the generic picker', () => { + render( + {}} + />, + ) + expect(screen.queryByText('Passkey')).toBeNull() + // No "unknown method" / device-implicit copy strands the user. + expect(screen.queryByText('On your device')).toBeNull() + }) + + it('still shows "Not enrolled" + Settings hint for a regular (renderable) method', () => { render( Date: Fri, 12 Jun 2026 13:37:29 +0000 Subject: [PATCH 2/5] =?UTF-8?q?fix(login):=20F1+F3=20=E2=80=94=20thread=20?= =?UTF-8?q?usernameless=20Layer-1=20=E2=86=92=20MFA=20continuation=20(no?= =?UTF-8?q?=20dead-end)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a usernameless / cross-device Layer-1 method (QR sign-in, approve-on-another- device, or passkey-as-first-factor) satisfied the first factor but the tenant flow needed MORE steps, the server returned an mfaSessionToken WITHOUT final tokens — but the web dropped it: - QrLoginPanel set phase='mfa' and rendered only an info alert + back button (continue suppressed), explicitly DROPPING the poll's mfaSessionToken (in-code TODO). → F1 ("web does nothing") = F3 ("only a back button"). - ApproveLoginPanel had no multi-step branch at all. - PasskeyLoginButton handed a tokenless response to onSuccess → "missing access token" dead-end (the passkey-success-into-MFA case behind F2). Fix (one mechanism): a shared Layer1Continuation handoff. Each first-factor entry point now calls onMfaRequired(mfaSessionToken + next-step availableMethods) when Layer 1 approved but more steps remain; the host enters the EXISTING MethodPicker / MfaStepRenderer continuation: - app.fivucsas (LoginPage): handleLayer1MfaRequired sets the session + methods and shows the picker / single-method dispatcher. - verify.fivucsas (HostedLoginApp + LoginMfaFlow): new initialContinuation prop resumes LoginMfaFlow into the picker / first MFA step. Poll/response types (QrLoginPoll, ApproveLoginPoll) gain availableMethods + completedMethods. The back-button-only dead-end is removed. Shared code → fixes BOTH surfaces. --- src/features/auth/approve-login.ts | 13 +++++ .../auth/components/ApproveLoginPanel.tsx | 27 +++++++++ .../auth/components/Layer1Shortcuts.tsx | 9 +++ src/features/auth/components/LoginPage.tsx | 33 +++++++++++ .../auth/components/PasskeyLoginButton.tsx | 58 ++++++++++++++++++- src/features/auth/components/QrLoginPanel.tsx | 36 ++++++++++-- .../auth/login-shared/layer1Continuation.ts | 30 ++++++++++ src/features/auth/qr-login.ts | 9 +++ src/verify-app/HostedLoginApp.tsx | 29 ++++++++++ src/verify-app/LoginMfaFlow.tsx | 47 ++++++++++++++- 10 files changed, 283 insertions(+), 8 deletions(-) create mode 100644 src/features/auth/login-shared/layer1Continuation.ts diff --git a/src/features/auth/approve-login.ts b/src/features/auth/approve-login.ts index f411cb99..ef12d6e8 100644 --- a/src/features/auth/approve-login.ts +++ b/src/features/auth/approve-login.ts @@ -26,6 +26,7 @@ */ import type { IHttpClient } from '@domain/interfaces/IHttpClient' +import type { AvailableMfaMethod } from '@domain/interfaces/IAuthRepository' export const APPROVE_LOGIN_API = { SESSION: '/auth/approve-login/session', @@ -51,6 +52,18 @@ export interface ApproveLoginPoll { refreshToken?: string expiresIn?: number role?: string + /** + * When the approved Layer-1 needs further tenant MFA steps, the server + * returns an `mfaSessionToken` (and, where available, the next step's + * `availableMethods`) instead of final tokens — so the web can continue + * into the existing MethodPicker / MfaStepRenderer flow. + */ + mfaRequired?: boolean + mfaSessionToken?: string + availableMethods?: AvailableMfaMethod[] + completedMethods?: string[] + currentStep?: number + totalSteps?: number } /** How often to poll the session status, in milliseconds. */ diff --git a/src/features/auth/components/ApproveLoginPanel.tsx b/src/features/auth/components/ApproveLoginPanel.tsx index a54d6c98..c6bad4d5 100644 --- a/src/features/auth/components/ApproveLoginPanel.tsx +++ b/src/features/auth/components/ApproveLoginPanel.tsx @@ -27,6 +27,7 @@ import { formatApiError } from '@utils/formatApiError' import { useService } from '@app/providers' import { TYPES } from '@core/di/types' import type { IHttpClient } from '@domain/interfaces/IHttpClient' +import type { Layer1Continuation } from '../login-shared/layer1Continuation' import { APPROVE_LOGIN_POLL_INTERVAL_MS, pollApproveLoginSession, @@ -47,6 +48,13 @@ export interface ApproveLoginPanelProps { initialEmail?: string /** Called with tokens when the request is APPROVED on the other device. */ onApproved: (result: ApproveLoginResult) => void + /** + * Called when the other device APPROVED Layer 1 but the tenant flow needs + * MORE steps: the server returned an `mfaSessionToken` (no final tokens). + * The host threads it into the existing MethodPicker / MfaStepRenderer + * continuation instead of stranding the user (parity with QrLoginPanel). + */ + onMfaRequired?: (continuation: Layer1Continuation) => void /** Dismiss the panel (back to the main login form). */ onCancel: () => void } @@ -64,6 +72,7 @@ function formatTime(seconds: number): string { export default function ApproveLoginPanel({ initialEmail = '', onApproved, + onMfaRequired, onCancel, }: ApproveLoginPanelProps) { const { t } = useTranslation() @@ -79,6 +88,8 @@ export default function ApproveLoginPanel({ // Keep the approved callback stable for the polling effect. const onApprovedRef = useRef(onApproved) onApprovedRef.current = onApproved + const onMfaRequiredRef = useRef(onMfaRequired) + onMfaRequiredRef.current = onMfaRequired const handleStart = useCallback(async () => { const trimmed = email.trim() @@ -142,6 +153,22 @@ export default function ApproveLoginPanel({ expiresIn: poll.expiresIn, role: poll.role, }) + } else if ( + poll.status === 'APPROVED' && + poll.mfaSessionToken && + onMfaRequiredRef.current + ) { + // Approved Layer 1, but the tenant flow needs MORE steps. Hand the + // mfaSessionToken (+ next step's availableMethods, when present) up + // so the host continues into the existing MethodPicker / + // MfaStepRenderer flow — no dead-end (parity with QrLoginPanel). + onMfaRequiredRef.current({ + mfaSessionToken: poll.mfaSessionToken, + availableMethods: poll.availableMethods, + completedMethods: poll.completedMethods, + currentStep: poll.currentStep, + totalSteps: poll.totalSteps, + }) } else if (poll.status === 'DENIED') { setPhase('denied') } else if (poll.status === 'EXPIRED') { diff --git a/src/features/auth/components/Layer1Shortcuts.tsx b/src/features/auth/components/Layer1Shortcuts.tsx index d904a4e4..a3ae52c8 100644 --- a/src/features/auth/components/Layer1Shortcuts.tsx +++ b/src/features/auth/components/Layer1Shortcuts.tsx @@ -21,6 +21,7 @@ import { Button, Divider, Stack, Typography } from '@mui/material' import { PhonelinkLock, QrCode2 } from '@mui/icons-material' import { useTranslation } from 'react-i18next' import PasskeyLoginButton from './PasskeyLoginButton' +import type { Layer1Continuation } from '../login-shared/layer1Continuation' import { hasUsernamelessPasskey, type LoginConfig, @@ -43,6 +44,12 @@ interface Layer1ShortcutsProps { */ fallbackAll?: boolean onPasskeySuccess: (login: T) => void + /** + * Passkey satisfied Layer 1 but the tenant flow needs MORE steps — the host + * continues into its MethodPicker / MfaStepRenderer flow. Forwarded to + * PasskeyLoginButton; optional so existing single-step callers are unaffected. + */ + onPasskeyMfaRequired?: (continuation: Layer1Continuation) => void onPasskeyError: (message: string) => void /** * Open the "Approve on another device" (number-matching) panel. When @@ -65,6 +72,7 @@ export default function Layer1Shortcuts({ config, fallbackAll = false, onPasskeySuccess, + onPasskeyMfaRequired, onPasskeyError, onApproveClick, onQrClick, @@ -100,6 +108,7 @@ export default function Layer1Shortcuts({ {showPasskey && ( onSuccess={onPasskeySuccess} + onMfaRequired={onPasskeyMfaRequired} onError={onPasskeyError} disabled={disabled} sx={passkeySx} diff --git a/src/features/auth/components/LoginPage.tsx b/src/features/auth/components/LoginPage.tsx index 01e373b3..b464e7ea 100644 --- a/src/features/auth/components/LoginPage.tsx +++ b/src/features/auth/components/LoginPage.tsx @@ -44,6 +44,7 @@ import ConfigUnavailableBanner from '../login-shared/ConfigUnavailableBanner' import Layer1Shortcuts from './Layer1Shortcuts' import ApproveLoginPanel, { type ApproveLoginResult } from './ApproveLoginPanel' import QrLoginPanel, { type QrLoginResult } from './QrLoginPanel' +import type { Layer1Continuation } from '../login-shared/layer1Continuation' import type { AvailableMfaMethod, MfaStepResponse, IAuthRepository } from '@domain/interfaces/IAuthRepository' import type { ITokenService } from '@domain/interfaces/ITokenService' import type { IHttpClient } from '@domain/interfaces/IHttpClient' @@ -544,6 +545,35 @@ export default function LoginPage() { [completeTokenLogin], ) + // A usernameless Layer-1 method (QR / approve / passkey) satisfied the first + // factor, but the tenant flow needs MORE steps: the server returned an + // mfaSessionToken (no final tokens). Continue into the EXISTING method-picker + // / TwoFactorDispatcher machinery instead of dead-ending (F1 = F3, and the + // passkey-success-into-MFA case). Closes whichever alt panel was open. + const handleLayer1MfaRequired = useCallback( + (continuation: Layer1Continuation) => { + setLoginError(null) + setShowApproveLogin(false) + setShowQrLogin(false) + setMfaSessionToken(continuation.mfaSessionToken) + setCompletedMfaMethods(continuation.completedMethods ?? []) + if (continuation.totalSteps) setFlowTotalSteps(continuation.totalSteps) + const methods = continuation.availableMethods ?? [] + setAvailableMethods(methods) + const enrolled = methods.filter((m) => m.enrolled) + if (enrolled.length === 1) { + // Exactly one next factor → run it directly. + setTwoFactorMethod(enrolled[0].methodType) + setShowSecondaryAuth(true) + } else { + // Multiple (pick) or none-reported (server will drive the next + // step / the user can cancel) → show the picker. + setShowMethodPicker(true) + } + }, + [], + ) + // Step/layer progress (parity with verify.fivucsas LoginMfaFlow). The DASHBOARD // login uses the PLATFORM login-config, which reports totalSteps=1 — MFA here is // dynamic per-user (not a configured layer), so we can't know upfront. We treat @@ -601,6 +631,7 @@ export default function LoginPage() { setShowApproveLogin(false)} /> @@ -642,6 +673,7 @@ export default function LoginPage() { setShowQrLogin(false)} /> @@ -1286,6 +1318,7 @@ export default function LoginPage() { config={loginConfig} fallbackAll onPasskeySuccess={handlePasskeySuccess} + onPasskeyMfaRequired={handleLayer1MfaRequired} onPasskeyError={(msg) => setLoginError(msg)} onApproveClick={() => { setLoginError(null) diff --git a/src/features/auth/components/PasskeyLoginButton.tsx b/src/features/auth/components/PasskeyLoginButton.tsx index ebb764fb..b6ec6ac4 100644 --- a/src/features/auth/components/PasskeyLoginButton.tsx +++ b/src/features/auth/components/PasskeyLoginButton.tsx @@ -19,6 +19,8 @@ import { useTranslation } from 'react-i18next' import { useService } from '@app/providers' import { TYPES } from '@core/di/types' import type { IHttpClient } from '@domain/interfaces/IHttpClient' +import type { AvailableMfaMethod } from '@domain/interfaces/IAuthRepository' +import type { Layer1Continuation } from '../login-shared/layer1Continuation' import { mapWebAuthnError } from '../webauthn-utils' import { fetchPasskeyOptions, @@ -28,8 +30,21 @@ import { } from '../passkey-login' export interface PasskeyLoginButtonProps { - /** Called with the raw server login response after a successful assertion. */ + /** + * Called with the raw server login response after a successful assertion + * that COMPLETES the login (final tokens present). When the passkey only + * satisfied Layer 1 and the tenant flow needs more steps, `onMfaRequired` + * is called instead (when wired). + */ onSuccess: (loginResponse: T) => void + /** + * Called when the passkey satisfied Layer 1 but the tenant flow needs MORE + * steps: the server returned an `mfaSessionToken` (no final accessToken). + * The host threads it into the existing MethodPicker / MfaStepRenderer + * continuation. When omitted, a multi-step response falls through to + * `onSuccess` (prior behaviour). + */ + onMfaRequired?: (continuation: Layer1Continuation) => void /** Surfaced when the ceremony or server call fails (already localized). */ onError?: (message: string) => void /** Disable while a parent flow is busy (e.g. password submit in flight). */ @@ -38,8 +53,25 @@ export interface PasskeyLoginButtonProps { sx?: Parameters[0]['sx'] } +/** + * Shape of the fields a passkey-success response may carry when the tenant flow + * needs further steps. The server uses the standard login-success envelope + * (`twoFactorRequired` / `mfaSessionToken` / `availableMethods`), which the + * generic `T` does not expose — so we read them defensively off the raw object. + */ +interface PasskeyMultiStepFields { + accessToken?: string | null + twoFactorRequired?: boolean + mfaSessionToken?: string | null + availableMethods?: AvailableMfaMethod[] + completedMethods?: string[] + currentStep?: number + totalSteps?: number +} + export default function PasskeyLoginButton({ onSuccess, + onMfaRequired, onError, disabled, sx, @@ -68,6 +100,28 @@ export default function PasskeyLoginButton({ options.sessionId, assertion, ) + // Multi-step: the passkey satisfied Layer 1 but the tenant flow needs + // more steps. The server returns an mfaSessionToken WITHOUT a final + // accessToken. Route into the host's MFA continuation instead of + // handing a tokenless response to onSuccess (which would dead-end as + // "missing access token"). When no handoff handler is wired, fall + // through to onSuccess (prior behaviour). + const multi = loginResponse as PasskeyMultiStepFields + if ( + onMfaRequired && + !multi.accessToken && + multi.mfaSessionToken && + (multi.twoFactorRequired || multi.availableMethods?.length) + ) { + onMfaRequired({ + mfaSessionToken: multi.mfaSessionToken, + availableMethods: multi.availableMethods, + completedMethods: multi.completedMethods, + currentStep: multi.currentStep, + totalSteps: multi.totalSteps, + }) + return + } onSuccess(loginResponse) } catch (err) { // mapWebAuthnError handles both DOMExceptions (cancel/timeout) and @@ -78,7 +132,7 @@ export default function PasskeyLoginButton({ } finally { setBusy(false) } - }, [busy, supported, httpClient, onSuccess, onError, t]) + }, [busy, supported, httpClient, onSuccess, onMfaRequired, onError, t]) const button = ( )} + - {showQr && ( + {/* Group 2 — approve from another device (needs your email). Split out + under its own labelled section so the later email prompt is expected, + not a surprise among the no-typing shortcuts (F4). */} + {showApprove && ( + + + {t('approveLogin.sectionLabel')} + - )} - + + )} ) } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 33862dbb..d20b6470 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -2515,6 +2515,8 @@ }, "approveLogin": { "button": "Approve on another device", + "buttonWithEmail": "Approve from another device — enter your email", + "sectionLabel": "Approve from another device", "buttonHint": "Confirm this sign-in from a device where you're already signed in", "emailLabel": "Email", "emailPlaceholder": "you@example.com", diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index 1a10bacf..f44e2cb9 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -2515,6 +2515,8 @@ }, "approveLogin": { "button": "Başka bir cihazdan onayla", + "buttonWithEmail": "Başka cihazdan onayla — e-postanızı girin", + "sectionLabel": "Başka bir cihazdan onaylayın", "buttonHint": "Bu girişi, zaten giriş yapmış olduğunuz bir cihazdan onaylayın", "emailLabel": "E-posta", "emailPlaceholder": "siz@ornek.com", From 5a1b295181a74c2ddc7f4fc6ddcf48d3c63ecf8c Mon Sep 17 00:00:00 2001 From: Ahmet Abdullah Gultekin Date: Fri, 12 Jun 2026 13:39:57 +0000 Subject: [PATCH 4/5] =?UTF-8?q?fix(login):=20F6=20=E2=80=94=20clarify=20NF?= =?UTF-8?q?C=20web=20copy=20(no=20native=20NFC=20login=20claim)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mfa.nfc.notSupported message said NFC works only on Android Chrome — true for the Web NFC API but incomplete. Reworded (en + tr) to point users to the FIVUCSAS mobile app for managing their NFC card on other devices, or to pick another method. Does NOT claim a native NFC *login* exists (verified: none does — mobile NFC is enrol/eMRTD only). Rendered by NfcStep (shared) → both surfaces. --- src/i18n/locales/en.json | 2 +- src/i18n/locales/tr.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index d20b6470..d815be21 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1751,7 +1751,7 @@ "readError": "Failed to read NFC document. Try holding it closer.", "scanFailed": "NFC scan failed. Please try again.", "scanTimeout": "No NFC tag detected within 30 seconds. Tap your document to the back of your phone and try again.", - "notSupported": "NFC is only supported in Chrome on Android. Please use Chrome on your Android device.", + "notSupported": "Scanning an NFC card in the browser works only in Chrome on Android. On other devices, enrol or manage your NFC card in the FIVUCSAS mobile app, or choose another sign-in method.", "hint": "Hold your enrolled NFC card flat against the back of your phone. Keep it steady for a few seconds.", "framedTitle": "NFC can't run here", "framedBody": "For security, Web NFC only runs in a top-level browser tab. Open the login in a new tab to continue.", diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index f44e2cb9..dcda9979 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -1751,7 +1751,7 @@ "readError": "NFC belge okunamadı. Daha yakın tutmayı deneyin.", "scanFailed": "NFC tarama başarısız. Tekrar deneyin.", "scanTimeout": "30 saniye içinde NFC etiketi algılanmadı. Belgenizi telefonunuzun arkasına dokundurup tekrar deneyin.", - "notSupported": "NFC yalnızca Android Chrome'da desteklenir. Lütfen Android cihazınızda Chrome kullanın.", + "notSupported": "Tarayıcıda NFC kart tarama yalnızca Android'deki Chrome'da çalışır. Diğer cihazlarda NFC kartınızı FIVUCSAS mobil uygulamasında kaydedip yönetebilir veya başka bir giriş yöntemi seçebilirsiniz.", "hint": "Kayıtlı NFC kartınızı telefonunuzun arkasına düz bir şekilde tutun. Birkaç saniye sabit tutun.", "framedTitle": "NFC burada çalışmıyor", "framedBody": "Web NFC, güvenlik nedeniyle yalnızca üst düzey tarayıcı sekmesinde çalışır. Devam etmek için girişi yeni bir sekmede açın.", From 2595e4b1a536e882db0e09011802858e1e1f4171 Mon Sep 17 00:00:00 2001 From: Ahmet Abdullah Gultekin Date: Fri, 12 Jun 2026 13:41:51 +0000 Subject: [PATCH 5/5] =?UTF-8?q?fix(login):=20F10=20=E2=80=94=20cap=20face-?= =?UTF-8?q?detect=20inference=20to=20~18fps=20(kill=20rAF=20Violation)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useFaceDetection ran synchronous ML inference on EVERY requestAnimationFrame on the main thread (~60fps), producing `[Violation] requestAnimationFrame handler took ms` warnings + visible jank during the face step (F10). Low-risk fix: add a timestamp gate (MIN_DETECT_INTERVAL_MS = 55ms ≈ 18fps) to all three detection loops (FaceLandmarker / BlazeFace / MediaPipe FaceDetector). The rAF cadence (and cleanup/cancellation) is unchanged; inference is just SKIPPED on intervening frames. Detection / centering / captureReady are unaffected. NOT a Worker/OffscreenCanvas rewrite (deliberately out of scope). --- src/features/auth/hooks/useFaceDetection.ts | 43 +++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/features/auth/hooks/useFaceDetection.ts b/src/features/auth/hooks/useFaceDetection.ts index 79bf005d..c27e31a0 100644 --- a/src/features/auth/hooks/useFaceDetection.ts +++ b/src/features/auth/hooks/useFaceDetection.ts @@ -55,6 +55,18 @@ const INITIAL_STATE: FaceDetectionState = { /** Yaw threshold (degrees) beyond which the user is prompted to look straight. */ const YAW_STRAIGHT_THRESHOLD = 25 +/** + * Minimum gap between detection inferences (ms). The detection loops are driven + * by requestAnimationFrame (~60fps on most displays), but running synchronous ML + * inference on every frame on the MAIN thread caused `[Violation] rAF handler + * took ms` warnings + visible jank during the face step (F10). Capping to + * ~18fps (55ms) keeps the rAF cadence (so cancellation/cleanup is unchanged) but + * SKIPS inference on intervening frames — detection/centering/`captureReady` are + * unaffected (a face is detected within ~1 frame either way), the loop just does + * less main-thread work. Low-risk alternative to a Web Worker / OffscreenCanvas. + */ +const MIN_DETECT_INTERVAL_MS = 55 + export function useFaceDetection( videoRef: React.RefObject, active: boolean, @@ -63,6 +75,9 @@ export function useFaceDetection( // --- MediaPipe FaceDetector fallback state --- const mpDetectorRef = useRef(null) const animFrameRef = useRef(0) + // Timestamp of the last inference, used to cap detection to ~18fps (skip + // inference on intervening rAF frames) — see MIN_DETECT_INTERVAL_MS (F10). + const lastDetectTsRef = useRef(0) const [state, setState] = useState(INITIAL_STATE) const [initialized, setInitialized] = useState(false) const [initFailed, setInitFailed] = useState(false) @@ -199,6 +214,15 @@ export function useFaceDetection( return } + // Rate-cap: skip inference on frames that arrive sooner than the cap, so + // the heavy ML work runs ~18fps instead of every rAF (~60fps) — F10. + const nowTs = performance.now() + if (nowTs - lastDetectTsRef.current < MIN_DETECT_INTERVAL_MS) { + animFrameRef.current = requestAnimationFrame(detectWithFaceLandmarker) + return + } + lastDetectTsRef.current = nowTs + try { const engine = BiometricEngine.getInstance() const faceDetector = engine.faceDetector @@ -312,6 +336,14 @@ export function useFaceDetection( return } + // Rate-cap: skip inference on frames sooner than the cap (~18fps) — F10. + const nowTs = performance.now() + if (nowTs - lastDetectTsRef.current < MIN_DETECT_INTERVAL_MS) { + animFrameRef.current = requestAnimationFrame(detectWithBlazeFace) + return + } + lastDetectTsRef.current = nowTs + try { const result = await blazeFace.detect(video) if (!result || !result.detected || result.faces.length === 0) { @@ -377,6 +409,14 @@ export function useFaceDetection( return } + // Rate-cap: skip inference on frames sooner than the cap (~18fps) — F10. + const nowTs = performance.now() + if (nowTs - lastDetectTsRef.current < MIN_DETECT_INTERVAL_MS) { + animFrameRef.current = requestAnimationFrame(detectWithMediaPipe) + return + } + lastDetectTsRef.current = nowTs + try { const t0 = performance.now() const result = detector.detectForVideo(video, performance.now()) @@ -453,6 +493,9 @@ export function useFaceDetection( if (!active || !initialized) return perfLogCountRef.current = 0 + // Run the first frame immediately when the loop (re)starts; the rate-cap + // only throttles subsequent frames. + lastDetectTsRef.current = 0 if (backend === 'mediapipe-landmarker') { animFrameRef.current = requestAnimationFrame(detectWithFaceLandmarker)