mirror of
https://github.com/hanzoai/id.git
synced 2026-10-16 18:38:15 +00:00
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:
@@ -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*\)/)
|
||||
})
|
||||
})
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user