Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/features/auth/approve-login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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. */
Expand Down
27 changes: 27 additions & 0 deletions src/features/auth/components/ApproveLoginPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
}
Expand All @@ -64,6 +72,7 @@ function formatTime(seconds: number): string {
export default function ApproveLoginPanel({
initialEmail = '',
onApproved,
onMfaRequired,
onCancel,
}: ApproveLoginPanelProps) {
const { t } = useTranslation()
Expand All @@ -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()
Expand Down Expand Up @@ -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') {
Expand Down
62 changes: 44 additions & 18 deletions src/features/auth/components/Layer1Shortcuts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -43,6 +48,12 @@ interface Layer1ShortcutsProps<T> {
*/
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
Expand All @@ -65,6 +76,7 @@ export default function Layer1Shortcuts<T = unknown>({
config,
fallbackAll = false,
onPasskeySuccess,
onPasskeyMfaRequired,
onPasskeyError,
onApproveClick,
onQrClick,
Expand Down Expand Up @@ -96,24 +108,26 @@ export default function Layer1Shortcuts<T = unknown>({
</Divider>
)}

{/* Group 1 — no-typing sign-in (truly usernameless). */}
<Stack spacing={1.5}>
{showPasskey && (
<PasskeyLoginButton<T>
onSuccess={onPasskeySuccess}
onMfaRequired={onPasskeyMfaRequired}
onError={onPasskeyError}
disabled={disabled}
sx={passkeySx}
/>
)}

{showApprove && (
{showQr && (
<Button
fullWidth
variant="outlined"
size="large"
onClick={onApproveClick}
onClick={onQrClick}
disabled={disabled}
startIcon={<PhonelinkLock />}
startIcon={<QrCode2 />}
sx={{
py: 1.5,
borderRadius: '12px',
Expand All @@ -123,18 +137,30 @@ export default function Layer1Shortcuts<T = unknown>({
...passkeySx,
}}
>
{t('approveLogin.button')}
{t('qrLogin.button')}
</Button>
)}
</Stack>

{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 && (
<Stack spacing={0.75} sx={{ mt: 1.5 }}>
<Typography
variant="caption"
color="text.secondary"
sx={{ textAlign: 'center' }}
>
{t('approveLogin.sectionLabel')}
</Typography>
<Button
fullWidth
variant="outlined"
size="large"
onClick={onQrClick}
onClick={onApproveClick}
disabled={disabled}
startIcon={<QrCode2 />}
startIcon={<PhonelinkLock />}
sx={{
py: 1.5,
borderRadius: '12px',
Expand All @@ -144,10 +170,10 @@ export default function Layer1Shortcuts<T = unknown>({
...passkeySx,
}}
>
{t('qrLogin.button')}
{t('approveLogin.buttonWithEmail')}
</Button>
)}
</Stack>
</Stack>
)}
</>
)
}
33 changes: 33 additions & 0 deletions src/features/auth/components/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -601,6 +631,7 @@ export default function LoginPage() {
<CardContent sx={{ p: { xs: 3, sm: 4 } }}>
<ApproveLoginPanel
onApproved={handleApproveLoginApproved}
onMfaRequired={handleLayer1MfaRequired}
onCancel={() => setShowApproveLogin(false)}
/>
</CardContent>
Expand Down Expand Up @@ -642,6 +673,7 @@ export default function LoginPage() {
<CardContent sx={{ p: { xs: 3, sm: 4 } }}>
<QrLoginPanel
onApproved={handleQrLoginApproved}
onMfaRequired={handleLayer1MfaRequired}
onCancel={() => setShowQrLogin(false)}
/>
</CardContent>
Expand Down Expand Up @@ -1286,6 +1318,7 @@ export default function LoginPage() {
config={loginConfig}
fallbackAll
onPasskeySuccess={handlePasskeySuccess}
onPasskeyMfaRequired={handleLayer1MfaRequired}
onPasskeyError={(msg) => setLoginError(msg)}
onApproveClick={() => {
setLoginError(null)
Expand Down
58 changes: 56 additions & 2 deletions src/features/auth/components/PasskeyLoginButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -28,8 +30,21 @@ import {
} from '../passkey-login'

export interface PasskeyLoginButtonProps<T = unknown> {
/** 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). */
Expand All @@ -38,8 +53,25 @@ export interface PasskeyLoginButtonProps<T = unknown> {
sx?: Parameters<typeof Button>[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<T = unknown>({
onSuccess,
onMfaRequired,
onError,
disabled,
sx,
Expand Down Expand Up @@ -68,6 +100,28 @@ export default function PasskeyLoginButton<T = unknown>({
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
Expand All @@ -78,7 +132,7 @@ export default function PasskeyLoginButton<T = unknown>({
} finally {
setBusy(false)
}
}, [busy, supported, httpClient, onSuccess, onError, t])
}, [busy, supported, httpClient, onSuccess, onMfaRequired, onError, t])

const button = (
<Button
Expand Down
Loading
Loading