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
@@ -7,3 +7,8 @@ out/
|
||||
.env
|
||||
.env.local
|
||||
.env.production.local
|
||||
# Vite + pnpm monorepo
|
||||
apps/*/dist
|
||||
pkgs/*/dist
|
||||
**/tsconfig.tsbuildinfo
|
||||
**/node_modules
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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"]
|
||||
@@ -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!
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
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 |
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { LoginForm } from './LoginForm'
|
||||
export { SignupForm } from './SignupForm'
|
||||
export { ForgotForm } from './ForgotForm'
|
||||
export { OTPForm } from './OTPForm'
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './provider'
|
||||
export * from './session'
|
||||
export { IDVFlow } from './ui/IDVFlow'
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>> }
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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>> }
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './tenant'
|
||||
export * from './brand'
|
||||
export * from './types'
|
||||
@@ -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 {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
- "apps/*"
|
||||
- "pkgs/*"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||