mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
Try Hanzo opens /signup directly instead of /chat bouncing a stranger there, which cost a whole page load on a phone (13 s throttled). /login no longer sends a new address to /signup: both pages send the link at once and make the account once the code proves the address, so the person stays on the page they chose. /login, /signup and /auth/callback preconnect to api.hanzo.ai from the head, so the methods read, the token exchange and the discovery a provider press needs start on a warm connection. A member at /login is marked before first paint (the app's BOOT under data-member), the form stays off screen while the page moves them on, and the panel asks IAM for no sign-in methods it would never draw. The callback starts the bind and the standing read together instead of one after the other.
293 lines
14 KiB
TypeScript
293 lines
14 KiB
TypeScript
'use client'
|
||
|
||
/**
|
||
* The return from Hanzo IAM. The @hanzo/iam SDK reads the authorization code +
|
||
* state from the URL and the PKCE verifier from storage, exchanges the code at
|
||
* /v1/iam/oauth/token, and stores the tokens. The sign-in link IAM emails lands
|
||
* here too, its address and code in the fragment (components/auth/opened).
|
||
*/
|
||
|
||
import { useEffect, useRef, useState, Suspense } from 'react'
|
||
import { claimsOf, useIam } from '@hanzo/iam/react'
|
||
import { useAnalytics } from '@hanzo/event/react'
|
||
import { isoWeek } from '@hanzo/event'
|
||
import { track } from '@hanzo/app/lib/tags'
|
||
import { useRouter } from 'next/navigation'
|
||
import { signinMethod, takeSignupIntent } from '@hanzo/app/lib/intent'
|
||
import { landing, LOGIN, takeDestination } from '@hanzo/app/lib/destination'
|
||
import { arrive, signedIn } from '@/lib/auth/arrive'
|
||
import { owesTerms } from '@hanzo/ui/onboarding'
|
||
import { fresh, Owed, standing } from '@/components/onboarding/flow'
|
||
import { bind } from '@/lib/hanzo/affiliate'
|
||
import { impersonated } from '@/lib/auth/impersonation'
|
||
import { useI18n, type Say } from '@hanzo/i18n/react'
|
||
import { Text, YStack } from '@hanzo/ui'
|
||
import { Action } from '@hanzo/ui/marketing'
|
||
import { Focus, LARGE, Wait } from '@/components/shell/focus'
|
||
import { ARCHIVED, Mailed } from '@/components/auth/steps'
|
||
import { Opened } from '@/components/auth/opened'
|
||
import { read, type Handed } from '@/lib/auth/link'
|
||
import { keep } from '@hanzo/app/lib/gmail'
|
||
|
||
/** The provider a way in names, as its owner spells it. */
|
||
const PROVIDERS: Record<string, string> = { google: 'Google', github: 'GitHub' }
|
||
|
||
/** IAM's description for an account an operator archived (admin's Archive sets `isForbidden`). */
|
||
const ARCHIVED_REASON = 'the account is not permitted'
|
||
|
||
/** Whether IAM refused because an operator archived the account: no retry can change that. */
|
||
const archived = (code: string, description: string) => code === 'access_denied' && description.startsWith(ARCHIVED_REASON)
|
||
|
||
/**
|
||
* IAM's reasons for refusing a provider's sign-in (hanzoai/iam internal/oidc
|
||
* federation.go `fedResolveErrorRedirect` and the forbidden check after it), by
|
||
* the start of its description, each as what the person can do about it.
|
||
*/
|
||
const REFUSED: [string, (provider: string, t: Say) => string][] = [
|
||
[ARCHIVED_REASON, (_, t) => t(ARCHIVED)],
|
||
['an account already uses this email address', (_, t) => t('This email already belongs to a Hanzo account with a password. Log in with your email and password.')],
|
||
['more than one account uses this email address', (_, t) => t('More than one Hanzo account uses this email. Contact [email protected] to merge them.')],
|
||
[
|
||
'the provider shared no email address',
|
||
(p, t) =>
|
||
p
|
||
? t('{provider} didn’t share an email address. Allow email access when {provider} asks, or sign up with your email.', { provider: p })
|
||
: t('The provider didn’t share an email address. Allow email access when it asks, or sign up with your email.'),
|
||
],
|
||
['this application is not open for registration', (_, t) => t('New accounts can’t be created here right now.')],
|
||
]
|
||
|
||
/**
|
||
* IAM's code (RFC 6749 §4.1.2.1, or `exchange_failed` for the token call), as a
|
||
* sentence. The description only chooses among these sentences and is never
|
||
* shown: anyone can write one into a link to this page.
|
||
*/
|
||
function sentence(t: Say, code: string, method: string, description = ''): string {
|
||
const provider = PROVIDERS[method] ?? ''
|
||
if (code === 'access_denied') {
|
||
const known = REFUSED.find(([start]) => description.startsWith(start))
|
||
if (known) return known[1](provider, t)
|
||
return provider ? t('Signing in with {provider} was cancelled.', { provider }) : t('Signing in was cancelled.')
|
||
}
|
||
if (code === 'invalid_grant' || code === 'invalid_request') return t('That sign-in expired before it finished.')
|
||
return provider ? t("We couldn't finish signing you in with {provider}.", { provider }) : t("We couldn't finish signing you in.")
|
||
}
|
||
|
||
/**
|
||
* The sign-in page again, carrying the destination the failed sign-in started
|
||
* from as its `?next=`, so a checkout's sign-in reopens as that checkout's page
|
||
* (components/auth/page.tsx) and goes on to it.
|
||
*/
|
||
function again(): string {
|
||
const to = takeDestination()
|
||
return to === landing(window.location.hostname) ? LOGIN : `${LOGIN}?next=${encodeURIComponent(to)}`
|
||
}
|
||
|
||
/**
|
||
* A sign-in that did not finish: the reason in words as the page's title, the
|
||
* way back, and IAM's code small beneath them for whoever reports it. Try again
|
||
* resumes the destination the sign-in started from (`again`); Back leaves for
|
||
* this site's front page, `/` on every host (never the app, which would ask a
|
||
* signed-out person to sign up), since the browser's own back would return to
|
||
* the provider. An archived account gets the reason and Back alone: trying again
|
||
* cannot sign it in, and the sentence already names who restores it. The page is
|
||
* the focused page (components/shell/focus), its controls the sign-in card's
|
||
* 56px pills.
|
||
*/
|
||
function Retry({ code, description }: { code: string; description: string }) {
|
||
const { t } = useI18n()
|
||
const final = archived(code, description)
|
||
return (
|
||
<Focus id="refused" title={<Mailed text={sentence(t, code, signinMethod(), description)} />}>
|
||
<YStack gap="$3" items="stretch">
|
||
{final ? null : (
|
||
<Action render="button" type="button" fill onClick={() => window.location.assign(again())} {...LARGE}>
|
||
{t('Try again')}
|
||
</Action>
|
||
)}
|
||
<Action render="button" type="button" fill={final} onClick={() => window.location.assign('/')} {...LARGE}>
|
||
{t('Back')}
|
||
</Action>
|
||
{final ? null : (
|
||
<Text render="p" m={0} pt="$2" fontSize="$1" color="$soft" text="center">
|
||
{code}
|
||
</Text>
|
||
)}
|
||
</YStack>
|
||
</Focus>
|
||
)
|
||
}
|
||
|
||
const AuthCallbackInner = () => {
|
||
const { t } = useI18n()
|
||
const router = useRouter()
|
||
const { handleCallback } = useIam()
|
||
const analytics = useAnalytics()
|
||
const [failed, setFailed] = useState<{ code: string; description: string } | null>(null)
|
||
const [link, setLink] = useState<Handed | null>(null)
|
||
// An account a provider made a moment ago owes the terms before it goes on (`Owed`).
|
||
const [owed, setOwed] = useState<{ email: string; next: string } | null>(null)
|
||
const linked = useRef(false)
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
|
||
// The emailed link: no authorization code to exchange, its own page.
|
||
if (linked.current) return
|
||
const handed = read(window.location.hash)
|
||
if (handed) {
|
||
linked.current = true
|
||
setLink(handed)
|
||
return
|
||
}
|
||
|
||
// Google Workspace OAuth fragment return (#access_token=...&state=gws_...)
|
||
const hashRaw = window.location.hash ? window.location.hash.replace(/^#/, '') : ''
|
||
if (hashRaw.includes('access_token=') || (hashRaw.includes('state=gws') && hashRaw.includes('error='))) {
|
||
linked.current = true
|
||
const hashParams = new URLSearchParams(hashRaw)
|
||
const gwsToken = hashParams.get('access_token')
|
||
const gwsScope = hashParams.get('scope') ?? ''
|
||
if (!gwsToken) {
|
||
if (window.opener) window.close()
|
||
else router.replace('/chat')
|
||
return
|
||
}
|
||
void (async () => {
|
||
let email = ''
|
||
try {
|
||
const r = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
|
||
headers: { Authorization: `Bearer ${gwsToken}` },
|
||
})
|
||
if (r.ok) {
|
||
const u = (await r.json()) as { email?: string }
|
||
if (typeof u?.email === 'string') email = u.email.trim().toLowerCase()
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
// Kept the one way @hanzo/app keeps a Google token, with its expiry.
|
||
keep(email || 'default', { accessToken: gwsToken, scope: gwsScope, expiresIn: Number(hashParams.get('expires_in')) || undefined })
|
||
if (window.opener) {
|
||
try {
|
||
window.opener.postMessage(
|
||
{ type: 'hanzo-google-workspace-connected', email, accessToken: gwsToken, scope: gwsScope },
|
||
window.location.origin,
|
||
)
|
||
} catch {
|
||
// ignore
|
||
}
|
||
window.close()
|
||
return
|
||
}
|
||
router.replace('/chat')
|
||
})()
|
||
return
|
||
}
|
||
|
||
// IAM's refusal arrives in the address (RFC 6749 §4.1.2.1). It is logged
|
||
// with its code and never exchanged: there is no code to exchange.
|
||
const q = new URLSearchParams(window.location.search)
|
||
const linkedProvider = q.get('linked')
|
||
if (linkedProvider) {
|
||
const normalized = linkedProvider.toLowerCase()
|
||
if (normalized.includes('google')) window.localStorage.setItem('hanzo.onboarding.google.v2', 'true')
|
||
else if (normalized.includes('github')) window.localStorage.setItem('hanzo.onboarding.github', 'true')
|
||
else if (normalized.includes('slack')) window.localStorage.setItem('hanzo.onboarding.slack', 'true')
|
||
if (window.opener) {
|
||
window.close()
|
||
return
|
||
}
|
||
router.replace('/chat')
|
||
return
|
||
}
|
||
const code = q.get('error')
|
||
if (code) {
|
||
const description = q.get('error_description') ?? ''
|
||
console.warn('[auth] IAM answered the callback with', code, description)
|
||
analytics.captureError(new Error(`sign-in refused: ${code}`), { properties: { where: 'auth_callback', code, description } })
|
||
setFailed({ code, description })
|
||
return
|
||
}
|
||
|
||
handleCallback()
|
||
.then(async (token) => {
|
||
if (window.opener && window.name.startsWith('hanzo-oauth-')) {
|
||
if (window.name === 'hanzo-oauth-google') window.localStorage.setItem('hanzo.onboarding.google.v2', 'true')
|
||
else if (window.name === 'hanzo-oauth-github') window.localStorage.setItem('hanzo.onboarding.github', 'true')
|
||
else if (window.name === 'hanzo-oauth-slack') window.localStorage.setItem('hanzo.onboarding.slack', 'true')
|
||
window.close()
|
||
return
|
||
}
|
||
// A support session (/auth/initiate) is not the person's sign-in: it
|
||
// opens the app, and nothing is counted, bound or claimed for them.
|
||
if (impersonated(claimsOf(token.accessToken))) {
|
||
if (!cancelled) router.replace('/chat')
|
||
return
|
||
}
|
||
// An affiliate link's kept code is named to the organization before the
|
||
// picker or a checkout opens, so a purchase that follows is attributed to
|
||
// it; it is asked beside the account's standing, never before it.
|
||
const read = Promise.all([bind(), standing(new AbortController().signal)])
|
||
// A sign-in that began at /signup is a new account: stamp the
|
||
// acquisition cohort once, at birth, so every later event (and every
|
||
// retention curve) carries the week the person joined. The sign-up
|
||
// CONVERSION is counted once, where the account becomes a customer —
|
||
// its first organization (components/workspace/Orgs.tsx), which a
|
||
// signed-in reader with none is shown before anything else, however the
|
||
// account was made. A returning sign-in is a login, never a sign-up.
|
||
// The way in rides along as `method` (the sign-in page wrote it down,
|
||
// `session` for a session IAM already held).
|
||
const method = signinMethod()
|
||
if (takeSignupIntent()) analytics.setCohort({ signupWeek: isoWeek(new Date()) })
|
||
else track('login_completed', { method })
|
||
// Straight on to the page the sign-in started from, which the header
|
||
// wrote down on its way out, or the checkout it was headed for; the
|
||
// landing is the app at /chat (lib/auth/arrive). /onboarding is not
|
||
// shown: no stop, no plans. Only the terms can stand in the way: an
|
||
// account a provider (Google, GitHub) made a moment ago has accepted
|
||
// none, where a code sign-up recorded them with IAM as it was made.
|
||
const dest = takeDestination()
|
||
const isDesktop = typeof window !== 'undefined' && Boolean((window as unknown as { __TAURI__?: unknown; __TAURI_INTERNALS__?: unknown }).__TAURI__ || (window as unknown as { __TAURI__?: unknown; __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__)
|
||
const next = (isDesktop && (!dest || dest === '/home' || dest === '/')) ? '/chat' : dest
|
||
const to = signedIn(next)
|
||
const [, { email, made, known, stored }] = await read
|
||
if (cancelled) return
|
||
// Owed on IAM's word; with no word, an account made in the last hour agrees to them (twice is harmless, never is not).
|
||
if (known ? owesTerms(stored.terms, made) : fresh(made)) {
|
||
setOwed({ email, next: to })
|
||
return
|
||
}
|
||
arrive(to, (path) => router.replace(path))
|
||
})
|
||
.catch((err: unknown) => {
|
||
if (cancelled) return
|
||
// A failed exchange is the single most expensive drop in the funnel —
|
||
// report it as an error so it lands in the sentry lens next to the drop.
|
||
const message = err instanceof Error ? err.message : String(err)
|
||
console.warn('[auth] the code exchange failed:', message)
|
||
analytics.captureError(err, { properties: { where: 'auth_callback', code: 'exchange_failed', description: message } })
|
||
setFailed({ code: 'exchange_failed', description: '' })
|
||
})
|
||
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [handleCallback, router, analytics])
|
||
|
||
if (owed) return <Owed email={owed.email} onDone={() => arrive(owed.next, (path) => router.replace(path))} />
|
||
if (link) return <Opened handed={link} />
|
||
return failed ? <Retry code={failed.code} description={failed.description} /> : <Wait title={t('Signing you in')} />
|
||
}
|
||
|
||
const AuthCallback = () => {
|
||
const { t } = useI18n()
|
||
return (
|
||
<Suspense fallback={<Wait title={t('Signing you in')} />}>
|
||
<AuthCallbackInner />
|
||
</Suspense>
|
||
)
|
||
}
|
||
|
||
export default AuthCallback
|