diff --git a/src/features/auth/approve-login.ts b/src/features/auth/approve-login.ts
index f411cb9..e205ac1 100644
--- a/src/features/auth/approve-login.ts
+++ b/src/features/auth/approve-login.ts
@@ -51,6 +51,24 @@ export interface ApproveLoginPoll {
refreshToken?: string
expiresIn?: number
role?: string
+ /**
+ * Multi-step bridge (Contract A). When the approved Layer-1 factor belongs to
+ * a MULTI-STEP tenant flow, the session is APPROVED but NOT complete: there
+ * are remaining MFA steps. In that case the poll carries the MFA session
+ * handoff instead of tokens, so the web can continue into the remaining steps
+ * (the method picker) rather than dead-ending at "extra step needed".
+ * - `mfaRequired` — true when remaining steps exist.
+ * - `mfaSessionToken` — the session token to drive `/auth/mfa/step`.
+ * - `currentStep` / `totalSteps` — backend-authoritative step counts.
+ * - `availableMethods` — AuthMethodType enum NAME strings for the NEXT step.
+ * Only present when the config-driven engine is ON for the tenant
+ * (APP_AUTH_CONFIG_DRIVEN_LOGIN[_TENANTS]) — that flag is the kill-switch.
+ */
+ mfaRequired?: boolean
+ mfaSessionToken?: string
+ currentStep?: number
+ totalSteps?: number
+ availableMethods?: string[]
}
/** 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..9d74a2e 100644
--- a/src/features/auth/components/ApproveLoginPanel.tsx
+++ b/src/features/auth/components/ApproveLoginPanel.tsx
@@ -47,6 +47,20 @@ export interface ApproveLoginPanelProps {
initialEmail?: string
/** Called with tokens when the request is APPROVED on the other device. */
onApproved: (result: ApproveLoginResult) => void
+ /**
+ * Multi-step bridge (Contract A). Called when the request is APPROVED on the
+ * other device but the tenant's flow has REMAINING MFA steps — so instead of
+ * tokens the poll returns an MFA session handoff. The caller seeds its MFA
+ * flow from this and continues into the method picker, rather than
+ * dead-ending at "extra step needed". When unset, the panel keeps its
+ * current behaviour (a multi-step approval simply never resolves here).
+ */
+ onMfaPending?: (p: {
+ mfaSessionToken: string
+ currentStep?: number
+ totalSteps?: number
+ availableMethods?: string[]
+ }) => void
/** Dismiss the panel (back to the main login form). */
onCancel: () => void
}
@@ -64,6 +78,7 @@ function formatTime(seconds: number): string {
export default function ApproveLoginPanel({
initialEmail = '',
onApproved,
+ onMfaPending,
onCancel,
}: ApproveLoginPanelProps) {
const { t } = useTranslation()
@@ -80,6 +95,11 @@ export default function ApproveLoginPanel({
const onApprovedRef = useRef(onApproved)
onApprovedRef.current = onApproved
+ // Same for the multi-step-bridge callback (Contract A), so the polling effect
+ // doesn't have to depend on it.
+ const onMfaPendingRef = useRef(onMfaPending)
+ onMfaPendingRef.current = onMfaPending
+
const handleStart = useCallback(async () => {
const trimmed = email.trim()
if (!trimmed) {
@@ -135,6 +155,26 @@ export default function ApproveLoginPanel({
if (cancelled) return
setError(null)
+ // Multi-step bridge (Contract A): APPROVED on the other device but the
+ // tenant flow has REMAINING MFA steps, so the poll carries an MFA
+ // session handoff instead of tokens. Hand it to the caller (which seeds
+ // its MFA flow and continues into the method picker) and stop here —
+ // before the token branch, since a pending response has no accessToken.
+ if (
+ poll.status === 'APPROVED' &&
+ poll.mfaRequired &&
+ poll.mfaSessionToken &&
+ onMfaPendingRef.current
+ ) {
+ onMfaPendingRef.current({
+ mfaSessionToken: poll.mfaSessionToken,
+ currentStep: poll.currentStep,
+ totalSteps: poll.totalSteps,
+ availableMethods: poll.availableMethods,
+ })
+ return
+ }
+
if (poll.status === 'APPROVED' && poll.accessToken) {
onApprovedRef.current({
accessToken: poll.accessToken,
diff --git a/src/features/auth/components/LoginPage.tsx b/src/features/auth/components/LoginPage.tsx
index dd697d8..db0488e 100644
--- a/src/features/auth/components/LoginPage.tsx
+++ b/src/features/auth/components/LoginPage.tsx
@@ -516,6 +516,50 @@ export default function LoginPage() {
[completeTokenLogin],
)
+ // Multi-step bridge (Contract A) — dashboard surface. The approve-login was
+ // approved on the other device but the tenant flow has REMAINING MFA steps, so
+ // the panel handed back an MFA session instead of tokens. Seed the same
+ // method-picker / TwoFactorDispatcher state the password & identifier-first
+ // paths use (mfaSessionToken + availableMethods + completed/step counts) and
+ // route into the method picker, so the phone-approved login CONTINUES into the
+ // remaining steps instead of dead-ending. Mirrors `onSubmit`'s twoFactorRequired
+ // branch. Methods arrive as AuthMethodType NAME strings → AvailableMfaMethod[].
+ const handleApproveLoginMfaPending = useCallback(
+ (p: { mfaSessionToken: string; currentStep?: number; totalSteps?: number; availableMethods?: string[] }) => {
+ setLoginError(null)
+ setShowApproveLogin(false)
+ setMfaSessionToken(p.mfaSessionToken)
+ if (p.totalSteps) setFlowTotalSteps(p.totalSteps)
+ // The approved usernameless factor satisfied Layer 1; the server is
+ // authoritative on completed methods, but we have only step counts here,
+ // so seed an empty completed set and let the next /auth/mfa/step response
+ // reaffirm it (parity with the identifier-first path).
+ setCompletedMfaMethods([])
+ const methods: AvailableMfaMethod[] = (p.availableMethods ?? []).map((m) => ({
+ methodType: m,
+ name: m,
+ category: '',
+ enrolled: true,
+ preferred: false,
+ requiresEnrollment: false,
+ }))
+ setAvailableMethods(methods)
+ const enrolled = methods.filter((m) => m.enrolled)
+ if (enrolled.length > 1) {
+ setShowMethodPicker(true)
+ } else if (enrolled.length === 1) {
+ setTwoFactorMethod(enrolled[0].methodType)
+ setSelectedMethod(enrolled[0].methodType)
+ setShowSecondaryAuth(true)
+ } else {
+ // No methods reported — show the picker (it renders the empty/“set up”
+ // hint) rather than guessing a factor.
+ 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
@@ -580,6 +624,7 @@ export default function LoginPage() {
setShowApproveLogin(false)}
/>
diff --git a/src/verify-app/HostedLoginApp.tsx b/src/verify-app/HostedLoginApp.tsx
index 8b4c958..fa49157 100644
--- a/src/verify-app/HostedLoginApp.tsx
+++ b/src/verify-app/HostedLoginApp.tsx
@@ -261,6 +261,18 @@ export default function HostedLoginApp() {
// can be active at a time; `null` means the default email/password+MFA flow.
const [altFlow, setAltFlow] = useState<'approveLogin' | null>(null)
const [altError, setAltError] = useState(null)
+ // Multi-step bridge (Contract A). When an approve-login is approved on another
+ // device but the tenant flow has REMAINING MFA steps, the panel hands back an
+ // MFA session (not tokens). We stash it here and feed it to LoginMfaFlow as
+ // `resumeSession`, which seeds its MFA state and jumps to the method picker so
+ // the phone-approved login CONTINUES into the remaining steps. `null` ⇒ no
+ // resume in flight (the default password/MFA flow).
+ const [resumeSession, setResumeSession] = useState<{
+ mfaSessionToken: string
+ currentStep?: number
+ totalSteps?: number
+ availableMethods?: string[]
+ } | null>(null)
// Whether the inner LoginMfaFlow is on its OPENING identity-entry screen.
// The usernameless shortcuts (passkey / approve) are ALTERNATIVES to typing
// an identifier, so — mirroring the dashboard's `onInitialIdentityEntry`
@@ -601,6 +613,41 @@ export default function HostedLoginApp() {
[handleLoginComplete],
)
+ // Multi-step bridge (Contract A). The approve-login was approved on the other
+ // device, but the tenant flow has REMAINING MFA steps, so the panel handed back
+ // an MFA session instead of tokens. Close the approve-login surface and stash
+ // the handoff in `resumeSession`; LoginMfaFlow consumes it (`resumeSession`
+ // prop) to seed its MFA state and continue into the method picker for the next
+ // step — instead of the user dead-ending at "extra step needed, continue here".
+ const handleApproveLoginMfaPending = useCallback(
+ (p: { mfaSessionToken: string; currentStep?: number; totalSteps?: number; availableMethods?: string[] }) => {
+ setAltError(null)
+ setAltFlow(null)
+ setResumeSession(p)
+ },
+ [],
+ )
+
+ // Identifier-first preflight propagation. The INITIAL `loginConfig` above was
+ // resolved by OAuth `clientId`, which maps to the OAuth client's bound tenant
+ // (the dashboard/mobile client is on the `system` sentinel tenant) — so before
+ // the user types an email the page previews the SYSTEM flow. When the user
+ // submits their identifier, `LoginMfaFlow` calls `/auth/login/preflight`, which
+ // returns the USER's ACTUAL tenant login-config; we swap the displayed config to
+ // it so the step list / methods / branding shown from that point match the user's
+ // real tenant (the backend already enforces that flow at /auth/login — this only
+ // fixes the DISPLAY mismatch). The backend keeps unknown emails enumeration-safe
+ // by returning a platform-default-looking config, and `LoginMfaFlow` only fires
+ // this with a non-null resolved config, so a preflight error / older API leaves
+ // the displayed config untouched (fallback preserved). Gated to the opening
+ // identity-entry phase so we never re-shape a flow the user has already started.
+ const handlePreflightResolved = useCallback(
+ (resolved: LoginConfig) => {
+ if (onInitialLoginPhase) setLoginConfig(resolved)
+ },
+ [onInitialLoginPhase],
+ )
+
const handleCancel = useCallback(() => {
// Best-effort: return user to the origin of the redirect URI.
if (config.redirectUri) {
@@ -852,6 +899,7 @@ export default function HostedLoginApp() {
) : altFlow === 'approveLogin' ? (
{
setAltError(null)
setAltFlow(null)
@@ -870,7 +918,9 @@ export default function HostedLoginApp() {
onComplete={handleLoginComplete}
onCancel={handleCancel}
onInitialPhaseChange={setOnInitialLoginPhase}
+ onPreflightResolved={handlePreflightResolved}
loginConfig={loginConfig}
+ resumeSession={resumeSession}
/>
{/* Config-driven usernameless shortcuts (G-web).
diff --git a/src/verify-app/LoginMfaFlow.tsx b/src/verify-app/LoginMfaFlow.tsx
index 3e716ca..0d3ab63 100644
--- a/src/verify-app/LoginMfaFlow.tsx
+++ b/src/verify-app/LoginMfaFlow.tsx
@@ -75,9 +75,41 @@ interface LoginMfaFlowProps {
* legacy behaviour (always start on the password step).
*/
loginConfig?: LoginConfig | null
+ /**
+ * Identifier-first preflight propagation. The hosted page's INITIAL config
+ * is resolved by OAuth `clientId`, which maps to the client's bound tenant
+ * (the dashboard/mobile client is on the `system` sentinel tenant) — so the
+ * page previews the SYSTEM flow until the user enters an email. When the user
+ * submits their identifier we call `/auth/login/preflight`, which returns the
+ * USER's ACTUAL tenant login-config; this callback hands that resolved config
+ * up to the shell (HostedLoginApp) so the displayed flow (step list / methods
+ * / branding) switches to the user's real tenant from that point on.
+ *
+ * Only fired with a non-null resolved config (an older API returning only
+ * `{eligible}`, or any failure, yields null → the displayed config is left
+ * unchanged, preserving the fallback). The backend keeps unknown emails
+ * enumeration-safe by returning a platform-default-looking config.
+ */
+ onPreflightResolved?: (loginConfig: LoginConfig) => void
+ /**
+ * Multi-step bridge (Contract A). A usernameless Layer-1 factor approved on
+ * another device (APPROVE_LOGIN / QR_CODE) for a MULTI-STEP tenant flow hands
+ * back an MFA session — NOT tokens — because remaining steps follow. The shell
+ * passes that handoff here so this flow RESUMES into the MFA leg (the method
+ * picker for the next step) instead of the user dead-ending at "extra step
+ * needed, continue here". `null`/undefined ⇒ no resume (unchanged behaviour).
+ * Seeded into MFA state exactly once, and only while no MFA session is already
+ * open, so it can never yank a user out of an in-progress flow.
+ */
+ resumeSession?: {
+ mfaSessionToken: string
+ currentStep?: number
+ totalSteps?: number
+ availableMethods?: string[]
+ } | null
}
-export default function LoginMfaFlow({ clientId, onComplete, onCancel, onStepChange, onInitialPhaseChange, loginConfig }: LoginMfaFlowProps) {
+export default function LoginMfaFlow({ clientId, onComplete, onCancel, onStepChange, onInitialPhaseChange, loginConfig, onPreflightResolved, resumeSession }: LoginMfaFlowProps) {
const { t } = useTranslation()
const authRepository = useService(TYPES.AuthRepository)
const httpClient = useService(TYPES.HttpClient)
@@ -209,6 +241,42 @@ export default function LoginMfaFlow({ clientId, onComplete, onCancel, onStepCha
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loginConfig])
+ // ─── Multi-step bridge resume (Contract A) ──────────────────
+ //
+ // When a usernameless Layer-1 factor (APPROVE_LOGIN / QR_CODE) is approved on
+ // another device for a MULTI-STEP tenant, the shell hands us the resulting MFA
+ // session via `resumeSession`. Seed the MFA state from it and jump straight to
+ // the method picker for the next step — mirroring how `handleMfaResult` seeds
+ // the same state on STEP_COMPLETED. This is what turns "approved on phone but
+ // web dead-ends at 'extra step needed'" into a continuing flow.
+ //
+ // Guards: seed ONCE (ref latch) and ONLY when no MFA session is already open,
+ // so a resume can never pull a user out of an in-progress flow.
+ const resumeSyncedRef = useRef(false)
+ useEffect(() => {
+ if (resumeSyncedRef.current) return
+ if (!resumeSession || !resumeSession.mfaSessionToken) return
+ if (mfaSessionToken) return // already in an MFA flow — never interrupt it
+ resumeSyncedRef.current = true
+
+ setMfaSessionToken(resumeSession.mfaSessionToken)
+ const methods: AvailableMfaMethod[] = (resumeSession.availableMethods ?? []).map(
+ (m) => ({
+ methodType: m,
+ name: m,
+ category: '',
+ enrolled: true,
+ preferred: false,
+ requiresEnrollment: false,
+ }),
+ )
+ setAvailableMethods(methods)
+ if (resumeSession.currentStep) setCurrentStep(resumeSession.currentStep)
+ if (resumeSession.totalSteps) setTotalSteps((prev) => Math.max(prev, resumeSession.totalSteps!))
+ setPhase(FlowPhase.MethodPicker)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [resumeSession])
+
// ─── Password Submit ────────────────────────────────────────
const handlePasswordSubmit = useCallback(async (data: { email: string; password: string }) => {
@@ -307,7 +375,18 @@ export default function LoginMfaFlow({ clientId, onComplete, onCancel, onStepCha
setLoading(true)
setError(undefined)
try {
- await authRepository.checkLoginEligibility(identifier.trim(), clientId || undefined)
+ // The hosted page's INITIAL config was resolved by OAuth clientId
+ // → the client's bound tenant (typically the `system` sentinel),
+ // so the previewed flow may be the SYSTEM flow, not the user's.
+ // The preflight resolves the USER's ACTUAL tenant login-config;
+ // hand it up so the displayed flow switches to the user's tenant.
+ // A null result (older API / failure) leaves the displayed config
+ // unchanged — fallback preserved.
+ const resolved = await authRepository.checkLoginEligibility(
+ identifier.trim(),
+ clientId || undefined,
+ )
+ if (resolved && onPreflightResolved) onPreflightResolved(resolved)
setPhase(FlowPhase.Password)
} catch (err) {
// TENANT_MISMATCH (or any pre-flight error) is shown inline on the
@@ -351,7 +430,7 @@ export default function LoginMfaFlow({ clientId, onComplete, onCancel, onStepCha
} finally {
setLoading(false)
}
- }, [authRepository, identifier, clientId, configLayer1Methods, engineActive, passwordIsLayer1, layer1IsChoice, t])
+ }, [authRepository, identifier, clientId, configLayer1Methods, engineActive, passwordIsLayer1, layer1IsChoice, onPreflightResolved, t])
// ─── Method Selection ───────────────────────────────────────
diff --git a/src/verify-app/__tests__/LoginMfaFlow.config.test.tsx b/src/verify-app/__tests__/LoginMfaFlow.config.test.tsx
index 8d2175c..928a4c1 100644
--- a/src/verify-app/__tests__/LoginMfaFlow.config.test.tsx
+++ b/src/verify-app/__tests__/LoginMfaFlow.config.test.tsx
@@ -127,6 +127,75 @@ describe('LoginMfaFlow — config-driven Layer 1', () => {
expect(mockLogin).not.toHaveBeenCalled()
})
+ it('propagates the RESOLVED tenant login-config from the preflight to onPreflightResolved (identifier-first display fix)', async () => {
+ // The hosted page's INITIAL config is resolved by OAuth clientId → the
+ // client's bound (system) tenant. The preflight returns the USER's ACTUAL
+ // tenant login-config; LoginMfaFlow must hand it UP so the shell can swap
+ // the displayed flow to the user's real tenant.
+ const initialConfig = normalizeLoginConfig({
+ engineActive: true,
+ layer1: { identifierRequired: true, methods: [{ type: 'PASSWORD' }] },
+ })
+ // Resolved (user's real tenant) config returned by /auth/login/preflight.
+ const resolvedConfig = normalizeLoginConfig({
+ engineActive: true,
+ tenantName: 'Marmara University',
+ totalSteps: 3,
+ layer1: { identifierRequired: true, methods: [{ type: 'PASSWORD' }] },
+ })
+ mockCheckEligibility.mockResolvedValue(resolvedConfig)
+ const onPreflightResolved = vi.fn()
+
+ render(
+ ,
+ )
+ fireEvent.change(screen.getByLabelText('Email Address'), { target: { value: 'staff@marmara.edu.tr' } })
+ fireEvent.click(screen.getByRole('button', { name: /continue/i }))
+
+ await waitFor(() => {
+ expect(onPreflightResolved).toHaveBeenCalledWith(resolvedConfig)
+ })
+ // The password screen is still revealed (login is not broken).
+ await waitFor(() => {
+ expect(screen.getByLabelText('Password')).toBeInTheDocument()
+ })
+ })
+
+ it('does NOT call onPreflightResolved when the preflight returns null (older API / failure → display unchanged)', async () => {
+ // Enumeration-safety + reversibility: a null resolved config (older API
+ // returning only {eligible}, or a normalize failure) must leave the
+ // displayed config untouched and still reveal the password screen.
+ const initialConfig = normalizeLoginConfig({
+ engineActive: true,
+ layer1: { identifierRequired: true, methods: [{ type: 'PASSWORD' }] },
+ })
+ mockCheckEligibility.mockResolvedValue(null)
+ const onPreflightResolved = vi.fn()
+
+ render(
+ ,
+ )
+ fireEvent.change(screen.getByLabelText('Email Address'), { target: { value: 'unknown@example.com' } })
+ fireEvent.click(screen.getByRole('button', { name: /continue/i }))
+
+ await waitFor(() => {
+ expect(mockCheckEligibility).toHaveBeenCalled()
+ })
+ // Null resolved config ⇒ no propagation; password screen still revealed.
+ await waitFor(() => {
+ expect(screen.getByLabelText('Password')).toBeInTheDocument()
+ })
+ expect(onPreflightResolved).not.toHaveBeenCalled()
+ })
+
it('shows the tenant-mismatch error on the EMAIL step and does NOT reveal the password screen', async () => {
mockCheckEligibility.mockRejectedValue(
Object.assign(new Error('tenant'), {