mirror of
https://github.com/hanzoai/id.git
synced 2026-10-16 18:38:15 +00:00
feat(auth): prompt plan payment immediately after login and credential entry
Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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 & 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 —
|
||||
|
||||
Reference in New Issue
Block a user