IAM signals MFA with a STRING in the login response `data`
("RequiredMfa" = org forces MFA, user not enrolled; "NextMfa" = user has
MFA, needs a challenge) — never the `mfa_required` boolean the portal was
checking. So every first login fell through to /onboarding, silently
bypassing 2FA.
parseLoginResponse now branches on `data` BEFORE the /onboarding return:
- RequiredMfa -> { mfaRequired, mfaStage: 'enroll' }
- NextMfa -> { mfaRequired, mfaStage: 'challenge', mfaTypes }
New AuthClient methods drive the flow against the canonical /v1/iam surface:
getAccount, mfaInitiate, mfaVerify, mfaEnable, mfaChallenge.
Wire contract (verified live vs iam.hanzo.ai): the /v1/iam/mfa/setup/*
calls put EVERY param on the query string with an EMPTY body — the only
shape that satisfies both IAM's authz self-match (objOwner/objName are
read from the query only when the body is empty; a urlencoded body fails
the JSON-unmarshal and yields "Unauthorized operation") and the MFA
controller. owner/name ride the query on every call, incl. verify.
UI: MfaEnrollForm renders forced TOTP enrollment — QR from the otpauth://
URI via @paulmillr/qr (secret never leaves the browser), manual-key
fallback, recovery code, and the existing OTPForm for code entry. No skip
control. Login.tsx routes onMfaRequired by stage; the existing OTPForm is
reused for the NextMfa challenge.
Tests: 8 new MFA wire-contract tests (pure, mocked fetch). 14/14 green.