The MFA screens, walked

Three PRs bring back the emailed code and the authenticator app, add a scannable QR, and put every sign-in method on one page. Until now they were proven by tests; this is what they look like in a browser.

21 Sep 2026 · PRs #10648, #10653, #10656 · run against propflow-auth-stage, never production

The one thing

The screens hold up, and the walkthrough found two things the tests could not. One was a duplicated word on a card, fixed. The other is a pre-existing bug on main: after a password sign-in, an enrolled account is told it has no second factor configured.

How it was run

All three branches merged onto main in a scratch worktree, then a dev server against the stage auth table. Driven with Playwright as smoke@propflowai.co, which was read first and had no MFA of any kind — no OTP row, no passkey, no authenticator — and which was reset back to exactly that state as the last step of the script.

The dedicated mfa-e2e@propflowai.co identity the e2e spec names does not exist in this table, which is why smoke@ was used and why the restore is part of the run rather than an afterthought.

Setting up an authenticator

The MFA setup picker showing four method cards: use a passkey, use an authenticator app, text me a code, email me a code.
Four methods, in order. Passkey recommended; authenticator second because it needs nothing from the outside world; text code third; email last, because for an account that signs in with an emailed code it is the same channel twice.
The authenticator setup screen: a QR code, a secondary deep link, and a collapsed manual key.
The QR is the primary path. The deep link is demoted to “on this phone?”, and the 32-character key folds away behind a toggle for the broken-camera case.

What the walkthrough proved

A code generated the way Google Authenticator generates it actually verifies proven

The script base32-decodes the secret out of the QR’s own URI, HMACs with those bytes, and submits the six digits. The server accepted it and the browser left /setup/mfa for the dashboard.

otpauth://totp/PropFlow:smoke%40propflowai.co?secret=…&issuer=PropFlow&digits=6&period=30
manual key === URI secret         : true
decoded secret is the stored hex  : true
enroll/verify                     : 200

This is the trap the route comments warn about, hit from the client side: passing the base32 text as the HMAC key instead of decoding it first produces codes that never verify — the old desktop-enrollment bug. Decoding is what a real authenticator does, and it works. No algorithm parameter, 6 digits, 30 seconds: the shape every authenticator app handles.

The QR survives dark mode proven

A scanner needs dark modules on a light field. The colours are pinned rather than inherited from the theme, so the white plate holds when the rest of the page goes dark. The failure this prevents would only ever have shown up for dark-mode users, on their own phone, where nobody is watching.

The same authenticator setup screen in dark mode; the QR code still sits on a white plate with its quiet zone intact.
Dark mode, same screen. The plate stays white and the quiet zone survives.

The screen that started this

The original report was a two-factor screen offering nothing but “Tap your passkey”, on a machine that did not have the passkey. The alternatives existed — as one button and three 11px text links declared separately under each form. Here is the same screen now.

Two-factor sign-in with a passkey button and, below a divider titled Other ways to sign in, three bordered rows: email me a code, use your authenticator app, use a backup code.
Passkey account with other methods. One list, one divider, each row saying what it needs.
Two-factor sign-in for a passkey-only account: just the passkey button, no list of alternatives.
Passkey-only account: no list at all. Nothing to offer, so nothing is offered — a broken option spends the one action a locked-out person reaches for.
Two-factor sign-in in code-entry mode, with passkey, authenticator and backup code listed below the divider.
Entering a code. The method you are already using is never offered as a way out of itself, and “Resend code” stays with the form it belongs to rather than joining the list.

Two things the screenshots caught that the tests did not

signin (2fa enabled)     : 200  {"twoFactorRedirect":true,"twoFactorMethods":["totp","otp"]}
GET /api/mfa/methods     : 401  {"error":"Not authenticated"}

The user-visible result is a contradiction: an enrolled account gets a code form saying “Sending you a code now…” directly above a red “No second-factor method is configured. Contact support.”

This is the state of main and none of the three PRs cause it — but it is why the challenge screens above had to be captured with the methods response stubbed. Real users sign in with Google or a magic link, which the page’s own header comment names as the paths it was built for, so this may only affect the password path. Worth checking against production before anyone leans on password sign-in.

What is real in each shot

ShotServerData
Setup picker, light and darkrealReal account, real session
Authenticator QR, light and darkrealReal secret, minted by the route
Enrollment verifyrealReal code, accepted by the server
Challenge screensrealmethods stubbed — see the 401 above

Everything rendered is the real page, real CSS and the real decideMfaMode. In the challenge shots the stub supplies exactly what the server returns for each account shape, because the server cannot be made to return it on the password path.

The three PRs

Branches mfa-email-code-enrollment, mfa-authenticator-app-enrollment and mfa-challenge-one-page-choices, merged onto main in a scratch worktree for this run. The account used was restored to zero MFA at the end.

PropFlow Docs