Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e3aed3e6a | ||
|
|
40c75c77ff | ||
|
|
563d5482b7 | ||
|
|
afd9982e00 | ||
|
|
7808400d58 | ||
|
|
007a8fc262 | ||
|
|
4170fd1f3e | ||
|
|
cf0a70f5fa | ||
|
|
73a7d5be9a | ||
|
|
a5d696b772 | ||
|
|
7b73828908 | ||
|
|
1fccc6234c | ||
|
|
0046cb6417 | ||
|
|
12d271025a | ||
|
|
ed2b42617c | ||
|
|
664e86624b | ||
|
|
737171bc3b | ||
|
|
8241a409b8 | ||
|
|
0e01abba27 | ||
|
|
edcc3dc71e | ||
|
|
3ba61da43b | ||
|
|
b2ba1669cb | ||
|
|
1bbb6e8b5e | ||
|
|
57dec0a33e | ||
|
|
1ac00b4b2b | ||
|
|
85ce3e93c5 | ||
|
|
652c8150b6 | ||
|
|
514bf2704d | ||
|
|
95c9dbe899 | ||
|
|
894dd58091 | ||
|
|
b0779c7db9 | ||
|
|
8369a4a68e | ||
|
|
983c2b7a3e |
@@ -1,78 +0,0 @@
|
||||
name: Deploy to Cloudflare Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install pnpm
|
||||
run: npm i -g pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build for Cloudflare Pages
|
||||
run: |
|
||||
# First pass: builds Next.js via Vercel, may fail on _not-found Node.js function
|
||||
npx @cloudflare/next-on-pages 2>&1 || true
|
||||
# Patch out _not-found (Next.js 15 generates it as Node.js even with edge runtime)
|
||||
node scripts/patch-not-found.mjs
|
||||
# Second pass: convert pre-built output (skip build step)
|
||||
npx @cloudflare/next-on-pages --skip-build
|
||||
# Verify functions were actually generated (catches silent build failures)
|
||||
if ! ls .vercel/output/static/_worker.js/__next-on-pages-dist__/functions/*.func.js 1>/dev/null 2>&1; then
|
||||
echo "::error::Build failed — no edge functions generated. Check for TypeScript errors above."
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ Functions verified:"
|
||||
ls .vercel/output/static/_worker.js/__next-on-pages-dist__/functions/
|
||||
|
||||
- name: Fetch CF credentials from KMS
|
||||
id: kms
|
||||
env:
|
||||
KMS_CLIENT_ID: ${{ secrets.KMS_CLIENT_ID }}
|
||||
KMS_CLIENT_SECRET: ${{ secrets.KMS_CLIENT_SECRET }}
|
||||
HANZO_API_KEY: ${{ secrets.HANZO_API_KEY }}
|
||||
KMS_ENDPOINT: ${{ vars.KMS_ENDPOINT || 'https://kms.hanzo.ai' }}
|
||||
run: |
|
||||
# Auth: Universal Auth (preferred) or legacy API key
|
||||
if [ -n "${KMS_CLIENT_ID}" ] && [ -n "${KMS_CLIENT_SECRET}" ]; then
|
||||
HANZO_API_KEY=$(curl -sf "${KMS_ENDPOINT}/api/v1/auth/universal-auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"clientId\":\"${KMS_CLIENT_ID}\",\"clientSecret\":\"${KMS_CLIENT_SECRET}\"}" | jq -r '.accessToken')
|
||||
fi
|
||||
response=$(curl -sf "${KMS_ENDPOINT}/api/v3/secrets/raw?workspaceId=e1359bf4-31b4-4dfa-bb90-323e2c298ad8&secretPath=/deploy&environment=prod" \
|
||||
-H "Authorization: Bearer ${HANZO_API_KEY}" 2>/dev/null || echo "")
|
||||
if [ -n "$response" ]; then
|
||||
cf_token=$(echo "$response" | jq -r '.secrets[] | select(.secretKey=="CLOUDFLARE_API_TOKEN") | .secretValue // empty')
|
||||
cf_account=$(echo "$response" | jq -r '.secrets[] | select(.secretKey=="CLOUDFLARE_ACCOUNT_ID") | .secretValue // empty')
|
||||
fi
|
||||
if [ -z "${cf_token:-}" ]; then
|
||||
echo "::error::CF credentials not found in KMS."
|
||||
exit 1
|
||||
fi
|
||||
echo "::add-mask::${cf_token}"
|
||||
echo "::add-mask::${cf_account}"
|
||||
echo "cf_token=${cf_token}" >> "$GITHUB_OUTPUT"
|
||||
echo "cf_account=${cf_account}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ steps.kms.outputs.cf_token }}
|
||||
accountId: ${{ steps.kms.outputs.cf_account }}
|
||||
packageManager: npm
|
||||
command: pages deploy .vercel/output/static --project-name hanzo-id --commit-dirty=true
|
||||
@@ -1,38 +1,19 @@
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
push:
|
||||
branches: [main, dev, test]
|
||||
tags: ['v*']
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
jobs:
|
||||
build-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: docker/metadata-action@v5
|
||||
id: meta
|
||||
with:
|
||||
images: ghcr.io/hanzoai/hanzo-login
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=sha,prefix=,suffix=,format=short
|
||||
type=semver,pattern={{version}}
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
docker:
|
||||
uses: hanzoai/.github/.github/workflows/docker-build.yml@main
|
||||
with:
|
||||
image: ghcr.io/hanzoai/id
|
||||
pre-build-command: |
|
||||
corepack enable
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
secrets: inherit
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
name: Workflow Sanity
|
||||
on:
|
||||
pull_request:
|
||||
paths: ['.github/workflows/**']
|
||||
jobs:
|
||||
sanity:
|
||||
uses: hanzoai/.github/.github/workflows/workflow-sanity.yml@main
|
||||
@@ -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 pnpm@10.15.0 --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 downstream tenant id-app forks.** 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,39 @@
|
||||
# Hanzo ID — runtime tenant catalog.
|
||||
#
|
||||
# Brand-neutral image: zero brand-specific data is bundled. This ConfigMap
|
||||
# carries the per-host overrides — `orgId`, `brandUrl`, `clientId`, `appName`.
|
||||
# The image's hostname-derived defaults handle everything else.
|
||||
#
|
||||
# clientId/appName point at each brand's canonical IAM application (one app
|
||||
# per brand: `<org>-<app>`), which the brand console and the identity portal
|
||||
# share — the portal is the login UI for that brand's app, not a separate
|
||||
# OAuth client. (The earlier `<org>-id-portal` placeholders were never seeded
|
||||
# in IAM; the real apps are hanzo-console / lux-cloud / zoo-console /
|
||||
# pars-console.) Brand display still comes from `brandUrl` (npm `@<scope>/brand`).
|
||||
#
|
||||
# Downstream Hanzo-ID white-label deploys (ad.nexus, bootno.de, etc.)
|
||||
# ship their own ConfigMap with their own host map. The image never changes.
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: id-tenant-catalog
|
||||
namespace: hanzo
|
||||
labels:
|
||||
app: id
|
||||
app.kubernetes.io/name: id
|
||||
app.kubernetes.io/part-of: platform
|
||||
data:
|
||||
SPA_IAM_TENANT_CONFIG_JSON: |
|
||||
{
|
||||
"hanzo.id": {"orgId":"hanzo","clientId":"hanzo-console","appName":"hanzo-console","brandUrl":"https://cdn.jsdelivr.net/npm/@hanzo/brand@latest/brand.json"},
|
||||
"lux.id": {"orgId":"lux", "clientId":"lux-cloud", "appName":"lux-cloud", "brandUrl":"https://cdn.jsdelivr.net/npm/@luxfi/brand@latest/brand.json"},
|
||||
"id.lux.network": {"orgId":"lux", "clientId":"lux-cloud", "appName":"lux-cloud", "brandUrl":"https://cdn.jsdelivr.net/npm/@luxfi/brand@latest/brand.json"},
|
||||
"iam.lux.network": {"orgId":"lux", "clientId":"lux-cloud", "appName":"lux-cloud", "brandUrl":"https://cdn.jsdelivr.net/npm/@luxfi/brand@latest/brand.json"},
|
||||
"zoolabs.id": {"orgId":"zoo", "clientId":"zoo-console", "appName":"zoo-console", "brandUrl":"https://cdn.jsdelivr.net/npm/@zooai/brand@latest/brand.json"},
|
||||
"www.zoolabs.id": {"orgId":"zoo", "clientId":"zoo-console", "appName":"zoo-console", "brandUrl":"https://cdn.jsdelivr.net/npm/@zooai/brand@latest/brand.json"},
|
||||
"id.zoo.network": {"orgId":"zoo", "clientId":"zoo-console", "appName":"zoo-console", "brandUrl":"https://cdn.jsdelivr.net/npm/@zooai/brand@latest/brand.json"},
|
||||
"pars.id": {"orgId":"pars", "clientId":"pars-console", "appName":"pars-console", "brandUrl":"https://cdn.jsdelivr.net/npm/@parsdao/brand@latest/brand.json"},
|
||||
"id.pars.network": {"orgId":"pars", "clientId":"pars-console", "appName":"pars-console", "brandUrl":"https://cdn.jsdelivr.net/npm/@parsdao/brand@latest/brand.json"},
|
||||
"osage.id": {"orgId":"osage","clientId":"osage-id-portal","appName":"osage-id","brandUrl":"https://cdn.jsdelivr.net/npm/@osage/brand@latest/brand.json"},
|
||||
"www.osage.id": {"orgId":"osage","clientId":"osage-id-portal","appName":"osage-id","brandUrl":"https://cdn.jsdelivr.net/npm/@osage/brand@latest/brand.json"}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@hanzo/id-web",
|
||||
"private": true,
|
||||
"version": "0.1.1",
|
||||
"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,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||
<rect width="1024" height="1024" fill="#000000"/>
|
||||
<g transform="translate(128, 128) scale(11.46)">
|
||||
<path d="M22.21 67V44.6369H0V67H22.21Z" fill="#ffffff"/>
|
||||
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#ffffff"/>
|
||||
<path d="M22.21 0H0V22.3184H22.21V0Z" fill="#ffffff"/>
|
||||
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#ffffff"/>
|
||||
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#ffffff"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 541 B |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||
<!-- Hanzo brand mark: black square + canonical block-H. Red is the brand accent (links/CTAs); the mark itself is always black + white. -->
|
||||
<rect width="1024" height="1024" fill="#000000"/>
|
||||
<g transform="translate(128, 128) scale(11.46)">
|
||||
<path d="M22.21 67V44.6369H0V67H22.21Z" fill="#ffffff"/>
|
||||
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#ffffff"/>
|
||||
<path d="M22.21 0H0V22.3184H22.21V0Z" fill="#ffffff"/>
|
||||
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#ffffff"/>
|
||||
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#ffffff"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 683 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="1024" height="1024" fill="#000000"/>
|
||||
<text x="512" y="720" font-family="ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" font-weight="700" font-size="800" fill="#FFFFFF" text-anchor="middle" letter-spacing="-40">L</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 361 B |
@@ -0,0 +1,5 @@
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Lux brand mark: solid black square -->
|
||||
<rect width="1024" height="1024" fill="#000000"/>
|
||||
<text x="512" y="640" font-family="ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" font-weight="700" font-size="640" fill="#FFFFFF" text-anchor="middle" letter-spacing="-32">L</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 407 B |
@@ -0,0 +1,69 @@
|
||||
<svg viewBox="-120 -120 240 240" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!--
|
||||
Pars Network Logo - Persian 8-Pointed Star (Khatam/Shamseh)
|
||||
The traditional Persian geometric motif - recursive fractal star
|
||||
Used in mosques, palaces, and tilework across Persia for millennia.
|
||||
-->
|
||||
<defs>
|
||||
<linearGradient id="pars-gold" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#f5d06f"/>
|
||||
<stop offset="50%" stop-color="#caa24a"/>
|
||||
<stop offset="100%" stop-color="#f3dc8f"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="pars-blue" x1="0" y1="1" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#003355"/>
|
||||
<stop offset="50%" stop-color="#00abff"/>
|
||||
<stop offset="100%" stop-color="#66d0ff"/>
|
||||
</linearGradient>
|
||||
<filter id="pars-glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="2" result="blur"/>
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Outer 8-pointed star -->
|
||||
<g filter="url(#pars-glow)">
|
||||
<path
|
||||
d="M0,-100 L30,-60 L100,-40 L60,0 L100,40 L30,60 L0,100 L-30,60 L-100,40 L-60,0 L-100,-40 L-30,-60 Z"
|
||||
fill="none"
|
||||
stroke="url(#pars-gold)"
|
||||
stroke-width="4"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- Inner star with blue fill -->
|
||||
<path
|
||||
d="M0,-70 L22,-42 L70,-28 L42,0 L70,28 L22,42 L0,70 L-22,42 L-70,28 L-42,0 L-70,-28 L-22,-42 Z"
|
||||
fill="url(#pars-blue)"
|
||||
stroke="url(#pars-gold)"
|
||||
stroke-width="3"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
|
||||
<!-- Recursive inner star -->
|
||||
<path
|
||||
d="M0,-45 L14,-27 L45,-18 L27,0 L45,18 L14,27 L0,45 L-14,27 L-45,18 L-27,0 L-45,-18 L-14,-27 Z"
|
||||
fill="none"
|
||||
stroke="url(#pars-gold)"
|
||||
stroke-width="2"
|
||||
stroke-linejoin="round"
|
||||
opacity="0.8"
|
||||
/>
|
||||
|
||||
<!-- Interlaced circles (Persian geometric pattern) -->
|
||||
<g fill="none" stroke="#eaf7ff" stroke-width="1.5" opacity="0.6">
|
||||
<circle r="55"/>
|
||||
<circle r="35"/>
|
||||
</g>
|
||||
|
||||
<!-- Center rosette -->
|
||||
<circle r="8" fill="url(#pars-gold)"/>
|
||||
<path
|
||||
d="M0,-20 L6,-6 L20,0 L6,6 L0,20 L-6,6 L-20,0 L-6,-6 Z"
|
||||
fill="#002a47"
|
||||
stroke="url(#pars-gold)"
|
||||
stroke-width="1.5"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,38 @@
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Zoo brand mark: three-circle CMYK Venn diagram, full color.
|
||||
Filled to the viewport so it scales cleanly into 32x32 nav slots
|
||||
and 256x256 OG cards without tiny dead margins. -->
|
||||
<defs>
|
||||
<clipPath id="logoClip">
|
||||
<circle cx="512" cy="511" r="500"/>
|
||||
</clipPath>
|
||||
<clipPath id="logoYellow">
|
||||
<circle cx="512" cy="250" r="430"/>
|
||||
</clipPath>
|
||||
<clipPath id="logoMagenta">
|
||||
<circle cx="240" cy="670" r="430"/>
|
||||
</clipPath>
|
||||
<clipPath id="logoCyan">
|
||||
<circle cx="784" cy="670" r="430"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#logoClip)">
|
||||
<circle cx="512" cy="250" r="430" fill="#FCF006"/>
|
||||
<circle cx="240" cy="670" r="430" fill="#EA018E"/>
|
||||
<circle cx="784" cy="670" r="430" fill="#01ACF1"/>
|
||||
<g clip-path="url(#logoYellow)">
|
||||
<circle cx="240" cy="670" r="430" fill="#ED1C24"/>
|
||||
</g>
|
||||
<g clip-path="url(#logoYellow)">
|
||||
<circle cx="784" cy="670" r="430" fill="#00A652"/>
|
||||
</g>
|
||||
<g clip-path="url(#logoMagenta)">
|
||||
<circle cx="784" cy="670" r="430" fill="#2E3192"/>
|
||||
</g>
|
||||
<g clip-path="url(#logoYellow)">
|
||||
<g clip-path="url(#logoMagenta)">
|
||||
<circle cx="784" cy="670" r="430" fill="#000000"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -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!
|
||||
@@ -21,7 +21,7 @@ export async function GET(request: NextRequest) {
|
||||
const state = url.searchParams.get('state') || ''
|
||||
|
||||
// Call IAM logout
|
||||
const logoutUrl = new URL('/api/logout', iamOrigin)
|
||||
const logoutUrl = new URL('/v1/iam/logout', iamOrigin)
|
||||
logoutUrl.searchParams.set('id_token_hint', idTokenHint)
|
||||
logoutUrl.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri)
|
||||
logoutUrl.searchParams.set('state', state)
|
||||
@@ -65,7 +65,7 @@ export default function LoginForm({ branding }: LoginFormProps) {
|
||||
state: state || '',
|
||||
})
|
||||
|
||||
fetch(`/api/get-app-login?${params}`)
|
||||
fetch(`/v1/iam/get-app-login?${params}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
// IAM returns the app data even when status is "error"
|
||||
@@ -99,7 +99,7 @@ export default function LoginForm({ branding }: LoginFormProps) {
|
||||
const resolvedApp = appName || CLIENT_APP_MAP[clientId]?.application || clientId
|
||||
|
||||
if (isOAuthFlow) {
|
||||
// OAuth flow: direct code grant via /api/login with PKCE
|
||||
// OAuth flow: direct code grant via /v1/iam/login with PKCE.
|
||||
// Pass OAuth params (including code_challenge) as query params so IAM
|
||||
// binds the authorization code to the PKCE challenge.
|
||||
const loginParams = new URLSearchParams({
|
||||
@@ -112,7 +112,7 @@ export default function LoginForm({ branding }: LoginFormProps) {
|
||||
...(codeChallengeMethod ? { code_challenge_method: codeChallengeMethod } : {}),
|
||||
})
|
||||
|
||||
const res = await fetch(`/api/login?${loginParams}`, {
|
||||
const res = await fetch(`/v1/iam/login?${loginParams}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -271,6 +271,47 @@ export const staticBranding: Record<string, Partial<BrandingConfig>> = {
|
||||
home: 'https://lux.network',
|
||||
},
|
||||
},
|
||||
'id.lux.cloud': {
|
||||
orgId: 'lux',
|
||||
orgName: 'Lux Network',
|
||||
logo: '/logos/lux.svg',
|
||||
colors: {
|
||||
primary: '#e4e4e7',
|
||||
primaryText: '#09090b',
|
||||
background: '#000000',
|
||||
surface: '#0a0a0a',
|
||||
text: '#ffffff',
|
||||
textMuted: '#a1a1aa',
|
||||
border: '#27272a',
|
||||
error: '#dc2626',
|
||||
},
|
||||
content: {
|
||||
title: 'Start deploying in seconds',
|
||||
subtitle: 'High-performance blockchain infrastructure for the Lux ecosystem',
|
||||
tagline: 'Lux-powered infrastructure',
|
||||
quotes: [
|
||||
{
|
||||
text: "Lux is fast. We deploy chains in minutes, not weeks.",
|
||||
author: 'Validator',
|
||||
role: 'Node Operator',
|
||||
}
|
||||
],
|
||||
},
|
||||
auth: {
|
||||
passwordEnabled: true,
|
||||
codeEnabled: true,
|
||||
webauthnEnabled: true,
|
||||
faceIdEnabled: true,
|
||||
socialProviders: ['metamask', 'google', 'github'],
|
||||
},
|
||||
links: {
|
||||
terms: 'https://lux.network/terms',
|
||||
privacy: 'https://lux.network/privacy',
|
||||
support: 'https://lux.network/support',
|
||||
docs: 'https://docs.lux.network',
|
||||
home: 'https://lux.network',
|
||||
},
|
||||
},
|
||||
'id.lux.network': {
|
||||
orgId: 'lux',
|
||||
orgName: 'Lux Network',
|
||||
@@ -394,7 +435,7 @@ export const staticBranding: Record<string, Partial<BrandingConfig>> = {
|
||||
home: 'https://lux-test.network',
|
||||
},
|
||||
},
|
||||
'id.dev.hanzo.ai': {
|
||||
'id-dev.hanzo.ai': {
|
||||
orgId: 'hanzo',
|
||||
orgName: 'Hanzo (Dev)',
|
||||
logo: '/logos/hanzo.svg',
|
||||
@@ -421,7 +462,7 @@ export const staticBranding: Record<string, Partial<BrandingConfig>> = {
|
||||
socialProviders: ['metamask', 'google', 'github'],
|
||||
},
|
||||
},
|
||||
'id.test.hanzo.ai': {
|
||||
'id-test.hanzo.ai': {
|
||||
orgId: 'hanzo',
|
||||
orgName: 'Hanzo (Test)',
|
||||
logo: '/logos/hanzo.svg',
|
||||
@@ -447,7 +488,49 @@ export const staticBranding: Record<string, Partial<BrandingConfig>> = {
|
||||
faceIdEnabled: true,
|
||||
socialProviders: ['metamask', 'google', 'github'],
|
||||
},
|
||||
}, 'id.zoo.network': {
|
||||
},
|
||||
'zoolabs.id': {
|
||||
orgId: 'zoo',
|
||||
orgName: 'Zoo Labs',
|
||||
logo: '/logos/zoo.svg',
|
||||
colors: {
|
||||
primary: '#e4e4e7',
|
||||
primaryText: '#09090b',
|
||||
background: '#000000',
|
||||
surface: '#0a0a0a',
|
||||
text: '#ffffff',
|
||||
textMuted: '#a1a1aa',
|
||||
border: '#27272a',
|
||||
error: '#dc2626',
|
||||
},
|
||||
content: {
|
||||
title: 'Build the future of DeAI',
|
||||
subtitle: 'Open AI research + decentralized science for everyone',
|
||||
tagline: 'Open AI research network',
|
||||
quotes: [
|
||||
{
|
||||
text: "Zoo is where bleeding-edge DeAI experiments actually ship.",
|
||||
author: 'Researcher',
|
||||
role: 'ML Engineer',
|
||||
}
|
||||
],
|
||||
},
|
||||
auth: {
|
||||
passwordEnabled: true,
|
||||
codeEnabled: true,
|
||||
webauthnEnabled: true,
|
||||
faceIdEnabled: true,
|
||||
socialProviders: ['metamask', 'google', 'github'],
|
||||
},
|
||||
links: {
|
||||
terms: 'https://zoo.ngo/terms',
|
||||
privacy: 'https://zoo.ngo/privacy',
|
||||
support: 'https://zoo.ngo/support',
|
||||
docs: 'https://zoo.ngo/docs',
|
||||
home: 'https://zoo.ngo',
|
||||
},
|
||||
},
|
||||
'id.zoo.network': {
|
||||
orgId: 'zoo',
|
||||
orgName: 'Zoo Labs',
|
||||
logo: '/logos/zoo.svg',
|
||||
@@ -720,6 +803,25 @@ export const staticBranding: Record<string, Partial<BrandingConfig>> = {
|
||||
|
||||
// Resolve domain to branding key
|
||||
// Handles: exact match, id.{domain} → {domain}, {sub}.{domain} patterns
|
||||
|
||||
// Runtime-extensible tenants: deployments can ship additional tenant branding via
|
||||
// TENANT_BRANDING_JSON env var (a JSON object: { domain: BrandingConfig, ... }).
|
||||
// Downstream tenants and other white-label deployments can override/add tenants
|
||||
// without modifying this source.
|
||||
const ENV_TENANTS: Record<string, Partial<BrandingConfig>> = (() => {
|
||||
try {
|
||||
const raw = process.env.TENANT_BRANDING_JSON
|
||||
if (!raw) return {}
|
||||
const parsed = JSON.parse(raw)
|
||||
return typeof parsed === 'object' && parsed !== null ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
})()
|
||||
|
||||
// Merge static (compile-time) + env (runtime) tenants. Env wins.
|
||||
Object.assign(staticBranding, ENV_TENANTS)
|
||||
|
||||
export function resolveBrandingDomain(host: string): string {
|
||||
const domain = host.split(':')[0]
|
||||
|
||||
@@ -83,7 +83,7 @@ export async function passwordLogin(params: {
|
||||
clientId?: string
|
||||
redirectUri?: string
|
||||
}): Promise<{ token: string; code?: string }> {
|
||||
const url = new URL('/api/login', params.iamUrl)
|
||||
const url = new URL('/v1/iam/login', params.iamUrl)
|
||||
|
||||
// If OAuth params provided, pass as query params (camelCase — IAM convention)
|
||||
if (params.clientId && params.redirectUri) {
|
||||
@@ -44,6 +44,11 @@ const TENANTS: Record<string, TenantConfig> = {
|
||||
iamOrigin: 'https://iam.hanzo.ai',
|
||||
publicOrigin: 'https://iam.lux.network',
|
||||
},
|
||||
'id.lux.cloud': {
|
||||
org: 'lux',
|
||||
iamOrigin: 'https://iam.hanzo.ai',
|
||||
publicOrigin: 'https://id.lux.cloud',
|
||||
},
|
||||
'id.lux.network': {
|
||||
org: 'lux',
|
||||
iamOrigin: 'https://iam.hanzo.ai',
|
||||
@@ -59,15 +64,20 @@ const TENANTS: Record<string, TenantConfig> = {
|
||||
iamOrigin: 'https://iam.hanzo.ai',
|
||||
publicOrigin: 'https://id.lux-test.network',
|
||||
},
|
||||
'id.dev.hanzo.ai': {
|
||||
'id-dev.hanzo.ai': {
|
||||
org: 'hanzo',
|
||||
iamOrigin: 'https://iam.hanzo.ai',
|
||||
publicOrigin: 'https://id.dev.hanzo.ai',
|
||||
publicOrigin: 'https://id-dev.hanzo.ai',
|
||||
},
|
||||
'id.test.hanzo.ai': {
|
||||
'id-test.hanzo.ai': {
|
||||
org: 'hanzo',
|
||||
iamOrigin: 'https://iam.hanzo.ai',
|
||||
publicOrigin: 'https://id.test.hanzo.ai',
|
||||
publicOrigin: 'https://id-test.hanzo.ai',
|
||||
},
|
||||
'zoolabs.id': {
|
||||
org: 'zoo',
|
||||
iamOrigin: 'https://iam.hanzo.ai',
|
||||
publicOrigin: 'https://zoolabs.id',
|
||||
},
|
||||
'id.zoo.network': {
|
||||
org: 'zoo',
|
||||
@@ -142,29 +152,35 @@ function getTenant(hostname: string): TenantConfig {
|
||||
|
||||
// --- RFC path normalization ---
|
||||
|
||||
// PATH_REWRITES collapse RFC-standard OAuth/OIDC paths onto IAM's canonical
|
||||
// surface. IAM exposes `/v1/iam/*` natively — `/api/*` is legacy and not
|
||||
// part of the canonical surface. Every standard RFC alias funnels into a
|
||||
// `/v1/iam/*` target, exactly one way.
|
||||
const PATH_REWRITES: Record<string, string> = {
|
||||
// NOTE: /oauth/authorize is handled explicitly in middleware() — NOT here.
|
||||
// RFC 6749 — Token (exchange, refresh, client_credentials all use this)
|
||||
'/oauth/token': '/api/login/oauth/access_token',
|
||||
'/oauth/token': '/v1/iam/login/oauth/access_token',
|
||||
// RFC 7662 — Token Introspection
|
||||
'/oauth/introspect': '/api/login/oauth/introspect',
|
||||
'/oauth/introspect': '/v1/iam/login/oauth/introspect',
|
||||
// RFC 7009 — Token Revocation
|
||||
'/oauth/revoke': '/api/login/oauth/revoke',
|
||||
'/oauth/revoke': '/v1/iam/login/oauth/revoke',
|
||||
// OIDC Core — UserInfo
|
||||
'/oauth/userinfo': '/api/userinfo',
|
||||
'/oauth/userinfo': '/v1/iam/userinfo',
|
||||
// OIDC — Logout
|
||||
'/oauth/logout': '/login/oauth/logout',
|
||||
'/oauth/logout': '/v1/iam/logout',
|
||||
// RFC 8628 — Device Authorization
|
||||
'/oauth/device': '/api/login/oauth/device',
|
||||
'/oauth/device': '/v1/iam/login/oauth/device',
|
||||
// JWKS — standard /.well-known/jwks.json → IAM's /.well-known/jwks
|
||||
'/.well-known/jwks.json': '/.well-known/jwks',
|
||||
// RFC 8414 — OAuth metadata
|
||||
'/.well-known/oauth-authorization-server': '/.well-known/openid-configuration',
|
||||
}
|
||||
|
||||
// Paths to proxy to IAM backend (prefix match)
|
||||
// Paths to proxy to IAM backend (prefix match). `/v1/iam/` is the canonical
|
||||
// IAM surface — everything else here is RFC-spec aliasing that lands at IAM
|
||||
// after PATH_REWRITES normalization.
|
||||
const IAM_PATH_PREFIXES = [
|
||||
'/api/',
|
||||
'/v1/iam/',
|
||||
'/oauth/',
|
||||
'/login/oauth/',
|
||||
'/.well-known/',
|
||||
@@ -236,7 +252,7 @@ async function handleSocialProviderRedirect(
|
||||
scope: url.searchParams.get('scope') || 'openid profile email',
|
||||
state: url.searchParams.get('state') || '',
|
||||
})
|
||||
const appLoginRes = await fetch(`${tenant.iamOrigin}/api/get-app-login?${loginParams}`)
|
||||
const appLoginRes = await fetch(`${tenant.iamOrigin}/v1/iam/get-app-login?${loginParams}`)
|
||||
const appLoginData = await appLoginRes.json()
|
||||
if (appLoginData?.status === 'ok' && appLoginData.data) {
|
||||
appName = appLoginData.data.name || ''
|
||||
@@ -405,7 +421,14 @@ export async function middleware(request: NextRequest) {
|
||||
redirect: 'manual',
|
||||
})
|
||||
|
||||
// Rewrite OIDC discovery documents
|
||||
// Rewrite OIDC discovery documents.
|
||||
//
|
||||
// IAM advertises a mix of canonical (`/v1/iam/login/oauth/*`, `/v1/iam/userinfo`)
|
||||
// and OAuth2-spec (`/login/oauth/*`, `/oauth/*`) endpoints. The public RFC
|
||||
// shape on this domain is `/oauth/*` — collapse both legacy `/api/*` and
|
||||
// canonical `/v1/iam/*` rewrites onto `/oauth/*` so OIDC clients see the
|
||||
// standard surface. PATH_REWRITES handles the inbound direction
|
||||
// (RFC → canonical `/v1/iam/*` for proxying).
|
||||
const isDiscovery = pathname === '/.well-known/openid-configuration'
|
||||
|| pathname === '/.well-known/oauth-authorization-server'
|
||||
if (isDiscovery && iamResponse.ok) {
|
||||
@@ -415,15 +438,18 @@ export async function middleware(request: NextRequest) {
|
||||
let body = await iamResponse.text()
|
||||
// Rewrite IAM backend origin to public tenant origin
|
||||
body = body.replaceAll(tenant.iamOrigin, tenant.publicOrigin)
|
||||
// Normalize legacy IAM backend paths to RFC standard paths
|
||||
// Normalize IAM backend paths (both canonical /v1/iam/* and
|
||||
// legacy /api/*) to RFC standard /oauth/* surface.
|
||||
body = body.replaceAll('/v1/iam/login/oauth/authorize', '/oauth/authorize')
|
||||
body = body.replaceAll('/v1/iam/login/oauth/access_token', '/oauth/token')
|
||||
body = body.replaceAll('/v1/iam/login/oauth/refresh_token', '/oauth/token')
|
||||
body = body.replaceAll('/v1/iam/login/oauth/introspect', '/oauth/introspect')
|
||||
body = body.replaceAll('/v1/iam/login/oauth/revoke', '/oauth/revoke')
|
||||
body = body.replaceAll('/v1/iam/login/oauth/device', '/oauth/device')
|
||||
body = body.replaceAll('/v1/iam/userinfo', '/oauth/userinfo')
|
||||
body = body.replaceAll('/v1/iam/logout', '/oauth/logout')
|
||||
body = body.replaceAll('/login/oauth/authorize', '/oauth/authorize')
|
||||
body = body.replaceAll('/api/login/oauth/access_token', '/oauth/token')
|
||||
body = body.replaceAll('/api/login/oauth/refresh_token', '/oauth/token')
|
||||
body = body.replaceAll('/api/login/oauth/introspect', '/oauth/introspect')
|
||||
body = body.replaceAll('/api/login/oauth/revoke', '/oauth/revoke')
|
||||
body = body.replaceAll('/login/oauth/logout', '/oauth/logout')
|
||||
body = body.replaceAll('/api/login/oauth/device', '/oauth/device')
|
||||
body = body.replaceAll('/api/userinfo', '/oauth/userinfo')
|
||||
return new NextResponse(body, {
|
||||
status: iamResponse.status,
|
||||
headers: iamResponse.headers,
|
||||
@@ -455,7 +481,10 @@ export async function middleware(request: NextRequest) {
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/api/:path*',
|
||||
// /v1/iam/* is the canonical IAM surface — this must match so the
|
||||
// middleware proxies it to IAM_ORIGIN. Without this, CF Pages returns
|
||||
// 405 for POST /v1/iam/login because the static SPA has no POST handler.
|
||||
'/v1/iam/:path*',
|
||||
'/oauth/:path*',
|
||||
'/login/oauth/:path*',
|
||||
'/.well-known/:path*',
|
||||
@@ -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.1",
|
||||
"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,216 @@
|
||||
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 `/v1/iam/oauth/*`). One
|
||||
* client instance per tenant. The portal creates one in `createRoot()`;
|
||||
* downstream pages call `.login()`, `.signup()`, `.forgot()`, `.authorize()`
|
||||
* directly.
|
||||
*
|
||||
* Wire contract (verified against live IAM): the auth fields — `type`,
|
||||
* `application`, `organization` — are read from the request BODY; the OAuth
|
||||
* params — `clientId`, `responseType`, `redirectUri`, `scope`, `state` — ride
|
||||
* on the query string. `type=code` (a client `redirectUri` is present) returns
|
||||
* an authorization code in `data`; `type=login` (bare portal sign-in)
|
||||
* establishes the session cookie.
|
||||
*
|
||||
* 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 type = req.redirectUri ? 'code' : 'login'
|
||||
const url = new URL('/v1/iam/login', tenant.iamUrl)
|
||||
url.searchParams.set('clientId', req.clientId)
|
||||
url.searchParams.set('responseType', 'code')
|
||||
if (req.redirectUri) url.searchParams.set('redirectUri', req.redirectUri)
|
||||
url.searchParams.set('scope', 'openid profile email')
|
||||
if (req.state) url.searchParams.set('state', req.state)
|
||||
url.searchParams.set('type', type)
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
type,
|
||||
username: req.identifier,
|
||||
password: req.password,
|
||||
application: req.application,
|
||||
organization: req.organization,
|
||||
signinMethod: 'Password',
|
||||
autoSignin: true,
|
||||
}),
|
||||
})
|
||||
return parseLoginResponse(res, req)
|
||||
}
|
||||
|
||||
async function signup(req: SignupRequest): Promise<LoginResponse> {
|
||||
const url = new URL('/v1/iam/signup', tenant.iamUrl)
|
||||
url.searchParams.set('clientId', req.clientId)
|
||||
const username = req.email.split('@')[0]
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
application: req.application,
|
||||
organization: req.organization,
|
||||
username,
|
||||
name: username,
|
||||
email: req.email,
|
||||
password: req.password,
|
||||
confirm: req.password,
|
||||
autoSignin: true,
|
||||
...(req.inviteCode ? { invitationCode: 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({
|
||||
applicationId: `admin/${tenant.appName}`,
|
||||
organization: req.organization,
|
||||
dest: req.identifier,
|
||||
type: req.identifier.includes('@') ? 'email' : 'phone',
|
||||
method: 'forget',
|
||||
checkUser: req.identifier,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }
|
||||
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>
|
||||
if (body.status === 'error') return { ok: false, error: typeof body.msg === 'string' ? body.msg : 'failed' }
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
function authorize(req: OAuthAuthorizeRequest): string {
|
||||
const url = new URL('/v1/iam/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('/v1/iam/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('/v1/iam/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,
|
||||
req?: { redirectUri?: string; state?: string },
|
||||
): 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
|
||||
|
||||
// Authorization-code flow: a client redirectUri is present and `data` is the
|
||||
// freshly minted code — hand the SPA a fully-formed redirect back to the app.
|
||||
if (req?.redirectUri && typeof data === 'string' && data.length > 0) {
|
||||
const sep = req.redirectUri.includes('?') ? '&' : '?'
|
||||
return {
|
||||
redirectUrl: `${req.redirectUri}${sep}code=${encodeURIComponent(data)}&state=${encodeURIComponent(req.state ?? '')}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Bare portal sign-in: the IAM session cookie is now set; land on the portal.
|
||||
if (!req?.redirectUri) {
|
||||
return { redirectUrl: '/' }
|
||||
}
|
||||
|
||||
// Fallback: a nested token payload (future direct-token IAM responses).
|
||||
const d = (typeof data === 'object' && data ? data : body) as Record<string, unknown>
|
||||
return {
|
||||
accessToken: typeof d.access_token === 'string' ? d.access_token : undefined,
|
||||
refreshToken: typeof d.refresh_token === 'string' ? d.refresh_token : undefined,
|
||||
idToken: typeof d.id_token === 'string' ? d.id_token : undefined,
|
||||
expiresAt: typeof d.expires_at === 'number' ? d.expires_at : undefined,
|
||||
mfaRequired: d.mfa_required === true,
|
||||
mfaChannel: typeof d.mfa_channel === 'string' ? (d.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"
|
||||