mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
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
280 lines
13 KiB
TypeScript
280 lines
13 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, 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
|