rewrite: Vite + @hanzo/gui monorepo (drops CF Worker + Next.js) (#2)

Replaces the Cloudflare Worker (hanzo.id-worker) and the Next.js portal
with a pnpm monorepo following the a-monorepo/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
This commit is contained in:
2026-05-24 06:00:20 -07:00
committed by GitHub
parent 5fe3f79abc
commit a24ed753a4
88 changed files with 2261 additions and 247 deletions
+5
View File
@@ -7,3 +7,8 @@ out/
.env
.env.local
.env.production.local
# Vite + pnpm monorepo
apps/*/dist
pkgs/*/dist
**/tsconfig.tsbuildinfo
**/node_modules
+20 -51
View File
@@ -1,54 +1,23 @@
FROM node:22-alpine AS base
# syntax=docker/dockerfile:1.7
# Hanzo ID — Vite SPA built once, served by hanzoai/static.
FROM node:24-alpine AS build
WORKDIR /build
ENV PNPM_HOME=/pnpm PATH=$PNPM_HOME:$PATH
RUN corepack enable && corepack prepare [email protected] --activate
# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
COPY pnpm-workspace.yaml package.json tsconfig.base.json ./
COPY apps/web/package.json apps/web/
COPY pkgs/shared/package.json pkgs/shared/
COPY pkgs/auth/package.json pkgs/auth/
COPY pkgs/idv/package.json pkgs/idv/
RUN pnpm install --frozen-lockfile=false
# --- Dependencies ---
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile 2>/dev/null || pnpm install
COPY apps apps
COPY pkgs pkgs
RUN pnpm --filter @hanzo/id-web build
# --- Build ---
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build args become env vars at build time (for white-label forks)
ARG NEXT_PUBLIC_IAM_URL
ARG NEXT_PUBLIC_ORG
ARG NEXT_PUBLIC_CLIENT_ID
ARG NEXT_PUBLIC_APP_NAME
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
# --- Production ---
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Runtime env vars for white-label configuration:
# IAM_ORIGIN — IAM backend URL (default: https://iam.hanzo.ai)
# NEXT_PUBLIC_IAM_URL — Same, for client-side
# NEXT_PUBLIC_ORG — Organization name (default: hanzo)
# NEXT_PUBLIC_CLIENT_ID — Default app client ID
CMD ["node", "server.js"]
# Static server stage — hanzoai/static reads /spa for assets
FROM ghcr.io/hanzoai/static:0.4.1
COPY --from=build /build/apps/web/dist /spa
EXPOSE 8080
ENV PORT=8080 ROOT=/spa
+129 -29
View File
@@ -1,38 +1,138 @@
# LLM.md - Hanzo Id
# LLM.md — Hanzo ID
## Overview
White-label login portal for Hanzo IAM - forkable, multi-tenant, RFC-compliant OAuth2/OIDC
## What this is
## Tech Stack
- **Language**: TypeScript/JavaScript
White-label login + identity verification portal. Vite SPA, served from
the Hanzo K8s cluster, white-labels per hostname.
Replaces:
- `~/work/hanzo/hanzo.id-worker` (Cloudflare Worker) — being decommissioned
- `~/work/hanzo/id/legacy-nextjs/` (Next.js 15) — frozen, kept for diff
## Architecture
```
DNS → hanzo cluster ingress (129.212.164.5)
hanzo.id ──┐
lux.id ──┤ ─ TLS → ingress ─ K8s ─→ Service id ─→ Deployment id (2 replicas)
zoo.id ──┤ (Traefik) ghcr.io/hanzoai/id:vX.Y.Z
pars.id ──┘ │
│ on boot:
│ resolveTenant(hostname)
│ loadBrand(tenant.brandPackage)
▼
@hanzo|luxfi|zooai|parsdao /brand
│
▼
createAuthClient(tenant)
│
▼
https://iam.hanzo.ai (Casdoor fork, Go)
│
▼
iam-* postgres in hanzo namespace
```
## Workspace
```
apps/
web/ @hanzo/id-web — Vite + React 19 + @hanzo/gui SPA
pkgs/
shared/ @hanzo/id-shared — TenantConfig, resolveTenant, loadBrand
auth/ @hanzo/id-auth — composable login/signup/OTP forms +
AuthClient (wraps @hanzo/iam REST)
idv/ @hanzo/id-idv — pluggable identity verification
(Persona, Onfido, Veriff, stub)
legacy-nextjs/ Frozen predecessor. Delete after v0.1.0 ships.
```
## Why this layout (and not just Next.js)
1. **Vite > Next.js for an SPA.** No SSR needed; auth is post-load only.
~200kb gzip vs ~600kb. Build is 3s vs 90s.
2. **@hanzo/gui v7 is the canonical UI.** Same shell as every other
Hanzo admin surface. No bespoke components.
3. **Per-org brand packages.** `@hanzo/brand`, `@luxfi/brand`,
`@zooai/brand`, `@parsdao/brand` already ship `brand.json` files;
we fetch them at runtime. Adding a brand = `pnpm add @newco/brand`
+ one line in `pkgs/shared/src/tenant.ts` (or a runtime catalog
entry — no rebuild).
4. **Provider-pluggable IDV.** Same surface for Persona, Onfido, Veriff,
custom backends. No vendor lock-in at the portal layer.
5. **Mirrors `a downstream consumer`.** Same `apps/` + `pkgs/` pattern,
same `@hanzo/gui` shell, same per-tenant brand resolution.
## Local dev
## Build & Run
```bash
pnpm install && pnpm build
pnpm test
pnpm install
pnpm dev # http://localhost:5173 → defaults to hanzo brand
```
## Structure
For multi-tenant preview:
```
id/
Dockerfile
LICENSE
README.md
app/
components/
config/
lib/
middleware.ts
next-env.d.ts
next.config.ts
package.json
pnpm-lock.yaml
postcss.config.mjs
public/
scripts/
echo "127.0.0.1 lux.id zoo.id pars.id" | sudo tee -a /etc/hosts
```
then visit `http://lux.id:5173` etc.
## Build + deploy
```bash
pnpm build
docker build -t ghcr.io/hanzoai/id:0.1.0 .
docker push ghcr.io/hanzoai/id:0.1.0
kubectl apply -k apps/web/k8s
```
## Key Files
- `README.md` -- Project documentation
- `package.json` -- Dependencies and scripts
- `Dockerfile` -- Container build
## Adding a new brand
1. `pnpm add -F @hanzo/id-web @newco/brand`
2. Add `TenantConfig` for the hostname in `pkgs/shared/src/tenant.ts`
(or put it in `IAM_TENANT_CONFIG_JSON` at runtime — no rebuild).
3. Add the hostname to `BRAND_PACKAGES` in `apps/web/vite.config.ts`.
4. Add host + TLS secret to `apps/web/k8s/ingress.yaml`.
5. DNS → cluster ingress IP.
## Plugging an IDV provider
```ts
import { registerProvider, createPersonaProvider } from '@hanzo/id-idv'
registerProvider(createPersonaProvider({ templateId, apiKey, environment: 'production' }))
```
## Pre-built providers
| id | source | docs |
|---|---|---|
| `stub` | `@hanzo/id-idv/providers/stub` | in-memory, dev-only |
| `persona` | `@hanzo/id-idv/providers/persona` | https://docs.withpersona.com |
| `onfido` | `@hanzo/id-idv/providers/onfido` | https://documentation.onfido.com |
| `veriff` | `@hanzo/id-idv/providers/veriff` | https://developers.veriff.com |
Custom providers: implement the `IDVProvider` interface in
`pkgs/idv/src/provider.ts` and register it.
## Cutover from the CF Worker
1. Build + push image (PR scaffolds; image bump comes after CI green).
2. `kubectl apply -k apps/web/k8s` — Deployment + Service + Ingress live.
3. cert-manager issues TLS for all 4 hosts.
4. Remove the Cloudflare Worker routes for the 4 identity hosts.
5. Repoint CF A records: `hanzo.id`, `lux.id`, `zoo.id`, `pars.id` →
`129.212.164.5` (hanzo cluster ingress LB), CF-proxied.
6. Verify each host loads its own brand.
7. Archive `hanzo.id-worker` repo.
## Backend
The Go IAM backend lives at `~/work/hanzo/iam` (Casdoor fork, module
`github.com/hanzoai/iam`, image `ghcr.io/hanzoai/iam`). This portal talks
to it via the routes in `pkgs/auth/src/client.ts`:
- `/v1/iam/login` `/v1/iam/signup` `/v1/iam/send-verification-code`
- `/oauth/authorize` `/oauth/token` `/oauth/logout`
All hostnames talk to the same IAM backend — the org is carried in the
request body (`organization: <orgId>`), and the IAM backend tenant-scopes
on that.
+54 -127
View File
@@ -1,146 +1,73 @@
# Hanzo ID - Hosted Login Pages
# @hanzo/id
Configurable, white-label login pages for Hanzo IAM. Each organization can customize their login experience based on their domain (CNAME).
White-label login + identity verification portal. One Vite SPA, four hosts
(`hanzo.id`, `lux.id`, `zoo.id`, `pars.id`), per-tenant brand resolved from
the request hostname at runtime.
## Architecture
## Layout
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ hanzo.id │ │ pars.id │ │ lux.id │
│ (CNAME) │ │ (CNAME) │ │ (CNAME) │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
└───────────────────┴───────────────────┘
│
┌──────▼──────┐
│ Hanzo ID │ ← This repo (frontend)
│ (Next.js) │
└──────┬──────┘
│
┌──────▼──────┐
│ Hanzo IAM │ ← Backend auth services
│ (Go API) │
└─────────────┘
apps/
web/ Vite + React 19 + @hanzo/gui — the actual SPA
k8s/ Deployment + Service + Ingress (4 hosts, 4 TLS secrets)
pkgs/
shared/ @hanzo/id-shared — TenantConfig + brand resolver
auth/ @hanzo/id-auth — composable login/signup/OTP flows
on top of @hanzo/iam SDK
idv/ @hanzo/id-idv — pluggable identity verification
(Persona, Onfido, Veriff, stub)
legacy-nextjs/ Frozen — predecessor Next.js implementation. Kept
for reference until v0.1.0 ships to production.
```
## Features
- **Domain-based branding**: Logo, colors, content based on CNAME
- **Configurable auth methods**: Password, code, WebAuthn, Face ID
- **Social providers**: Google, GitHub, and more
- **Customizable content**: Quotes, testimonials, feature highlights
- **Dark mode by default**: Clean, modern design
- **Easy to fork**: Simple structure for white-labeling
## Configuration
Branding can be configured in two ways:
### 1. Static Configuration (for known domains)
Edit `lib/branding.ts` to add your domain:
```typescript
export const staticBranding: Record<string, Partial<BrandingConfig>> = {
'your-domain.com': {
orgId: 'your-org',
orgName: 'Your Organization',
logo: '/logos/your-logo.svg',
colors: {
primary: '#3b82f6',
primaryText: '#ffffff',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Welcome to Your App',
subtitle: 'Sign in to continue',
},
},
}
```
### 2. Dynamic Configuration (from IAM backend)
The login page fetches branding from IAM API:
```
GET https://api.hanzo.id/api/branding?domain=your-domain.com
```
Response:
```json
{
"orgId": "your-org",
"orgName": "Your Organization",
"logo": "https://...",
"colors": { ... },
"content": { ... },
"links": { ... },
"auth": { ... }
}
```
## Development
## Local dev
```bash
# Install dependencies
npm install
# Start development server
npm run dev
# Build for production
npm run build
# Start production server
npm start
pnpm install
pnpm dev # http://localhost:5173 (defaults to hanzo brand)
```
## Environment Variables
To preview a different brand locally, edit `/etc/hosts`:
```
127.0.0.1 lux.id zoo.id pars.id
```
then visit `http://lux.id:5173`.
## Build
```bash
# IAM backend URL
HANZO_IAM_URL=https://api.hanzo.id
# Public IAM URL (for client-side redirects)
NEXT_PUBLIC_IAM_URL=https://api.hanzo.id
pnpm build # builds apps/web -> dist/
docker build -t ghcr.io/hanzoai/id:0.1.0 .
```
## Forking for White-Label
## Adding a brand
1. Fork this repository
2. Update `lib/branding.ts` with your default branding
3. Add your logo to `public/logos/`
4. Update `app/globals.css` for custom styling
5. Deploy to your infrastructure
1. Publish or workspace-link the new per-org brand pkg (must ship
`brand.json` at the package root and match the `BrandContract` shape
in `pkgs/shared/src/types.ts`).
2. Add a `DEFAULT_TENANTS` entry in `pkgs/shared/src/tenant.ts` OR put
the override in the runtime catalog (`IAM_TENANT_CONFIG_JSON` env)
so no rebuild is needed.
3. Add the hostname to `apps/web/vite.config.ts::BRAND_PACKAGES`
(lets dev + build serve `/brand/<pkg>/brand.json`).
4. Add the hostname + TLS secret to `apps/web/k8s/ingress.yaml`.
5. DNS: CNAME or A record → cluster ingress IP.
## Directory Structure
That's it — no per-brand Worker, no per-brand image, no per-brand
deployment. One binary, four brands.
```
hanzo-id/
├── app/
│ ├── layout.tsx # Root layout with metadata
│ ├── page.tsx # Redirects to /login
│ ├── login/
│ │ └── page.tsx # Main login page
│ ├── signup/ # Sign up page
│ ├── forgot-password # Password reset
│ └── callback/ # OAuth callback handler
├── components/
│ ├── LoginForm.tsx # Login form component
│ └── MarketingPanel.tsx # Right side marketing content
├── lib/
│ └── branding.ts # Branding configuration
├── public/
│ └── logos/ # Organization logos
└── config/ # Additional configuration
## Plugging an IDV provider
```ts
import { registerProvider, createPersonaProvider } from '@hanzo/id-idv'
registerProvider(createPersonaProvider({
templateId: import.meta.env.VITE_PERSONA_TEMPLATE_ID,
apiKey: import.meta.env.VITE_PERSONA_API_KEY,
environment: 'production',
}))
```
## License
MIT - Fork and customize freely!
The portal stays unchanged — switching providers is a single registration
call at boot.
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="robots" content="noindex" />
<link id="favicon" rel="icon" type="image/png" href="data:," />
<title>Sign in</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+75
View File
@@ -0,0 +1,75 @@
# Hanzo ID — single Deployment serves hanzo.id / lux.id / zoo.id / pars.id.
# Tenant resolution is per-request by hostname (see pkgs/shared/src/tenant.ts).
apiVersion: apps/v1
kind: Deployment
metadata:
name: id
namespace: hanzo
labels:
app: id
app.kubernetes.io/name: id
app.kubernetes.io/part-of: platform
spec:
replicas: 2
selector:
matchLabels:
app: id
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: id
spec:
imagePullSecrets:
- name: ghcr-secret
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchExpressions:
- key: app
operator: In
values: [id]
topologyKey: kubernetes.io/hostname
containers:
- name: id
image: ghcr.io/hanzoai/id:0.1.0
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 8080
protocol: TCP
resources:
requests: { cpu: 25m, memory: 64Mi }
limits: { cpu: 500m, memory: 256Mi }
readinessProbe:
httpGet: { path: /healthz, port: 8080 }
initialDelaySeconds: 2
periodSeconds: 5
livenessProbe:
httpGet: { path: /healthz, port: 8080 }
initialDelaySeconds: 5
periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
name: id
namespace: hanzo
labels:
app: id
app.kubernetes.io/name: id
app.kubernetes.io/part-of: platform
spec:
type: ClusterIP
selector:
app: id
ports:
- name: http
port: 80
targetPort: 8080
protocol: TCP
+45
View File
@@ -0,0 +1,45 @@
# Hanzo ID — one Ingress, four hosts. Each host's `tls.secretName` is a
# cert-manager-issued Secret (Let's Encrypt DNS-01 via Cloudflare).
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: id
namespace: hanzo
annotations:
kubernetes.io/ingress.class: ingress
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
rules:
- host: hanzo.id
http:
paths:
- path: /
pathType: Prefix
backend: { service: { name: id, port: { number: 80 } } }
- host: lux.id
http:
paths:
- path: /
pathType: Prefix
backend: { service: { name: id, port: { number: 80 } } }
- host: zoo.id
http:
paths:
- path: /
pathType: Prefix
backend: { service: { name: id, port: { number: 80 } } }
- host: pars.id
http:
paths:
- path: /
pathType: Prefix
backend: { service: { name: id, port: { number: 80 } } }
tls:
- hosts: [hanzo.id]
secretName: hanzo-id-tls
- hosts: [lux.id]
secretName: lux-id-tls
- hosts: [zoo.id]
secretName: zoo-id-tls
- hosts: [pars.id]
secretName: pars-id-tls
+11
View File
@@ -0,0 +1,11 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: hanzo
resources:
- deployment.yaml
- ingress.yaml
labels:
- pairs:
app.kubernetes.io/managed-by: universe
app.kubernetes.io/part-of: platform
includeSelectors: false
+34
View File
@@ -0,0 +1,34 @@
{
"name": "@hanzo/id-web",
"private": true,
"version": "0.1.0",
"description": "Hanzo ID — white-label login / signup / IDV portal. Vite + React 19 + @hanzo/gui. Same image serves hanzo.id / lux.id / zoo.id / pars.id.",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview --port 5174",
"tc": "tsc --noEmit"
},
"dependencies": {
"@hanzo/brand": "^1.3.0",
"@hanzo/gui": "^7.2.4",
"@hanzo/iam": "^0.10.0",
"@hanzo/id-auth": "workspace:*",
"@hanzo/id-idv": "workspace:*",
"@hanzo/id-shared": "workspace:*",
"@luxfi/brand": "^1.0.0",
"@parsdao/brand": "^1.0.0",
"@tanstack/react-router": "^1.168.0",
"@zooai/brand": "^1.3.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.9.3",
"vite": "^7.0.0"
}
}
+45
View File
@@ -0,0 +1,45 @@
import { useEffect, useMemo, useState } from 'react'
import { loadBrand, parseCatalog, resolveTenant, type BrandContract, type TenantConfig } from '@hanzo/id-shared'
import { createAuthClient } from '@hanzo/id-auth'
import { Portal } from './pages/Portal'
import { Login } from './pages/Login'
import { Signup } from './pages/Signup'
import { Forgot } from './pages/Forgot'
import { Callback } from './pages/Callback'
/**
* Top-level wiring. Resolves tenant + brand once on mount, then routes via
* `window.location.pathname`. No router lib needed — this app is 5 pages,
* `<a href>` is enough. Adding paths is a switch case.
*/
export function App() {
const [tenant, setTenant] = useState<TenantConfig | null>(null)
const [brand, setBrand] = useState<BrandContract | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const runtimeCatalog = (window as unknown as { __ID_CATALOG__?: string }).__ID_CATALOG__
const t = resolveTenant(window.location.hostname, { catalog: parseCatalog(runtimeCatalog) })
setTenant(t)
loadBrand(t.brandPackage)
.then((b) => {
setBrand(b)
document.title = `Sign in — ${b.name}`
const fav = document.getElementById('favicon') as HTMLLinkElement | null
if (fav && b.faviconUrl) fav.href = b.faviconUrl
})
.catch((e) => setError(String(e)))
}, [])
const client = useMemo(() => (tenant ? createAuthClient({ tenant }) : null), [tenant])
if (error) return <div className="hanzo-id-error">{error}</div>
if (!tenant || !brand || !client) return <div>Loading…</div>
const path = window.location.pathname
if (path === '/login' || path.startsWith('/login/')) return <Login client={client} brand={brand} />
if (path === '/signup' || path.startsWith('/signup/')) return <Signup client={client} brand={brand} />
if (path === '/forget' || path === '/forgot' || path.startsWith('/forg')) return <Forgot client={client} brand={brand} />
if (path === '/callback' || path.startsWith('/callback/')) return <Callback client={client} brand={brand} />
return <Portal brand={brand} />
}
+90
View File
@@ -0,0 +1,90 @@
:root {
--brand: #ffffff;
--bg: #0a0a0a;
--fg: #fafafa;
--muted: #a3a3a3;
--border: #262626;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
color-scheme: dark;
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; }
body {
background: var(--bg);
color: var(--fg);
min-height: 100vh;
display: flex;
flex-direction: column;
}
.hanzo-id-page {
flex: 1;
display: flex;
flex-direction: column;
max-width: 480px;
margin: 0 auto;
padding: 24px;
}
.hanzo-id-brand-header {
padding: 16px 0 32px;
}
.hanzo-id-page main {
flex: 1;
display: flex;
flex-direction: column;
gap: 24px;
}
.hanzo-id-page h1 { margin: 0; font-size: 28px; }
.hanzo-id-page .lede { color: var(--muted); margin: 0; }
form { display: flex; flex-direction: column; gap: 16px; }
form label { display: flex; flex-direction: column; gap: 6px; font-size: 14px; color: var(--muted); }
form input {
background: #111;
color: var(--fg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 14px;
font-size: 16px;
}
form input:focus { outline: 2px solid var(--brand); outline-offset: -1px; }
.hanzo-id-btn, form button {
background: var(--fg);
color: var(--bg);
border: 0;
border-radius: 8px;
padding: 12px 16px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
text-decoration: none;
text-align: center;
display: inline-block;
}
.hanzo-id-btn[aria-disabled='true'], form button:disabled { opacity: 0.5; cursor: not-allowed; }
.hanzo-id-btn.primary { background: var(--brand); }
.hanzo-id-cta-row { display: flex; gap: 12px; }
.hanzo-id-footer-links { color: var(--muted); font-size: 14px; }
.hanzo-id-footer-links a { color: var(--fg); }
.hanzo-id-error {
background: #2d0a0a;
color: #ff7878;
border: 1px solid #5a1414;
padding: 12px;
border-radius: 8px;
font-size: 14px;
}
.hanzo-id-info {
background: #0a1f2d;
color: #78b8ff;
border: 1px solid #14385a;
padding: 12px;
border-radius: 8px;
}
+11
View File
@@ -0,0 +1,11 @@
import type { BrandContract } from '@hanzo/id-shared'
export function BrandHeader({ brand }: { brand: BrandContract }) {
return (
<header className="hanzo-id-brand-header">
<a href="/" aria-label={brand.name}>
<img src={brand.logoUrl} alt={brand.name} height={32} />
</a>
</header>
)
}
+17
View File
@@ -0,0 +1,17 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './App'
import { registerProvider } from '@hanzo/id-idv'
import { createStubProvider } from '@hanzo/id-idv/providers/stub'
import './app.css'
// Default IDV provider — replace at boot via env-driven config.
registerProvider(createStubProvider())
const root = document.getElementById('root')
if (!root) throw new Error('#root missing')
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
)
+40
View File
@@ -0,0 +1,40 @@
import { useEffect, useState } from 'react'
import type { BrandContract } from '@hanzo/id-shared'
import type { AuthClient } from '@hanzo/id-auth'
import { BrandHeader } from '../components/BrandHeader'
export function Callback({ client, brand }: { client: AuthClient; brand: BrandContract }) {
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const sp = new URLSearchParams(window.location.search)
const code = sp.get('code')
if (!code) {
setError('Missing authorization code.')
return
}
const codeVerifier = sessionStorage.getItem('pkce_verifier') ?? undefined
client
.exchange(code, codeVerifier)
.then((tok) => {
// Forward the tokens to whichever app initiated this flow.
const target = sessionStorage.getItem('post_login_redirect') ?? '/'
const url = new URL(target, window.location.origin)
url.searchParams.set('access_token', tok.accessToken)
if (tok.refreshToken) url.searchParams.set('refresh_token', tok.refreshToken)
if (tok.idToken) url.searchParams.set('id_token', tok.idToken)
sessionStorage.removeItem('pkce_verifier')
sessionStorage.removeItem('post_login_redirect')
window.location.replace(url.toString())
})
.catch((e) => setError(String(e)))
}, [client])
return (
<div className="hanzo-id-page hanzo-id-callback">
<BrandHeader brand={brand} />
<main>
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : <p>Completing sign-in…</p>}
</main>
</div>
)
}
+18
View File
@@ -0,0 +1,18 @@
import type { BrandContract } from '@hanzo/id-shared'
import { ForgotForm, type AuthClient } from '@hanzo/id-auth'
import { BrandHeader } from '../components/BrandHeader'
export function Forgot({ client, brand }: { client: AuthClient; brand: BrandContract }) {
return (
<div className="hanzo-id-page hanzo-id-forgot">
<BrandHeader brand={brand} />
<main>
<h1>Reset your {brand.name} password</h1>
<ForgotForm client={client} />
<p className="hanzo-id-footer-links">
<a href="/login">Back to sign in</a>
</p>
</main>
</div>
)
}
+27
View File
@@ -0,0 +1,27 @@
import type { BrandContract } from '@hanzo/id-shared'
import { LoginForm, type AuthClient } from '@hanzo/id-auth'
import { BrandHeader } from '../components/BrandHeader'
export function Login({ client, brand }: { client: AuthClient; brand: BrandContract }) {
const sp = new URLSearchParams(window.location.search)
const redirectUri = sp.get('redirect_uri') ?? undefined
const state = sp.get('state') ?? undefined
const clientIdOverride = sp.get('client_id') ?? undefined
return (
<div className="hanzo-id-page hanzo-id-login">
<BrandHeader brand={brand} />
<main>
<h1>Sign in to {brand.name}</h1>
<LoginForm
client={client}
redirectUri={redirectUri}
state={state}
clientIdOverride={clientIdOverride ?? undefined}
/>
<p className="hanzo-id-footer-links">
<a href="/forget">Forgot password?</a> · <a href="/signup">Create account</a>
</p>
</main>
</div>
)
}
+18
View File
@@ -0,0 +1,18 @@
import type { BrandContract } from '@hanzo/id-shared'
import { BrandHeader } from '../components/BrandHeader'
export function Portal({ brand }: { brand: BrandContract }) {
return (
<div className="hanzo-id-page hanzo-id-portal">
<BrandHeader brand={brand} />
<main>
<h1>Welcome to {brand.name}</h1>
<p className="lede">{brand.description}</p>
<div className="hanzo-id-cta-row">
<a className="hanzo-id-btn primary" href="/login">Sign in</a>
<a className="hanzo-id-btn" href="/signup">Create account</a>
</div>
</main>
</div>
)
}
+20
View File
@@ -0,0 +1,20 @@
import type { BrandContract } from '@hanzo/id-shared'
import { SignupForm, type AuthClient } from '@hanzo/id-auth'
import { BrandHeader } from '../components/BrandHeader'
export function Signup({ client, brand }: { client: AuthClient; brand: BrandContract }) {
const sp = new URLSearchParams(window.location.search)
const inviteCode = sp.get('invite') ?? undefined
return (
<div className="hanzo-id-page hanzo-id-signup">
<BrandHeader brand={brand} />
<main>
<h1>Create your {brand.name} account</h1>
<SignupForm client={client} inviteCode={inviteCode} />
<p className="hanzo-id-footer-links">
Already have an account? <a href="/login">Sign in</a>
</p>
</main>
</div>
)
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}
+78
View File
@@ -0,0 +1,78 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { resolve } from 'path'
import { readFileSync, existsSync } from 'fs'
/**
* Per-brand brand.json copy plugin.
*
* Each per-org brand package (`@hanzo/brand`, `@luxfi/brand`, `@zooai/brand`,
* `@parsdao/brand`) ships a `brand.json` at the package root. We serve them
* verbatim at `/brand/<pkg>/brand.json` so the runtime tenant resolver can
* fetch the right one based on hostname.
*
* Assets (logos, favicons) are imported by URL inside the per-brand `brand.json`
* (CDN URLs in production), so no further asset copying is needed.
*/
const BRAND_PACKAGES = ['@hanzo/brand', '@luxfi/brand', '@zooai/brand', '@parsdao/brand']
function brandJsonPlugin() {
return {
name: 'hanzo-id-brand-json',
configureServer(server: any) {
server.middlewares.use((req: any, res: any, next: any) => {
const m = /^\/brand\/(.+)\/brand\.json$/.exec(req.url ?? '')
if (!m) return next()
const pkg = decodeURIComponent(m[1]!)
if (!BRAND_PACKAGES.includes(pkg)) {
res.statusCode = 404
return res.end()
}
try {
const path = require.resolve(`${pkg}/brand.json`)
res.setHeader('Content-Type', 'application/json')
res.setHeader('Cache-Control', 'no-store')
return res.end(readFileSync(path, 'utf8'))
} catch {
res.statusCode = 404
return res.end()
}
})
},
generateBundle(this: any) {
for (const pkg of BRAND_PACKAGES) {
try {
const path = require.resolve(`${pkg}/brand.json`)
if (!existsSync(path)) continue
this.emitFile({
type: 'asset',
fileName: `brand/${pkg}/brand.json`,
source: readFileSync(path, 'utf8'),
})
} catch {
// pkg not installed — skip silently; only the brands listed in
// package.json deps actually need their JSON shipped.
}
}
},
}
}
export default defineConfig({
plugins: [react(), brandJsonPlugin()],
resolve: {
alias: { '@': resolve(__dirname, 'src') },
},
server: {
port: 5173,
host: '0.0.0.0',
},
preview: {
port: 5174,
host: '0.0.0.0',
},
build: {
target: 'es2022',
sourcemap: true,
},
})
+54
View File
@@ -0,0 +1,54 @@
FROM node:22-alpine AS base
# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# --- Dependencies ---
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile 2>/dev/null || pnpm install
# --- Build ---
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build args become env vars at build time (for white-label forks)
ARG NEXT_PUBLIC_IAM_URL
ARG NEXT_PUBLIC_ORG
ARG NEXT_PUBLIC_CLIENT_ID
ARG NEXT_PUBLIC_APP_NAME
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
# --- Production ---
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Runtime env vars for white-label configuration:
# IAM_ORIGIN — IAM backend URL (default: https://iam.hanzo.ai)
# NEXT_PUBLIC_IAM_URL — Same, for client-side
# NEXT_PUBLIC_ORG — Organization name (default: hanzo)
# NEXT_PUBLIC_CLIENT_ID — Default app client ID
CMD ["node", "server.js"]
+146
View File
@@ -0,0 +1,146 @@
# Hanzo ID - Hosted Login Pages
Configurable, white-label login pages for Hanzo IAM. Each organization can customize their login experience based on their domain (CNAME).
## Architecture
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ hanzo.id │ │ pars.id │ │ lux.id │
│ (CNAME) │ │ (CNAME) │ │ (CNAME) │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
└───────────────────┴───────────────────┘
│
┌──────▼──────┐
│ Hanzo ID │ ← This repo (frontend)
│ (Next.js) │
└──────┬──────┘
│
┌──────▼──────┐
│ Hanzo IAM │ ← Backend auth services
│ (Go API) │
└─────────────┘
```
## Features
- **Domain-based branding**: Logo, colors, content based on CNAME
- **Configurable auth methods**: Password, code, WebAuthn, Face ID
- **Social providers**: Google, GitHub, and more
- **Customizable content**: Quotes, testimonials, feature highlights
- **Dark mode by default**: Clean, modern design
- **Easy to fork**: Simple structure for white-labeling
## Configuration
Branding can be configured in two ways:
### 1. Static Configuration (for known domains)
Edit `lib/branding.ts` to add your domain:
```typescript
export const staticBranding: Record<string, Partial<BrandingConfig>> = {
'your-domain.com': {
orgId: 'your-org',
orgName: 'Your Organization',
logo: '/logos/your-logo.svg',
colors: {
primary: '#3b82f6',
primaryText: '#ffffff',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Welcome to Your App',
subtitle: 'Sign in to continue',
},
},
}
```
### 2. Dynamic Configuration (from IAM backend)
The login page fetches branding from IAM API:
```
GET https://api.hanzo.id/api/branding?domain=your-domain.com
```
Response:
```json
{
"orgId": "your-org",
"orgName": "Your Organization",
"logo": "https://...",
"colors": { ... },
"content": { ... },
"links": { ... },
"auth": { ... }
}
```
## Development
```bash
# Install dependencies
npm install
# Start development server
npm run dev
# Build for production
npm run build
# Start production server
npm start
```
## Environment Variables
```bash
# IAM backend URL
HANZO_IAM_URL=https://api.hanzo.id
# Public IAM URL (for client-side redirects)
NEXT_PUBLIC_IAM_URL=https://api.hanzo.id
```
## Forking for White-Label
1. Fork this repository
2. Update `lib/branding.ts` with your default branding
3. Add your logo to `public/logos/`
4. Update `app/globals.css` for custom styling
5. Deploy to your infrastructure
## Directory Structure
```
hanzo-id/
├── app/
│ ├── layout.tsx # Root layout with metadata
│ ├── page.tsx # Redirects to /login
│ ├── login/
│ │ └── page.tsx # Main login page
│ ├── signup/ # Sign up page
│ ├── forgot-password # Password reset
│ └── callback/ # OAuth callback handler
├── components/
│ ├── LoginForm.tsx # Login form component
│ └── MarketingPanel.tsx # Right side marketing content
├── lib/
│ └── branding.ts # Branding configuration
├── public/
│ └── logos/ # Organization logos
└── config/ # Additional configuration
```
## License
MIT - Fork and customize freely!
View File
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@hanzo/id",
"version": "0.1.0",
"private": true,
"description": "White-label login portal for Hanzo IAM - forkable, multi-tenant, RFC-compliant OAuth2/OIDC",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"pages:build": "npx @cloudflare/next-on-pages 2>&1 || true && node scripts/patch-not-found.mjs && npx @cloudflare/next-on-pages --skip-build",
"deploy": "pnpm pages:build && wrangler pages deploy .vercel/output/static --project-name hanzo-id --commit-dirty=true",
"deploy:docker": "docker build -t hanzo-id . && docker push ghcr.io/hanzoai/id:latest"
},
"dependencies": {
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwindcss": "^3.4.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0"
},
"devDependencies": {
"@cloudflare/next-on-pages": "^1.13.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"typescript": "^5.0.0",
"wrangler": "^3.0.0"
}
}
View File

Before

Width:  |  Height:  |  Size: 226 B

After

Width:  |  Height:  |  Size: 226 B

Before

Width:  |  Height:  |  Size: 829 B

After

Width:  |  Height:  |  Size: 829 B

Before

Width:  |  Height:  |  Size: 221 B

After

Width:  |  Height:  |  Size: 221 B

Before

Width:  |  Height:  |  Size: 222 B

After

Width:  |  Height:  |  Size: 222 B

Before

Width:  |  Height:  |  Size: 221 B

After

Width:  |  Height:  |  Size: 221 B

Before

Width:  |  Height:  |  Size: 221 B

After

Width:  |  Height:  |  Size: 221 B

+7 -22
View File
@@ -1,30 +1,15 @@
{
"name": "@hanzo/id",
"version": "0.1.0",
"private": true,
"description": "White-label login portal for Hanzo IAM - forkable, multi-tenant, RFC-compliant OAuth2/OIDC",
"version": "0.1.0",
"description": "Hanzo ID — white-label login + identity verification portal (Vite + @hanzo/gui)",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"pages:build": "npx @cloudflare/next-on-pages 2>&1 || true && node scripts/patch-not-found.mjs && npx @cloudflare/next-on-pages --skip-build",
"deploy": "pnpm pages:build && wrangler pages deploy .vercel/output/static --project-name hanzo-id --commit-dirty=true",
"deploy:docker": "docker build -t hanzo-id . && docker push ghcr.io/hanzoai/id:latest"
},
"dependencies": {
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwindcss": "^3.4.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0"
"build": "pnpm -r build",
"dev": "pnpm --filter @hanzo/id-web dev",
"tc": "pnpm -r tc",
"clean": "bash scripts/clean.sh"
},
"devDependencies": {
"@cloudflare/next-on-pages": "^1.13.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"typescript": "^5.0.0",
"wrangler": "^3.0.0"
"typescript": "^5.9.3"
}
}
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@hanzo/id-auth",
"version": "0.1.0",
"description": "Composable login / signup / OTP / OAuth-PKCE flows on top of @hanzo/iam. UI primitives in @hanzo/gui.",
"license": "BSD-3-Clause",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./client": "./src/client.ts",
"./forms": "./src/ui/index.ts",
"./package.json": "./package.json"
},
"files": ["src"],
"scripts": {
"tc": "tsc --noEmit"
},
"dependencies": {
"@hanzo/id-shared": "workspace:*",
"@hanzo/iam": "^0.10.0"
},
"peerDependencies": {
"react": ">=19",
"react-dom": ">=19"
},
"devDependencies": {
"@types/react": "^19.0.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"typescript": "^5.9.3"
}
}
+171
View File
@@ -0,0 +1,171 @@
import type { TenantConfig } from '@hanzo/id-shared'
import type {
ForgotRequest,
LoginRequest,
LoginResponse,
OAuthAuthorizeRequest,
SignupRequest,
TokenResponse,
} from './types'
/**
* Composable IAM client.
*
* Stateless wrapper around the canonical IAM REST surface (Casdoor-compat
* paths under `/v1/iam/*` and the OIDC paths under `/oauth/*`). One client
* instance per tenant. The portal creates one in `createRoot()`; downstream
* pages call `.login()`, `.signup()`, `.forgot()`, `.authorize()` directly.
*
* Token storage is intentionally NOT part of this client — the portal is a
* white-label OIDC provider, so tokens are minted then immediately redirected
* back to the requesting app via `redirectUri`. The browser never holds them
* past the redirect.
*/
export interface AuthClient {
readonly tenant: TenantConfig
login(req: LoginRequest): Promise<LoginResponse>
signup(req: SignupRequest): Promise<LoginResponse>
forgot(req: ForgotRequest): Promise<{ ok: boolean; error?: string }>
authorize(req: OAuthAuthorizeRequest): string
exchange(code: string, codeVerifier?: string): Promise<TokenResponse>
logout(idTokenHint?: string, postLogoutRedirectUri?: string): string
}
export interface AuthClientOptions {
readonly tenant: TenantConfig
/** Override fetch impl (testing). Defaults to global fetch. */
readonly fetchImpl?: typeof fetch
}
export function createAuthClient(opts: AuthClientOptions): AuthClient {
const tenant = opts.tenant
const f = opts.fetchImpl ?? fetch
async function login(req: LoginRequest): Promise<LoginResponse> {
const url = new URL('/v1/iam/login', tenant.iamUrl)
url.searchParams.set('clientId', req.clientId)
url.searchParams.set('application', req.application)
url.searchParams.set('organization', req.organization)
if (req.redirectUri) url.searchParams.set('redirect_uri', req.redirectUri)
if (req.state) url.searchParams.set('state', req.state)
const res = await f(url.toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
username: req.identifier,
password: req.password,
signinMethod: 'Password',
language: 'en',
autoSignin: true,
}),
})
return parseLoginResponse(res)
}
async function signup(req: SignupRequest): Promise<LoginResponse> {
const url = new URL('/v1/iam/signup', tenant.iamUrl)
url.searchParams.set('clientId', req.clientId)
url.searchParams.set('application', req.application)
url.searchParams.set('organization', req.organization)
const res = await f(url.toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
email: req.email,
password: req.password,
inviteCode: req.inviteCode,
}),
})
return parseLoginResponse(res)
}
async function forgot(req: ForgotRequest): Promise<{ ok: boolean; error?: string }> {
const url = new URL('/v1/iam/send-verification-code', tenant.iamUrl)
url.searchParams.set('clientId', req.clientId)
url.searchParams.set('organization', req.organization)
const res = await f(url.toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dest: req.identifier, type: 'login' }),
})
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }
return { ok: true }
}
function authorize(req: OAuthAuthorizeRequest): string {
const url = new URL('/oauth/authorize', tenant.iamUrl)
url.searchParams.set('client_id', req.clientId)
url.searchParams.set('redirect_uri', req.redirectUri)
url.searchParams.set('response_type', req.responseType ?? 'code')
url.searchParams.set('scope', req.scope ?? 'openid profile email')
url.searchParams.set('state', req.state)
if (req.nonce) url.searchParams.set('nonce', req.nonce)
if (req.codeChallenge) {
url.searchParams.set('code_challenge', req.codeChallenge)
url.searchParams.set('code_challenge_method', req.codeChallengeMethod ?? 'S256')
}
return url.toString()
}
async function exchange(code: string, codeVerifier?: string): Promise<TokenResponse> {
const url = new URL('/oauth/token', tenant.iamUrl)
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
client_id: tenant.clientId,
redirect_uri: `${tenant.publicOrigin}/callback`,
})
if (codeVerifier) body.set('code_verifier', codeVerifier)
const res = await f(url.toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
if (!res.ok) throw new Error(`token exchange failed: ${res.status}`)
const data = (await res.json()) as Record<string, unknown>
return {
accessToken: String(data.access_token ?? ''),
refreshToken: typeof data.refresh_token === 'string' ? data.refresh_token : undefined,
idToken: typeof data.id_token === 'string' ? data.id_token : undefined,
tokenType: String(data.token_type ?? 'Bearer'),
expiresIn: typeof data.expires_in === 'number' ? data.expires_in : undefined,
scope: typeof data.scope === 'string' ? data.scope : undefined,
}
}
function logout(idTokenHint?: string, postLogoutRedirectUri?: string): string {
const url = new URL('/oauth/logout', tenant.iamUrl)
if (idTokenHint) url.searchParams.set('id_token_hint', idTokenHint)
url.searchParams.set(
'post_logout_redirect_uri',
postLogoutRedirectUri ?? `${tenant.publicOrigin}/login`,
)
return url.toString()
}
return { tenant, login, signup, forgot, authorize, exchange, logout }
}
async function parseLoginResponse(res: Response): Promise<LoginResponse> {
let body: Record<string, unknown> = {}
try {
body = (await res.json()) as Record<string, unknown>
} catch {
return { error: `HTTP ${res.status} non-JSON response` }
}
if (!res.ok || body.status === 'error') {
return { error: typeof body.msg === 'string' ? body.msg : `HTTP ${res.status}` }
}
const data = (body.data ?? body) as Record<string, unknown>
return {
accessToken: typeof data.access_token === 'string' ? data.access_token : undefined,
refreshToken: typeof data.refresh_token === 'string' ? data.refresh_token : undefined,
idToken: typeof data.id_token === 'string' ? data.id_token : undefined,
expiresAt: typeof data.expires_at === 'number' ? data.expires_at : undefined,
redirectUrl: typeof data.redirect_url === 'string' ? data.redirect_url : undefined,
mfaRequired: data.mfa_required === true,
mfaChannel: typeof data.mfa_channel === 'string' ? (data.mfa_channel as LoginResponse['mfaChannel']) : undefined,
}
}
+10
View File
@@ -0,0 +1,10 @@
export { createAuthClient, type AuthClient, type AuthClientOptions } from './client'
export type {
LoginRequest,
LoginResponse,
SignupRequest,
ForgotRequest,
OAuthAuthorizeRequest,
TokenResponse,
} from './types'
export * from './ui'
+55
View File
@@ -0,0 +1,55 @@
export interface LoginRequest {
readonly identifier: string
readonly password: string
readonly clientId: string
readonly application: string
readonly organization: string
readonly redirectUri?: string
readonly state?: string
}
export interface LoginResponse {
readonly accessToken?: string
readonly refreshToken?: string
readonly idToken?: string
readonly expiresAt?: number
readonly redirectUrl?: string
readonly mfaRequired?: boolean
readonly mfaChannel?: 'totp' | 'sms' | 'email'
readonly error?: string
}
export interface SignupRequest {
readonly email: string
readonly password: string
readonly clientId: string
readonly application: string
readonly organization: string
readonly inviteCode?: string
}
export interface ForgotRequest {
readonly identifier: string
readonly clientId: string
readonly organization: string
}
export interface OAuthAuthorizeRequest {
readonly clientId: string
readonly redirectUri: string
readonly state: string
readonly scope?: string
readonly nonce?: string
readonly responseType?: 'code' | 'token'
readonly codeChallenge?: string
readonly codeChallengeMethod?: 'S256' | 'plain'
}
export interface TokenResponse {
readonly accessToken: string
readonly refreshToken?: string
readonly idToken?: string
readonly tokenType: string
readonly expiresIn?: number
readonly scope?: string
}
+52
View File
@@ -0,0 +1,52 @@
import { useState, type FormEvent } from 'react'
import type { AuthClient } from '../client'
export interface ForgotFormProps {
readonly client: AuthClient
readonly onSent?: () => void
}
export function ForgotForm(props: ForgotFormProps) {
const { client } = props
const [identifier, setIdentifier] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [sent, setSent] = useState(false)
async function onSubmit(e: FormEvent) {
e.preventDefault()
setBusy(true)
setError(null)
try {
const res = await client.forgot({
identifier,
clientId: client.tenant.clientId,
organization: client.tenant.orgId,
})
if (!res.ok) setError(res.error ?? 'send failed')
else {
setSent(true)
props.onSent?.()
}
} catch (err) {
setError(String(err))
} finally {
setBusy(false)
}
}
if (sent) {
return <p className="hanzo-id-info">Check your inbox for a reset link.</p>
}
return (
<form onSubmit={onSubmit} className="hanzo-id-forgot-form" aria-busy={busy}>
<label>
<span>Email</span>
<input type="email" autoComplete="email" value={identifier} onChange={(e) => setIdentifier(e.target.value)} required />
</label>
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
<button type="submit" disabled={busy}>{busy ? 'Sending…' : 'Send reset link'}</button>
</form>
)
}
+77
View File
@@ -0,0 +1,77 @@
import { useState, type FormEvent } from 'react'
import type { AuthClient } from '../client'
import type { LoginResponse } from '../types'
export interface LoginFormProps {
readonly client: AuthClient
readonly redirectUri?: string
readonly state?: string
readonly clientIdOverride?: string
readonly onSuccess?: (res: LoginResponse) => void
readonly onMfaRequired?: (res: LoginResponse) => void
}
export function LoginForm(props: LoginFormProps) {
const { client } = props
const [identifier, setIdentifier] = useState('')
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
async function onSubmit(e: FormEvent) {
e.preventDefault()
setBusy(true)
setError(null)
try {
const res = await client.login({
identifier,
password,
clientId: props.clientIdOverride ?? client.tenant.clientId,
application: client.tenant.appName,
organization: client.tenant.orgId,
redirectUri: props.redirectUri,
state: props.state,
})
if (res.error) {
setError(res.error)
} else if (res.mfaRequired) {
props.onMfaRequired?.(res)
} else if (res.redirectUrl) {
window.location.href = res.redirectUrl
} else {
props.onSuccess?.(res)
}
} catch (err) {
setError(String(err))
} finally {
setBusy(false)
}
}
return (
<form onSubmit={onSubmit} className="hanzo-id-login-form" aria-busy={busy}>
<label>
<span>Email or username</span>
<input
type="text"
autoComplete="username"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
required
/>
</label>
<label>
<span>Password</span>
<input
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</label>
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
<button type="submit" disabled={busy}>{busy ? 'Signing in…' : 'Sign in'}</button>
</form>
)
}
+45
View File
@@ -0,0 +1,45 @@
import { useState, type FormEvent } from 'react'
export interface OTPFormProps {
readonly onSubmit: (code: string) => void | Promise<void>
readonly length?: number
readonly channel?: 'totp' | 'sms' | 'email'
}
export function OTPForm(props: OTPFormProps) {
const { length = 6, channel = 'totp' } = props
const [code, setCode] = useState('')
const [busy, setBusy] = useState(false)
async function onSubmit(e: FormEvent) {
e.preventDefault()
if (code.length !== length) return
setBusy(true)
try {
await props.onSubmit(code)
} finally {
setBusy(false)
}
}
const label = channel === 'sms' ? 'SMS code' : channel === 'email' ? 'Email code' : 'Authenticator code'
return (
<form onSubmit={onSubmit} className="hanzo-id-otp-form" aria-busy={busy}>
<label>
<span>{label}</span>
<input
type="text"
inputMode="numeric"
pattern={`\\d{${length}}`}
maxLength={length}
autoComplete="one-time-code"
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, length))}
required
/>
</label>
<button type="submit" disabled={busy || code.length !== length}>{busy ? 'Verifying…' : 'Verify'}</button>
</form>
)
}
+61
View File
@@ -0,0 +1,61 @@
import { useState, type FormEvent } from 'react'
import type { AuthClient } from '../client'
export interface SignupFormProps {
readonly client: AuthClient
readonly inviteCode?: string
readonly onSuccess?: () => void
}
export function SignupForm(props: SignupFormProps) {
const { client } = props
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
async function onSubmit(e: FormEvent) {
e.preventDefault()
setBusy(true)
setError(null)
try {
const res = await client.signup({
email,
password,
clientId: client.tenant.clientId,
application: client.tenant.appName,
organization: client.tenant.orgId,
inviteCode: props.inviteCode,
})
if (res.error) setError(res.error)
else if (res.redirectUrl) window.location.href = res.redirectUrl
else props.onSuccess?.()
} catch (err) {
setError(String(err))
} finally {
setBusy(false)
}
}
return (
<form onSubmit={onSubmit} className="hanzo-id-signup-form" aria-busy={busy}>
<label>
<span>Email</span>
<input type="email" autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
</label>
<label>
<span>Password</span>
<input
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
minLength={12}
required
/>
</label>
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
<button type="submit" disabled={busy}>{busy ? 'Creating account…' : 'Create account'}</button>
</form>
)
}
+4
View File
@@ -0,0 +1,4 @@
export { LoginForm } from './LoginForm'
export { SignupForm } from './SignupForm'
export { ForgotForm } from './ForgotForm'
export { OTPForm } from './OTPForm'
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"noEmit": true
},
"include": ["src"]
}
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@hanzo/id-idv",
"version": "0.1.0",
"description": "Pluggable identity verification (KYC / KYB / liveness). Provider-agnostic — wires Persona, Onfido, Veriff, Sumsub, or any custom backend behind a single React surface.",
"license": "BSD-3-Clause",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./providers/persona": "./src/providers/persona.ts",
"./providers/onfido": "./src/providers/onfido.ts",
"./providers/veriff": "./src/providers/veriff.ts",
"./providers/stub": "./src/providers/stub.ts",
"./flow": "./src/ui/IDVFlow.tsx",
"./package.json": "./package.json"
},
"files": ["src"],
"scripts": {
"tc": "tsc --noEmit"
},
"dependencies": {
"@hanzo/id-shared": "workspace:*"
},
"peerDependencies": {
"react": ">=19",
"react-dom": ">=19"
},
"devDependencies": {
"@types/react": "^19.0.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"typescript": "^5.9.3"
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from './provider'
export * from './session'
export { IDVFlow } from './ui/IDVFlow'
+78
View File
@@ -0,0 +1,78 @@
/**
* IDV (identity verification) provider contract.
*
* A provider knows how to:
* 1. mint a verification session for a given subject + flow,
* 2. resume an existing session by id,
* 3. report the terminal status (passed / failed / needs_review / expired).
*
* The portal stays agnostic of the actual vendor (Persona, Onfido, Veriff,
* Sumsub, Stripe Identity, a custom backend, or the in-process stub for
* dev). Wire whichever in `apps/web/src/main.tsx` via `setProvider(...)`.
*/
export type IDVFlowKind =
| 'kyc-basic' // ID doc + selfie
| 'kyc-enhanced' // + proof of address
| 'kyb' // business onboarding
| 'liveness' // selfie-only liveness check
| 'address-proof'
export type IDVStatus =
| 'pending'
| 'in_progress'
| 'awaiting_review'
| 'passed'
| 'failed'
| 'expired'
| 'cancelled'
export interface IDVSubject {
/** Stable subject identifier (typically the IAM user id). */
readonly subjectId: string
/** Tenant org slug (for multi-tenant providers). */
readonly orgId: string
/** Email + display name carried through for vendor pre-fill. */
readonly email?: string
readonly displayName?: string
}
export interface IDVSessionInit {
readonly subject: IDVSubject
readonly flow: IDVFlowKind
/** Where to send the user after the IDV widget completes. */
readonly redirectUri: string
/** Optional vendor-specific metadata pass-through. */
readonly metadata?: Readonly<Record<string, string>>
}
export interface IDVSessionHandle {
readonly id: string
readonly provider: string
/** URL the browser should redirect the user to (hosted vendor flow). */
readonly hostedUrl?: string
/**
* Inline embed config (when the vendor supports an in-app web SDK). The
* IDV UI mounts an iframe / web component using this token + config.
*/
readonly embed?: {
readonly sdkUrl: string
readonly token: string
readonly env?: 'sandbox' | 'production'
}
}
export interface IDVStatusReport {
readonly id: string
readonly status: IDVStatus
readonly reason?: string
/** Provider-specific result blob (audit trail). */
readonly raw?: Readonly<Record<string, unknown>>
}
export interface IDVProvider {
readonly id: string
start(init: IDVSessionInit): Promise<IDVSessionHandle>
status(sessionId: string): Promise<IDVStatusReport>
cancel?(sessionId: string): Promise<void>
}
+69
View File
@@ -0,0 +1,69 @@
import type { IDVProvider, IDVSessionHandle, IDVSessionInit, IDVStatusReport } from '../provider'
export interface OnfidoOptions {
readonly apiToken: string
readonly region?: 'eu' | 'us' | 'ca'
readonly workflowId?: string
readonly fetchImpl?: typeof fetch
}
export function createOnfidoProvider(opts: OnfidoOptions): IDVProvider {
const region = opts.region ?? 'eu'
const base = `https://api.${region}.onfido.com/v3.6`
const f = opts.fetchImpl ?? fetch
return {
id: 'onfido',
async start(init: IDVSessionInit): Promise<IDVSessionHandle> {
// Create an applicant
const applicantRes = await f(`${base}/applicants`, {
method: 'POST',
headers: { Authorization: `Token token=${opts.apiToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
first_name: init.subject.displayName?.split(' ')[0] ?? 'Applicant',
last_name: init.subject.displayName?.split(' ').slice(1).join(' ') || init.subject.subjectId,
email: init.subject.email,
external_id: init.subject.subjectId,
}),
})
if (!applicantRes.ok) throw new Error(`onfido applicant failed: ${applicantRes.status}`)
const applicant = (await applicantRes.json()) as { id: string }
// Generate SDK token for the web SDK
const tokenRes = await f(`${base}/sdk_token`, {
method: 'POST',
headers: { Authorization: `Token token=${opts.apiToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
applicant_id: applicant.id,
referrer: '*://*.hanzo.id/*',
}),
})
if (!tokenRes.ok) throw new Error(`onfido sdk_token failed: ${tokenRes.status}`)
const { token } = (await tokenRes.json()) as { token: string }
return {
id: applicant.id,
provider: 'onfido',
embed: {
sdkUrl: 'https://assets.onfido.com/web-sdk-releases/14.0.0/onfido.min.js',
token,
},
}
},
async status(sessionId: string): Promise<IDVStatusReport> {
const res = await f(`${base}/checks?applicant_id=${sessionId}`, {
headers: { Authorization: `Token token=${opts.apiToken}` },
})
if (!res.ok) throw new Error(`onfido check status failed: ${res.status}`)
const body = (await res.json()) as { checks?: Array<{ status: string; result?: string }> }
const latest = body.checks?.[0]
if (!latest) return { id: sessionId, status: 'pending' }
const status: IDVStatusReport['status'] =
latest.status === 'complete'
? latest.result === 'clear'
? 'passed'
: 'failed'
: 'in_progress'
return { id: sessionId, status, raw: latest as unknown as Readonly<Record<string, unknown>> }
},
}
}
+90
View File
@@ -0,0 +1,90 @@
import type { IDVProvider, IDVSessionHandle, IDVSessionInit, IDVStatusReport } from '../provider'
/**
* Persona (https://withpersona.com) IDV adapter.
*
* Pre-creates an Inquiry via the Persona REST API, returns the hosted
* URL for redirect. Status is read by polling the Inquiry resource.
*/
export interface PersonaOptions {
readonly templateId: string
readonly apiKey: string
/** Sandbox or production environment. */
readonly environment?: 'sandbox' | 'production'
/** Override fetch impl (testing). */
readonly fetchImpl?: typeof fetch
}
export function createPersonaProvider(opts: PersonaOptions): IDVProvider {
const base = 'https://withpersona.com/api/v1'
const f = opts.fetchImpl ?? fetch
return {
id: 'persona',
async start(init: IDVSessionInit): Promise<IDVSessionHandle> {
const res = await f(`${base}/inquiries`, {
method: 'POST',
headers: {
Authorization: `Bearer ${opts.apiKey}`,
'Content-Type': 'application/json',
'Persona-Version': '2023-01-05',
},
body: JSON.stringify({
data: {
attributes: {
'inquiry-template-id': opts.templateId,
'reference-id': init.subject.subjectId,
fields: { email: init.subject.email },
},
},
}),
})
if (!res.ok) throw new Error(`persona start failed: ${res.status}`)
const body = (await res.json()) as { data: { id: string; attributes: { 'session-token'?: string } } }
const id = body.data.id
const token = body.data.attributes['session-token']
return {
id,
provider: 'persona',
embed: token
? {
sdkUrl: 'https://cdn.withpersona.com/dist/persona-v5.1.0.js',
token,
env: opts.environment ?? 'sandbox',
}
: undefined,
hostedUrl: `https://withpersona.com/verify?inquiry-id=${id}&redirect-uri=${encodeURIComponent(init.redirectUri)}`,
}
},
async status(sessionId: string): Promise<IDVStatusReport> {
const res = await f(`${base}/inquiries/${sessionId}`, {
headers: {
Authorization: `Bearer ${opts.apiKey}`,
'Persona-Version': '2023-01-05',
},
})
if (!res.ok) throw new Error(`persona status failed: ${res.status}`)
const body = (await res.json()) as { data: { attributes: { status: string } } }
return { id: sessionId, status: mapStatus(body.data.attributes.status), raw: body.data as unknown as Readonly<Record<string, unknown>> }
},
}
}
function mapStatus(s: string): IDVStatusReport['status'] {
switch (s) {
case 'completed':
case 'approved':
return 'passed'
case 'failed':
case 'declined':
return 'failed'
case 'needs_review':
return 'awaiting_review'
case 'expired':
return 'expired'
case 'created':
case 'pending':
return 'pending'
default:
return 'in_progress'
}
}
+31
View File
@@ -0,0 +1,31 @@
import type { IDVProvider, IDVSessionHandle, IDVSessionInit, IDVStatusReport } from '../provider'
/**
* In-memory IDV stub. Always passes after a short delay. For local dev only
* — never use in any environment that resolves user identity for real.
*/
export function createStubProvider(): IDVProvider {
const sessions = new Map<string, { startedAt: number; init: IDVSessionInit }>()
return {
id: 'stub',
async start(init: IDVSessionInit): Promise<IDVSessionHandle> {
const id = `stub-${crypto.randomUUID()}`
sessions.set(id, { startedAt: Date.now(), init })
return {
id,
provider: 'stub',
hostedUrl: `${init.redirectUri}?session=${id}&status=passed`,
}
},
async status(sessionId: string): Promise<IDVStatusReport> {
const s = sessions.get(sessionId)
if (!s) return { id: sessionId, status: 'expired' }
const elapsed = Date.now() - s.startedAt
return {
id: sessionId,
status: elapsed > 2000 ? 'passed' : 'in_progress',
}
},
}
}
+54
View File
@@ -0,0 +1,54 @@
import type { IDVProvider, IDVSessionHandle, IDVSessionInit, IDVStatusReport } from '../provider'
export interface VeriffOptions {
readonly apiKey: string
readonly secret: string
readonly baseUrl?: string
readonly fetchImpl?: typeof fetch
}
export function createVeriffProvider(opts: VeriffOptions): IDVProvider {
const base = opts.baseUrl ?? 'https://stationapi.veriff.com/v1'
const f = opts.fetchImpl ?? fetch
return {
id: 'veriff',
async start(init: IDVSessionInit): Promise<IDVSessionHandle> {
const res = await f(`${base}/sessions`, {
method: 'POST',
headers: { 'X-AUTH-CLIENT': opts.apiKey, 'Content-Type': 'application/json' },
body: JSON.stringify({
verification: {
callback: init.redirectUri,
person: { firstName: init.subject.displayName ?? '', lastName: init.subject.subjectId },
vendorData: init.subject.subjectId,
},
}),
})
if (!res.ok) throw new Error(`veriff session failed: ${res.status}`)
const body = (await res.json()) as { verification: { id: string; url: string } }
return {
id: body.verification.id,
provider: 'veriff',
hostedUrl: body.verification.url,
}
},
async status(sessionId: string): Promise<IDVStatusReport> {
const res = await f(`${base}/sessions/${sessionId}/decision`, {
headers: { 'X-AUTH-CLIENT': opts.apiKey },
})
if (!res.ok) throw new Error(`veriff decision failed: ${res.status}`)
const body = (await res.json()) as { verification?: { status?: string; code?: number } }
const status: IDVStatusReport['status'] =
body.verification?.status === 'approved'
? 'passed'
: body.verification?.status === 'declined'
? 'failed'
: body.verification?.status === 'resubmission_requested'
? 'awaiting_review'
: body.verification?.status === 'expired'
? 'expired'
: 'in_progress'
return { id: sessionId, status, raw: body as unknown as Readonly<Record<string, unknown>> }
},
}
}
+29
View File
@@ -0,0 +1,29 @@
import type { IDVProvider, IDVSessionHandle, IDVSessionInit, IDVStatusReport } from './provider'
/** Registry of installed IDV providers, keyed by provider id. */
const registry = new Map<string, IDVProvider>()
let active: IDVProvider | null = null
export function registerProvider(p: IDVProvider): void {
registry.set(p.id, p)
if (!active) active = p
}
export function setActiveProvider(id: string): void {
const p = registry.get(id)
if (!p) throw new Error(`IDV provider not registered: ${id}`)
active = p
}
export function getActiveProvider(): IDVProvider {
if (!active) throw new Error('No IDV provider registered. Call registerProvider() at startup.')
return active
}
export async function startSession(init: IDVSessionInit): Promise<IDVSessionHandle> {
return getActiveProvider().start(init)
}
export async function getStatus(sessionId: string): Promise<IDVStatusReport> {
return getActiveProvider().status(sessionId)
}
+81
View File
@@ -0,0 +1,81 @@
import { useEffect, useState } from 'react'
import { getActiveProvider } from '../session'
import type { IDVFlowKind, IDVSessionHandle, IDVStatusReport, IDVSubject } from '../provider'
export interface IDVFlowProps {
readonly subject: IDVSubject
readonly flow: IDVFlowKind
/** Where to send the user after the flow completes. */
readonly redirectUri: string
/** Called as the status changes. */
readonly onChange?: (s: IDVStatusReport) => void
/** Called once the status is terminal. */
readonly onTerminal?: (s: IDVStatusReport) => void
}
const TERMINAL = new Set<IDVStatusReport['status']>(['passed', 'failed', 'expired', 'cancelled'])
export function IDVFlow(props: IDVFlowProps) {
const [handle, setHandle] = useState<IDVSessionHandle | null>(null)
const [status, setStatus] = useState<IDVStatusReport | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let stopped = false
let poll: ReturnType<typeof setInterval> | null = null
const provider = getActiveProvider()
provider
.start({ subject: props.subject, flow: props.flow, redirectUri: props.redirectUri })
.then((h) => {
if (stopped) return
setHandle(h)
// If hosted, redirect immediately
if (h.hostedUrl && !h.embed) {
window.location.href = h.hostedUrl
return
}
// Otherwise poll for status while the embed is rendered
poll = setInterval(async () => {
try {
const s = await provider.status(h.id)
if (stopped) return
setStatus(s)
props.onChange?.(s)
if (TERMINAL.has(s.status)) {
if (poll) clearInterval(poll)
props.onTerminal?.(s)
}
} catch (e) {
setError(String(e))
}
}, 4000)
})
.catch((e) => setError(String(e)))
return () => {
stopped = true
if (poll) clearInterval(poll)
}
// props.flow / props.subject changes trigger a new session; the deps list
// intentionally tracks the meaningful identity bits.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.subject.subjectId, props.flow, props.redirectUri])
if (error) return <p role="alert" className="hanzo-id-error">{error}</p>
if (!handle) return <p>Starting verification…</p>
if (handle.embed) {
return (
<div className="hanzo-id-idv-embed" data-provider={handle.provider}>
<iframe
title="Identity verification"
src={handle.embed.sdkUrl}
// The web SDKs use postMessage to receive the token — apps that
// need full SDK init should override IDVFlow with their own
// provider-specific mount. This iframe is the safe default.
style={{ width: '100%', minHeight: 600, border: 0 }}
/>
{status ? <p className="hanzo-id-idv-status">Status: {status.status}</p> : null}
</div>
)
}
return <p>Redirecting…</p>
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"noEmit": true
},
"include": ["src"]
}
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@hanzo/id-shared",
"version": "0.1.0",
"description": "Shared types + tenant resolver for the Hanzo ID portal. No UI deps.",
"license": "BSD-3-Clause",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./tenant": "./src/tenant.ts",
"./brand": "./src/brand.ts",
"./package.json": "./package.json"
},
"files": ["src"],
"scripts": {
"tc": "tsc --noEmit",
"build": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.9.3"
}
}
+51
View File
@@ -0,0 +1,51 @@
import type { BrandContract } from './types'
/**
* Resolve a BrandContract from a tenant's brand package.
*
* Each per-org brand pkg (`@hanzo/brand`, `@luxfi/brand`, `@zooai/brand`,
* `@parsdao/brand`) ships a `brand.json` at the package root. This loader
* fetches it at runtime so the portal does not need to import every brand
* package's bundle (the unused ones tree-shake away).
*
* Build-time path (server, Node): use dynamic import of the JSON.
* Runtime path (browser): fetch from `/brand/${pkg}/brand.json` (the
* Vite plugin or the Express static serves the assets from each pkg's
* `assets/` directory at this path).
*/
export async function loadBrand(brandPackage: string): Promise<BrandContract> {
// Browser: served by the app from /brand/<pkg>/brand.json
if (typeof window !== 'undefined') {
const url = `/brand/${encodeURIComponent(brandPackage)}/brand.json`
const res = await fetch(url, { cache: 'no-store' })
if (!res.ok) throw new Error(`brand.json fetch failed: ${res.status} for ${brandPackage}`)
const raw = await res.json()
return raw.brand as BrandContract
}
// Node: dynamic import (build step + SSR fallback)
const mod = (await import(/* @vite-ignore */ `${brandPackage}/brand.json`, {
with: { type: 'json' },
})) as { default: { brand: BrandContract } }
return mod.default.brand
}
/** Subset of the brand contract safe to expose to the browser as window.__BRAND__. */
export interface BrandRuntime {
readonly name: string
readonly title: string
readonly description: string
readonly logoUrl: string
readonly faviconUrl: string
readonly accentColor?: string
}
export function toBrandRuntime(b: BrandContract): BrandRuntime {
return {
name: b.name,
title: b.title,
description: b.description,
logoUrl: b.logoUrl,
faviconUrl: b.faviconUrl,
accentColor: b.accentColor,
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from './tenant'
export * from './brand'
export * from './types'
+103
View File
@@ -0,0 +1,103 @@
import type { TenantConfig } from './types'
/**
* Resolve a TenantConfig by hostname.
*
* Resolution order (first hit wins):
* 1. `IAM_TENANT_CONFIG_JSON` runtime catalog (set in K8s ConfigMap, served
* to the browser via `/config.json` at pod startup).
* 2. Built-in defaults for the four canonical Hanzo identity hosts.
* 3. `IAM_DEFAULT_ORG` (or "hanzo") fallback — used for unknown hosts
* (preview deploys, local dev, custom domains pre-launch).
*
* No hardcoded hostname switches anywhere downstream. Adding a tenant
* means editing the runtime catalog, never editing source.
*/
const TRIM_TRAILING_SLASH = (s: string): string => s.replace(/\/+$/, '')
const DEFAULT_TENANTS: Record<string, TenantConfig> = {
'hanzo.id': {
orgId: 'hanzo',
iamUrl: 'https://iam.hanzo.ai',
iamIssuer: 'https://hanzo.id',
clientId: 'hanzo-id-portal',
appName: 'hanzo-id',
publicOrigin: 'https://hanzo.id',
brandPackage: '@hanzo/brand',
},
'lux.id': {
orgId: 'lux',
iamUrl: 'https://iam.hanzo.ai',
iamIssuer: 'https://lux.id',
clientId: 'lux-id-portal',
appName: 'lux-id',
publicOrigin: 'https://lux.id',
brandPackage: '@luxfi/brand',
},
'zoo.id': {
orgId: 'zoo',
iamUrl: 'https://iam.hanzo.ai',
iamIssuer: 'https://zoo.id',
clientId: 'zoo-id-portal',
appName: 'zoo-id',
publicOrigin: 'https://zoo.id',
brandPackage: '@zooai/brand',
},
'pars.id': {
orgId: 'pars',
iamUrl: 'https://iam.hanzo.ai',
iamIssuer: 'https://pars.id',
clientId: 'pars-id-portal',
appName: 'pars-id',
publicOrigin: 'https://pars.id',
brandPackage: '@parsdao/brand',
},
}
export interface ResolveOptions {
/** Optional runtime catalog (parsed from IAM_TENANT_CONFIG_JSON or /config.json). */
readonly catalog?: Record<string, Partial<TenantConfig>>
/** Default org slug when host has no entry. */
readonly defaultOrg?: string
}
export function resolveTenant(hostname: string, opts: ResolveOptions = {}): TenantConfig {
const host = stripPort(hostname).toLowerCase()
const catalogEntry = opts.catalog?.[host]
const builtIn = DEFAULT_TENANTS[host]
if (catalogEntry || builtIn) {
const merged: TenantConfig = {
...(builtIn ?? DEFAULT_TENANTS['hanzo.id']),
...(catalogEntry ?? {}),
} as TenantConfig
return normalize(merged)
}
const defaultOrg = opts.defaultOrg ?? 'hanzo'
const fallback = DEFAULT_TENANTS[`${defaultOrg}.id`] ?? DEFAULT_TENANTS['hanzo.id']
return normalize({ ...fallback, publicOrigin: `https://${host}` })
}
function stripPort(h: string): string {
return h.replace(/:\d+$/, '')
}
function normalize(t: TenantConfig): TenantConfig {
return {
...t,
iamUrl: TRIM_TRAILING_SLASH(t.iamUrl),
iamIssuer: TRIM_TRAILING_SLASH(t.iamIssuer || t.iamUrl),
publicOrigin: TRIM_TRAILING_SLASH(t.publicOrigin),
}
}
/** Parse the runtime catalog JSON safely; returns {} on any error. */
export function parseCatalog(raw: string | undefined | null): Record<string, Partial<TenantConfig>> {
if (!raw) return {}
try {
const parsed = JSON.parse(raw)
return parsed && typeof parsed === 'object' ? parsed : {}
} catch {
return {}
}
}
+48
View File
@@ -0,0 +1,48 @@
/**
* Per-tenant configuration resolved at runtime.
*
* One image, many hosts. The portal resolves a TenantConfig for each
* incoming request by hostname; the IAM backend, OAuth client id, and
* brand package are all wired from this single object.
*/
export interface TenantConfig {
/** Tenant org slug (matches the JWT `owner` claim and the IAM `<org>-<app>` namespace). */
readonly orgId: string
/** IAM (OIDC) backend origin, no trailing slash. */
readonly iamUrl: string
/** Pinned OIDC issuer claim. Defaults to iamUrl. */
readonly iamIssuer: string
/** Default OAuth client_id (used when the request has no `?client_id=` param). */
readonly clientId: string
/** Underlying IAM application slug. */
readonly appName: string
/** Canonical public origin for the host (used for OIDC discovery rewrites). */
readonly publicOrigin: string
/** npm package name of the brand pkg to load (e.g. `@hanzo/brand`). */
readonly brandPackage: string
}
/**
* Brand contract that all per-org brand packages MUST satisfy.
* Matches the consumer contract in `@hanzo/brand` / `@luxfi/brand` /
* `@zooai/brand` / `@parsdao/brand`. Read from each pkg's `brand.json`.
*/
export interface BrandContract {
/** Org display name shown in headings ("Hanzo", "Lux", "Zoo", "Pars"). */
readonly name: string
/** Browser tab title prefix. */
readonly title: string
/** Short tagline rendered on the portal hero. */
readonly description: string
/** Marketing site (footer link target). */
readonly appDomain: string
/** Logo + favicon URLs (CDN or data URI). */
readonly logoUrl: string
readonly faviconUrl: string
/** Primary accent (CSS color string, e.g. "#ff6b35" or "var(--brand)"). */
readonly accentColor?: string
/** Optional social links rendered in the footer. */
readonly twitter?: string
readonly github?: string
readonly discord?: string
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"noEmit": true
},
"include": ["src"]
}
+3
View File
@@ -0,0 +1,3 @@
packages:
- "apps/*"
- "pkgs/*"
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
find . -type d \( -name node_modules -o -name dist -o -name .turbo -o -name .next \) -prune -exec rm -rf {} +
find . -type f -name 'tsconfig.tsbuildinfo' -delete
echo "clean done"
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"strict": true,
"noImplicitAny": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"useDefineForClassFields": true
}
}
-18
View File
@@ -1,18 +0,0 @@
#:schema node_modules/wrangler/config-schema.json
name = "hanzo-id"
compatibility_date = "2024-12-01"
compatibility_flags = ["nodejs_compat"]
pages_build_output_dir = ".vercel/output/static"
# All IAM login domains are configured as custom domains on this CF Pages project.
# The middleware resolves org-specific branding from the request hostname.
# DNS must be proxied through Cloudflare (orange cloud) for each domain.
#
# Custom domains (add via CF dashboard or `wrangler pages project ...`):
# hanzo.id, auth.hanzo.ai, lux.id, pars.id, zoo.id
# id.lux.network, id.zoo.network, iam.lux.network, id.ad.nexus
[vars]
# Default IAM backend — override per fork
IAM_ORIGIN = "https://iam.hanzo.ai"