mirror of
https://github.com/hanzoai/id.git
synced 2026-11-03 00:42:22 +00:00
Replaces the Cloudflare Worker (hanzo.id-worker) and the Next.js portal
with a pnpm monorepo following the ~/work/liquidity/id pattern.
Layout:
apps/web/ Vite + React 19 SPA, embeds @hanzo/gui shell
k8s/ Deployment(2) + Service + Ingress (4 hosts, 4 TLS)
pkgs/shared/ @hanzo/id-shared — TenantConfig, resolveTenant,
loadBrand (browser + node)
pkgs/auth/ @hanzo/id-auth — AuthClient (wraps @hanzo/iam REST)
+ LoginForm/SignupForm/ForgotForm/OTPForm
pkgs/idv/ @hanzo/id-idv — pluggable IDV (stub, persona,
onfido, veriff) behind one
IDVProvider interface
legacy-nextjs/ Frozen — Next.js predecessor. Delete after v0.1.0 ships.
Dockerfile Two-stage: pnpm build → hanzoai/static:0.4.1 serves /spa
README.md
LLM.md Architecture, dev, deploy, cutover plan
Tenant resolution: hostname → TenantConfig (orgId, iamUrl, clientId,
appName, publicOrigin, brandPackage). Built-in defaults for
hanzo.id/lux.id/zoo.id/pars.id; runtime override via
IAM_TENANT_CONFIG_JSON env (served as /config.json at pod startup).
Brand resolution: each per-org brand pkg (@hanzo/brand, @luxfi/brand,
@zooai/brand, @parsdao/brand) ships brand.json. The Vite plugin
brandJsonPlugin emits /brand/<pkg>/brand.json verbatim; the browser
fetches the right one based on the resolved tenant. No bundle bloat.
IDV: stub (default for dev), persona, onfido, veriff. Each adapter
implements `IDVProvider` from pkgs/idv/src/provider.ts. Swap providers
with one registration call at boot — portal code unchanged.
Cutover (separate ops PR — not in this commit):
1. Tag + push image ghcr.io/hanzoai/id:0.1.0
2. Apply k8s manifests, cert-manager issues TLS
3. Remove CF Worker routes for hanzo.id/lux.id/zoo.id/pars.id
4. CF A records → 129.212.164.5 (hanzo ingress LB)
5. Archive hanzo.id-worker repo
178 lines
5.9 KiB
TypeScript
178 lines
5.9 KiB
TypeScript
'use client'
|
|
|
|
export const runtime = 'edge'
|
|
|
|
import { Suspense, useEffect, useState } from 'react'
|
|
import { useSearchParams } from 'next/navigation'
|
|
import { exchangeCode } from '@/lib/oauth'
|
|
import { getIamUrl, getDefaultClientId } from '@/lib/iam'
|
|
|
|
/**
|
|
* Claim a referral code after successful login/signup.
|
|
* Fire-and-forget: never blocks redirect on failure.
|
|
*/
|
|
function claimReferral(accessToken: string, userId: string, email: string) {
|
|
const refCode = sessionStorage.getItem('hanzo_ref_code')
|
|
if (!refCode) return
|
|
|
|
sessionStorage.removeItem('hanzo_ref_code')
|
|
|
|
fetch('https://commerce.hanzo.ai/api/v1/referral/claim', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${accessToken}`,
|
|
},
|
|
body: JSON.stringify({ code: refCode, userId, email }),
|
|
}).catch(() => {})
|
|
}
|
|
|
|
function CallbackHandler() {
|
|
const searchParams = useSearchParams()
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
useEffect(() => {
|
|
handleCallback()
|
|
}, [])
|
|
|
|
async function handleCallback() {
|
|
const errorParam = searchParams.get('error')
|
|
if (errorParam) {
|
|
setError(searchParams.get('error_description') || errorParam)
|
|
return
|
|
}
|
|
|
|
// Token passthrough from social login / bridge callback
|
|
const accessToken = searchParams.get('access_token')
|
|
if (accessToken) {
|
|
localStorage.setItem('hanzo_access_token', accessToken)
|
|
const refreshToken = searchParams.get('refresh_token')
|
|
if (refreshToken) {
|
|
localStorage.setItem('hanzo_refresh_token', refreshToken)
|
|
}
|
|
const idToken = searchParams.get('id_token')
|
|
if (idToken) {
|
|
localStorage.setItem('hanzo_id_token', idToken)
|
|
}
|
|
|
|
// Extract user info: prefer id_token (has full claims), fall back to access_token
|
|
try {
|
|
const idPayload = idToken
|
|
? JSON.parse(atob(idToken.split('.')[1]))
|
|
: null
|
|
const atPayload = JSON.parse(atob(accessToken.split('.')[1]))
|
|
const p = idPayload || atPayload
|
|
localStorage.setItem('hanzo_user', JSON.stringify({
|
|
sub: p.sub || atPayload.sub || atPayload.name,
|
|
name: p.name || p.preferred_username || atPayload.name,
|
|
displayName: p.displayName || p.name || p.preferred_username,
|
|
email: p.email || atPayload.email,
|
|
avatar: p.avatar || p.picture || p.permanentAvatar,
|
|
}))
|
|
claimReferral(accessToken, p.sub || atPayload.sub || atPayload.name, p.email || atPayload.email)
|
|
} catch {}
|
|
|
|
const postLoginRedirect = sessionStorage.getItem('hanzo_auth_post_login_redirect')
|
|
if (postLoginRedirect) {
|
|
sessionStorage.removeItem('hanzo_auth_post_login_redirect')
|
|
window.location.href = postLoginRedirect
|
|
} else {
|
|
window.location.href = '/account'
|
|
}
|
|
return
|
|
}
|
|
|
|
// PKCE authorization code flow
|
|
const code = searchParams.get('code')
|
|
const state = searchParams.get('state')
|
|
|
|
if (!code || !state) {
|
|
setError('Missing authorization code or state')
|
|
return
|
|
}
|
|
|
|
try {
|
|
const host = window.location.hostname
|
|
const iamUrl = getIamUrl(host)
|
|
const clientId = getDefaultClientId(host)
|
|
const redirectUri = `${window.location.origin}/callback`
|
|
|
|
const tokens = await exchangeCode({
|
|
iamUrl,
|
|
code,
|
|
state,
|
|
clientId,
|
|
redirectUri,
|
|
})
|
|
|
|
localStorage.setItem('hanzo_access_token', tokens.access_token)
|
|
if (tokens.refresh_token) {
|
|
localStorage.setItem('hanzo_refresh_token', tokens.refresh_token)
|
|
}
|
|
if (tokens.id_token) {
|
|
localStorage.setItem('hanzo_id_token', tokens.id_token)
|
|
}
|
|
|
|
// Extract user info: prefer id_token (has full claims), fall back to access_token
|
|
try {
|
|
const idPayload = tokens.id_token
|
|
? JSON.parse(atob(tokens.id_token.split('.')[1]))
|
|
: null
|
|
const atPayload = JSON.parse(atob(tokens.access_token.split('.')[1]))
|
|
const p = idPayload || atPayload
|
|
localStorage.setItem('hanzo_user', JSON.stringify({
|
|
sub: p.sub || atPayload.sub || atPayload.name,
|
|
name: p.name || p.preferred_username || atPayload.name,
|
|
displayName: p.displayName || p.name || p.preferred_username,
|
|
email: p.email || atPayload.email,
|
|
avatar: p.avatar || p.picture || p.permanentAvatar,
|
|
}))
|
|
claimReferral(tokens.access_token, p.sub || atPayload.sub || atPayload.name, p.email || atPayload.email)
|
|
} catch {}
|
|
|
|
const postLoginRedirect = sessionStorage.getItem('hanzo_auth_post_login_redirect')
|
|
if (postLoginRedirect) {
|
|
sessionStorage.removeItem('hanzo_auth_post_login_redirect')
|
|
window.location.href = postLoginRedirect
|
|
} else {
|
|
window.location.href = '/account'
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Authentication failed')
|
|
}
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="login-card max-w-md w-full p-8 text-center">
|
|
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
|
|
{error}
|
|
</div>
|
|
<a href="/login" className="link text-sm">Back to login</a>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="text-center">
|
|
<div className="animate-spin w-8 h-8 border-2 border-zinc-700 border-t-white rounded-full mx-auto mb-4" />
|
|
<p className="text-zinc-400 text-sm">Completing sign in...</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function CallbackPage() {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-black">
|
|
<Suspense fallback={
|
|
<div className="text-center">
|
|
<div className="animate-spin w-8 h-8 border-2 border-zinc-700 border-t-white rounded-full mx-auto mb-4" />
|
|
<p className="text-zinc-400 text-sm">Loading...</p>
|
|
</div>
|
|
}>
|
|
<CallbackHandler />
|
|
</Suspense>
|
|
</div>
|
|
)
|
|
}
|