Files
hanzo.ai/app/(marketing)/auth/callback/page.tsx
zeekay b0fe332a88 auth: a Google consent is kept the one way @hanzo/app keeps it
The callback wrote Workspace tokens into storage by hand, with no expiry.
It calls @hanzo/app 0.2.28's keep() now, which records the expiry Google
gave, so gmail() can tell a live token from a spent one when the beta
invites send from the member's own Gmail. A gate holds it.

Claude-Session: https://claude.ai/code/session_017vrVus6PPBgP5YRzKV1iqW
2026-10-25 19:05:23 -07:00

280 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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, picker, takeDestination } from '@hanzo/app/lib/destination'
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)
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
// lands on `/`, and nothing is counted, bound or claimed for them.
if (impersonated(claimsOf(token.accessToken))) {
if (!cancelled) router.replace('/')
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.
await bind()
if (cancelled) return
// 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 })
// /onboarding, then the page the sign-in started from, which the header
// wrote down on its way out; a shared chat opens without it
// (@hanzo/app/lib/destination `picker`). Every sign-in passes it and
// a returning account goes straight through: it holds the terms a new
// account owes, then a new account's one question, a checkout chosen
// before signing in, and Free chosen on /pricing
// (components/onboarding/flow.tsx).
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
router.replace(picker(next))
})
.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 (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