feat(auth): prompt plan payment immediately after login and credential entry

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
zooqueen
2026-09-29 21:01:31 -07:00
co-authored by Hanzo Dev
parent fe1c30e84f
commit c667b911fc
3 changed files with 59 additions and 52 deletions
+1 -3
View File
@@ -74,7 +74,7 @@ const COLUMN = [
'h1',
'.id-field > span',
'.id-field > label',
'button:not(.id-revealbtn):not(.id-plan)',
'button:not(.id-revealbtn)',
'.id-divider',
'.id-footer-links a',
'.id-brand-footer',
@@ -96,8 +96,6 @@ for (const h of HOSTS) {
test(`${h.host}: email first, then "or", then every other way in, then Create a new account`, async ({ context, page }, info) => {
await serve(context, h)
await page.goto(`https://${h.host}/login`)
await expect(page.locator('.id-plan-prompt')).toBeVisible()
await expect(page.locator('.id-plan')).toHaveCount(3)
await expect(page.locator('[data-provider="phone"]')).toBeVisible()
await expect(page.locator('a[href^="/signup"]')).toBeVisible()
await expect(page).toHaveTitle(h.title)
+56
View File
@@ -0,0 +1,56 @@
import { existsSync } from 'node:fs'
import { extname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { expect, test } from '@playwright/test'
const DIST = fileURLToPath(new URL('../dist/', import.meta.url))
const ADA = { owner: 'ada', name: 'ada', id: 'u-1', displayName: 'Ada Lovelace', email: '[email protected]' }
const PLANS = [
{ slug: 'pro', name: 'Pro', price: 1900, category: 'personal', popular: true, description: 'For developers & creators. Full AI model suite.' },
{ slug: 'team', name: 'Team', price: 2500, category: 'team', limits: { minSeats: 2 }, perSeat: true, description: 'For teams building together with shared workspaces.' },
]
test('onboarding opens immediately on Choose how you pay step with plan choices', async ({ context, page }) => {
await context.route('**/*', (route) => {
const url = new URL(route.request().url())
if (url.hostname === 'cdn.jsdelivr.net') return route.continue()
if (url.pathname.startsWith('/v1/iam/account')) return route.fulfill({ json: { status: 'ok', data: ADA } })
if (url.pathname.startsWith('/v1/iam/preferences')) return route.fulfill({ json: { status: 'ok', data: {} } })
if (url.pathname.startsWith('/v1/billing/plans')) return route.fulfill({ json: PLANS })
if (url.pathname.startsWith('/v1/iam/auth/application')) {
return route.fulfill({
json: {
status: 'ok',
data: {
owner: 'admin',
name: 'hanzo-console',
organization: 'hanzo',
enablePassword: true,
enableCodeSignin: true,
enableSignUp: true,
providers: [],
},
},
})
}
const file = join(DIST, url.pathname)
return route.fulfill({ path: extname(url.pathname) && existsSync(file) ? file : join(DIST, 'index.html') })
})
await page.goto('https://hanzo.id/onboarding')
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Choose how you pay')
await expect(page.getByText('Pick a plan, or pay as you go with a prepaid balance.')).toBeVisible()
await expect(page.locator('.id-plan')).toHaveCount(3)
await expect(page.locator('.id-plan').filter({ hasText: 'Pro' })).toBeVisible()
await expect(page.locator('.id-plan').filter({ hasText: 'Team' })).toBeVisible()
await expect(page.locator('.id-plan').filter({ hasText: 'Pay as you go' })).toBeVisible()
// Clicking Pro saves preference and navigates to pay cart
await page.locator('.id-plan').filter({ hasText: 'Pro' }).click()
await page.waitForURL((url) => url.hostname === 'pay.hanzo.ai' && url.pathname.includes('/cart'))
const payUrl = new URL(page.url())
expect(payUrl.searchParams.get('plan')).toBe('pro')
expect(payUrl.searchParams.get('returnUrl')).toBe('https://hanzo.id/onboarding')
})
+2 -49
View File
@@ -96,7 +96,6 @@ export function Login({ client, brand }: { client: AuthClient; brand: Brand }) {
const [refused, setRefused] = useState<string | null>(null)
const [challengeError, setChallengeError] = useState<string | null>(null)
const challengeErrorId = useId()
const [selectedPlan, setSelectedPlan] = useState<string>(sp.get('plan') || 'pro')
const clientId = clientIdOverride ?? client.org.clientId
@@ -140,7 +139,7 @@ export function Login({ client, brand }: { client: AuthClient; brand: Brand }) {
// The credential check succeeded (or MFA was satisfied). For a downstream
// OIDC request, re-enter authorize with the now-established IAM session so it
// mints the code; for a bare portal sign-in, land directly on checkout for the plan.
// mints the code; for a bare portal sign-in, land on onboarding to prompt the plan.
async function completeAfterAuth() {
// A wallet refused earlier for having no account attaches to the account
// that just satisfied its factor.
@@ -153,18 +152,8 @@ export function Login({ client, brand }: { client: AuthClient; brand: Brand }) {
codeChallenge,
codeChallengeMethod,
})
} else if (back) {
window.location.href = back
} else {
const plan = sp.get('plan') || selectedPlan || 'pro'
const payUrl = client.org.payUrl || 'https://pay.hanzo.ai'
const q = new URLSearchParams({ returnUrl: `${window.location.origin}/onboarding` })
if (plan === 'payg') {
window.location.replace(`${payUrl}/onboard?${q}`)
} else {
q.set('plan', plan)
window.location.replace(`${payUrl}/cart?${q}`)
}
window.location.href = back ?? '/onboarding'
}
}
@@ -305,42 +294,6 @@ export function Login({ client, brand }: { client: AuthClient; brand: Brand }) {
identity arrives through a provider under the rule, which IAM
provisions wherever the application allows sign-up.
No brand in the heading: the mark top-left says whose sign-in this is. */}
{!redirectUri && (
<div className="id-plan-prompt">
<h2 className="id-plan-prompt-title">Choose your plan</h2>
<div className="id-plans" role="list">
<button
type="button"
className={selectedPlan === 'pro' ? 'id-plan popular selected' : 'id-plan popular'}
onClick={() => setSelectedPlan('pro')}
>
<span className="id-plan-badge">Popular</span>
<span className="id-plan-name">Pro</span>
<span className="id-plan-price">$19/mo</span>
<span className="id-plan-desc">For developers &amp; creators. Full AI model suite.</span>
</button>
<button
type="button"
className={selectedPlan === 'team' ? 'id-plan selected' : 'id-plan'}
onClick={() => setSelectedPlan('team')}
>
<span className="id-plan-name">Team</span>
<span className="id-plan-price">$25 per seat/mo · minimum 2 seats</span>
<span className="id-plan-desc">For teams building together with shared workspaces.</span>
</button>
<button
type="button"
className={selectedPlan === 'payg' ? 'id-plan payg selected' : 'id-plan payg'}
onClick={() => setSelectedPlan('payg')}
>
<span className="id-plan-name">Pay as you go</span>
<span className="id-plan-price">Prepaid balance · $5 minimum</span>
<span className="id-plan-desc">No subscription. Pay only for what you use.</span>
</button>
</div>
<div className="id-plan-prompt-divider" />
</div>
)}
<h1>Sign in</h1>
{/* THE CREDENTIAL LEADS: email or username, password, Continue, the code
switch. Then "or", then Google, GitHub, the wallet and the phone —