mirror of
https://github.com/hanzoai/billing.git
synced 2026-10-30 18:46:54 +00:00
`?plan=<slug>` is what every "Get started" on the marketing site links to, and the grid now continues that choice through the same door a click uses: the tab the plan lives on, its price from the catalog, and the hop to the Pay card form. It cannot charge on its own. A click may charge a card already on file in one step; a plan carried in on a link stops at the card form, because arriving somewhere is not consent to be charged. Sign-in keeps it too — startLogin remembers the query and hash to come back to, and the callback lands there instead of at the bare page. Only a suffix is ever stored, so there is no path or host in it to point somewhere else. The CTA on the commerce page names its own tier rather than dropping it, and `make test` runs the suite the repo actually has.
78 lines
3.1 KiB
TypeScript
78 lines
3.1 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useRef, useState } from 'react'
|
|
import { BASE_PATH, getOrgByHost } from '@/lib/config'
|
|
import { makeAuthConf } from '@/lib/auth'
|
|
import { handleCallback, startLogin, forgetTokens, resumed } from '@/lib/iam-auth'
|
|
|
|
// One-shot retry flag — survives the authorize round-trip via sessionStorage so
|
|
// a transient first-attempt failure self-heals silently but can never loop.
|
|
const RETRY_KEY = 'iam-callback-retry'
|
|
|
|
export default function CallbackPage() {
|
|
const [error, setError] = useState<string | null>(null)
|
|
// Guard against the effect running more than once (StrictMode double-invoke
|
|
// or a re-render) — handleCallback consumes the one-time auth code, so a
|
|
// second run would otherwise fail.
|
|
const ran = useRef(false)
|
|
|
|
useEffect(() => {
|
|
if (ran.current) return
|
|
ran.current = true
|
|
|
|
const org = getOrgByHost(window.location.host)
|
|
const conf = makeAuthConf(org)
|
|
|
|
handleCallback(conf)
|
|
.then(() => {
|
|
sessionStorage.removeItem(RETRY_KEY)
|
|
// Back to the spot sign-in started from — a plan picked before signing
|
|
// in is still the plan afterwards.
|
|
window.location.href = `${BASE_PATH}/${resumed()}`
|
|
})
|
|
.catch((err) => {
|
|
const msg = err instanceof Error ? err.message : 'Authentication failed'
|
|
// A consumed/stale authorization code or a lost PKCE verifier is a
|
|
// transient first-attempt failure that a fresh authorize round-trip
|
|
// resolves. Recover automatically ONCE (guarded so it cannot loop) by
|
|
// clearing the stale PKCE/token state and restarting sign-in, so the
|
|
// user sees a brief spinner instead of a spurious "Authentication Error".
|
|
const transient = /invalid_grant|authorization code|code[_ ]?verifier|expired/i.test(msg)
|
|
if (transient && !sessionStorage.getItem(RETRY_KEY)) {
|
|
sessionStorage.setItem(RETRY_KEY, '1')
|
|
forgetTokens() // stale token + spent PKCE/state, so the retry starts clean
|
|
startLogin(conf).catch(() => setError(msg))
|
|
return
|
|
}
|
|
sessionStorage.removeItem(RETRY_KEY)
|
|
setError(msg)
|
|
})
|
|
}, [])
|
|
|
|
if (error) {
|
|
return (
|
|
<main className="flex min-h-screen items-center justify-center bg-bg p-8">
|
|
<div className="max-w-md rounded-2xl border border-border bg-bg-card p-8 text-center">
|
|
<h1 className="text-lg font-semibold text-text">Authentication Error</h1>
|
|
<p className="mt-3 text-sm text-text-muted">{error}</p>
|
|
<a
|
|
href="/"
|
|
className="mt-6 inline-block rounded-lg bg-brand px-4 py-2 text-sm font-medium text-brand-foreground transition hover:bg-brand-hover"
|
|
>
|
|
Return to billing
|
|
</a>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<main className="flex min-h-screen items-center justify-center bg-bg">
|
|
<div className="text-center">
|
|
<div className="mx-auto h-6 w-6 animate-spin rounded-full border-2 border-text-dim border-t-brand" />
|
|
<p className="mt-4 text-sm text-text-muted">Completing sign in...</p>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|