fix(login): honor a registration hint, so the signup funnel reaches signup

hanzo.app's "Get started" forwards `signup=true` on the authorize request and
this page ignored it. Measured end to end: the CTA lands on "Sign in to Hanzo ID"
with empty credentials, and appending `&signup=true` to the authorize URL by hand
changes nothing. Every net-new customer had to notice the small "Create account"
link to get past a form they could not fill in. The app was doing its part; the
IdP dropped the hint.

`screen_hint=signup` is honored too — the OIDC-standard spelling of the same
request, so a compliant client works without knowing our name for it.

It LOSES to silent SSO, and that ordering is the point. A browser already
carrying an issuer session belongs to someone who HAS an account, and sending
them to registration is worse than ignoring the hint. So this sits in `fallback`,
where `provider_hint` already sits, and only decides what happens when there is
no session to mint from.

The navigation carries the whole search string. Registration is its own page, and
client_id, redirect_uri, state and the PKCE challenge live in that query — a bare
`/signup` strands the new account with nowhere to return to. `replace` rather
than `assign` so Back reaches whatever sent the user here, not a login page that
would bounce forward again.

Both invariants are mutation-verified: short-circuit silent SSO and the ordering
test fails; drop the search string and the round-trip test fails.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
zooqueen
2026-08-06 02:37:17 -07:00
co-authored by Hanzo Dev
parent 0504174fc9
commit ee85e1fead
2 changed files with 69 additions and 3 deletions
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
/**
* The signup funnel has to reach signup.
*
* hanzo.app's "Get started" forwards `signup=true` on the authorize request and
* this page ignored it, so a net-new customer landed on "Sign in to Hanzo ID"
* with empty credentials and had to spot the small "Create account" link. The
* app was already doing its part; the IdP dropped the hint.
*
* Source text rather than a mount: the assertions are about which BRANCH exists
* and its ordering against silent SSO, and a render test would need the whole
* auth client stubbed to say much less.
*/
const src = readFileSync(join(__dirname, 'Login.tsx'), 'utf8')
describe('Login honors a registration hint', () => {
it('reads both spellings of the hint', () => {
expect(src).toMatch(/sp\.get\('signup'\)\s*===\s*'true'/)
// The OIDC-standard spelling, so a compliant client works without knowing ours.
expect(src).toMatch(/sp\.get\('screen_hint'\)\s*===\s*'signup'/)
})
it('sends the whole OIDC request across, or registration cannot return the user', () => {
// client_id, redirect_uri, state and the PKCE challenge live in the search
// string; a bare '/signup' strands the new account with nowhere to go back to.
expect(src).toMatch(/replace\(`\/signup\$\{window\.location\.search\}`\)/)
})
it('loses to silent SSO instead of short-circuiting it', () => {
// Someone whose browser already holds an issuer session HAS an account.
// The hint must sit in the fallback, so `canSilent` still wins the initial
// phase — sending a returning customer to registration is worse than
// ignoring the hint entirely.
expect(src).toMatch(/const fallback = providerHint \? 'federate' : wantsSignup \? 'register' : 'form'/)
expect(src).toMatch(/useState<[^>]*>\(\s*canSilent \? 'silent' : fallback,?\s*\)/)
})
})
+29 -3
View File
@@ -36,9 +36,23 @@ export function Login({ client, brand }: { client: AuthClient; brand: BrandContr
// hinted provider if one was named, else the interactive form. A bare portal
// visit (no client_id/redirect_uri) has nowhere to redirect, so it shows the
// form immediately as before.
// A caller that sent the user here to REGISTER should get registration.
// hanzo.app's "Get started" forwards `signup=true` and this page ignored it,
// so every net-new customer met a sign-in form with empty credentials and had
// to notice the small "Create account" link to get past it — the signup funnel
// never reached signup. `screen_hint=signup` is the OIDC-standard spelling of
// the same request, so both are honored.
//
// It LOSES to silent SSO, deliberately. A browser already holding an issuer
// session belongs to someone who has an account, and sending them to
// registration would be worse than ignoring the hint. So it is the fallback
// when there is no session, never a short-circuit ahead of one.
const wantsSignup = sp.get('signup') === 'true' || sp.get('screen_hint') === 'signup'
const canSilent = !!clientIdOverride && !!redirectUri
const fallback = providerHint ? 'federate' : 'form'
const [phase, setPhase] = useState<'silent' | 'federate' | 'form'>(canSilent ? 'silent' : fallback)
const fallback = providerHint ? 'federate' : wantsSignup ? 'register' : 'form'
const [phase, setPhase] = useState<'silent' | 'federate' | 'form' | 'register'>(
canSilent ? 'silent' : fallback,
)
// null = show the credential form; otherwise IAM returned an MFA signal and
// we render the matching step instead of navigating on.
@@ -95,7 +109,19 @@ export function Login({ client, brand }: { client: AuthClient; brand: BrandContr
}
}
if (phase === 'silent') {
// Registration lives on its own page, so this is a real navigation rather than
// a branch in the render. `replace`, not `assign`: Back from the signup form
// must return to whatever sent the user here, not to a login page that would
// immediately bounce forward again. The whole search string travels — the
// client_id, redirect_uri, state and PKCE challenge are what let registration
// return the new user to the app that asked for them.
useEffect(() => {
if (phase === 'register') {
window.location.replace(`/signup${window.location.search}`)
}
}, [phase])
if (phase === 'silent' || phase === 'register') {
return (
<div className="hanzo-id-page hanzo-id-login">
<BrandHeader brand={brand} />