diff --git a/src/features/auth/approve-login.ts b/src/features/auth/approve-login.ts index f411cb9..ef12d6e 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 a54d6c9..c6bad4d 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 d904a4e..1253c73 100644 --- a/src/features/auth/components/Layer1Shortcuts.tsx +++ b/src/features/auth/components/Layer1Shortcuts.tsx @@ -2,14 +2,18 @@ * Layer1Shortcuts — the cross-device sign-in cluster shown on the initial * identity-entry screen, beside the email/password form. * - * Three alternatives to typing a password here: - * • Passkey — discoverable WebAuthn, fully usernameless (no email typed). - * • Approve on another device — number-matching. The ApproveLoginPanel it - * opens collects the email itself, so it lives here as a peer affordance - * (an earlier note removed it for being "identifier-first"; since the panel - * self-collects the email, it's fine as a Layer-1 alternative). - * • Sign in with your phone — cross-device QR scan-to-login. The QrLoginPanel - * it opens needs no identifier (the scanning phone resolves the user). + * Two GROUPS, deliberately separated (F4): + * 1. No-typing sign-in (truly usernameless — no identifier needed): + * • Passkey — discoverable WebAuthn, fully usernameless. + * • Sign in with your phone — cross-device QR scan-to-login; the scanning + * phone resolves the user, so nothing is typed here. + * 2. Approve from another device — number-matching APPROVE_LOGIN. This one is + * genuinely NOT usernameless (the server needs an identifier to route the + * push; the backend set supports_usernameless=false in V74), so it is split + * OUT of the no-typing group under its own labelled section and labelled to + * make the "enter your email" step explicit, instead of sitting silently + * among the no-typing shortcuts where the later email prompt felt out of + * place (the F4 report). * * Approve and QR render only when the caller wires `onApproveClick`/`onQrClick`; * the caller gates them to the initial identity-entry phase. When no login-config @@ -21,6 +25,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 +48,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 +76,7 @@ export default function Layer1Shortcuts({ config, fallbackAll = false, onPasskeySuccess, + onPasskeyMfaRequired, onPasskeyError, onApproveClick, onQrClick, @@ -96,24 +108,26 @@ export default function Layer1Shortcuts({ )} + {/* Group 1 — no-typing sign-in (truly usernameless). */} {showPasskey && ( onSuccess={onPasskeySuccess} + onMfaRequired={onPasskeyMfaRequired} onError={onPasskeyError} disabled={disabled} sx={passkeySx} /> )} - {showApprove && ( + {showQr && ( )} + - {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/features/auth/components/LoginPage.tsx b/src/features/auth/components/LoginPage.tsx index 01e373b..b464e7e 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 ebb764f..b6ec6ac 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 = (