Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
007a8fc262 | ||
|
|
4170fd1f3e | ||
|
|
cf0a70f5fa | ||
|
|
73a7d5be9a | ||
|
|
a5d696b772 | ||
|
|
7b73828908 | ||
|
|
1fccc6234c | ||
|
|
0046cb6417 | ||
|
|
12d271025a | ||
|
|
ed2b42617c | ||
|
|
664e86624b | ||
|
|
737171bc3b | ||
|
|
8241a409b8 | ||
|
|
0e01abba27 | ||
|
|
edcc3dc71e | ||
|
|
3ba61da43b | ||
|
|
b2ba1669cb | ||
|
|
1bbb6e8b5e | ||
|
|
57dec0a33e | ||
|
|
1ac00b4b2b | ||
|
|
652c8150b6 | ||
|
|
514bf2704d | ||
|
|
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,18 @@
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
push:
|
||||
branches: [main, dev, test]
|
||||
tags: ['v*']
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: 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
|
||||
# arm64 paused on DOKS (universe LLM.md 2026-04-27) — amd64-only.
|
||||
platforms: linux/amd64
|
||||
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
|
||||
|
||||
# --- 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
|
||||
COPY apps apps
|
||||
COPY pkgs pkgs
|
||||
RUN pnpm --filter @hanzo/id-web build
|
||||
|
||||
# SPA runtime — hanzoai/spa is SPA-mode-always-on, smart caching,
|
||||
# templates /public/config.json from SPA_* env vars at startup.
|
||||
FROM ghcr.io/hanzoai/spa:1.2.0
|
||||
COPY --from=build /build/apps/web/dist /public
|
||||
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"]
|
||||
|
||||
@@ -1,38 +1,131 @@
|
||||
# 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
|
||||
**Brand-neutral** white-label identity portal. Vite SPA, ships ZERO
|
||||
brand-specific data. Every per-tenant value — orgId, brandUrl, clientId,
|
||||
appName — comes from the runtime catalog (K8s ConfigMap) or is derived
|
||||
from the hostname at request time.
|
||||
|
||||
One image serves any identity host. Adding a brand never touches this repo.
|
||||
|
||||
Replaces:
|
||||
- `~/work/hanzo/hanzo.id-worker` (Cloudflare Worker) — decommissioned
|
||||
- `~/work/hanzo/id/legacy-nextjs/` (Next.js 15) — frozen, kept for diff
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
DNS → hanzo cluster ingress (129.212.164.5)
|
||||
any-host.id ──┐
|
||||
id.any-host.net ──┤ ─ TLS ─→ ingress ─ K8s ─→ Service id ─→ Deployment id
|
||||
www.any-host.id ──┘ (Traefik) ghcr.io/hanzoai/id:vX.Y.Z
|
||||
│
|
||||
│ on boot:
|
||||
│ GET /config.json (templated from SPA_IAM_TENANT_CONFIG_JSON)
|
||||
│ ↓
|
||||
│ window.__ID_CATALOG__
|
||||
▼
|
||||
resolveTenant(hostname)
|
||||
catalog ?? derive-from-hostname
|
||||
▼
|
||||
loadBrand(tenant.brandUrl)
|
||||
fetch absolute URL (jsdelivr, etc.)
|
||||
▼
|
||||
createAuthClient(tenant)
|
||||
▼
|
||||
IAM backend (same-origin /v1/iam/*)
|
||||
```
|
||||
|
||||
## Workspace
|
||||
|
||||
```
|
||||
apps/
|
||||
web/ @hanzo/id-web — Vite + React 19 + @hanzo/gui SPA. No brand deps.
|
||||
pkgs/
|
||||
shared/ @hanzo/id-shared — TenantConfig (brandUrl, not brandPackage)
|
||||
+ resolveTenant + parseCatalog + loadBrand
|
||||
auth/ @hanzo/id-auth — composable login/signup/OTP forms
|
||||
idv/ @hanzo/id-idv — pluggable identity verification (stub/persona/onfido/veriff)
|
||||
legacy-nextjs/ Frozen predecessor.
|
||||
```
|
||||
|
||||
## Brand resolution (3 layers, first non-empty wins)
|
||||
|
||||
1. **Runtime catalog** — `window.__ID_CATALOG__` populated from `/config.json`
|
||||
(templated by hanzoai/spa at pod startup from `SPA_IAM_TENANT_CONFIG_JSON`).
|
||||
This is the standard production path. Each deploy ships a `ConfigMap` with
|
||||
its full host → tenant map.
|
||||
2. **Hostname-derived defaults** — `foo.id` / `id.foo.net` / `iam.foo.net` /
|
||||
`www.foo.id` all derive `orgId=foo`, `clientId=foo-id-portal`,
|
||||
`appName=foo-id`, `brandUrl=https://cdn.jsdelivr.net/npm/@foo/brand@latest/brand.json`.
|
||||
Works out of the box when the npm scope matches the org. The catalog
|
||||
handles mismatches (`lux → @luxfi/brand`, `pars → @parsdao/brand`,
|
||||
`zoo → @zooai/brand`, `zoolabs.id → org=zoo`).
|
||||
3. The Hanzo deployment's catalog lives at
|
||||
`apps/web/k8s/tenant-catalog.yaml`. Other consumers (Liquidity,
|
||||
ad.nexus, bootno.de, ...) ship their own ConfigMap.
|
||||
|
||||
## Local dev
|
||||
|
||||
## Build & Run
|
||||
```bash
|
||||
pnpm install && pnpm build
|
||||
pnpm test
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## Structure
|
||||
```
|
||||
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/
|
||||
For multi-host preview without a deployed catalog, the hostname-derived
|
||||
defaults render the matching brand pkg from jsDelivr.
|
||||
|
||||
For an in-cluster preview with the full catalog, just hit the production
|
||||
hostnames (hostname → 129.212.164.5).
|
||||
|
||||
## Build + deploy
|
||||
|
||||
CI publishes images on tag push. Universe auto-bumps the manifest
|
||||
(`infra/k8s/operator/crs/hanzo-platform.yaml` `images:` override) on
|
||||
green CI.
|
||||
|
||||
Manual:
|
||||
```bash
|
||||
docker build -t ghcr.io/hanzoai/id:X.Y.Z .
|
||||
docker push ghcr.io/hanzoai/id:X.Y.Z
|
||||
kubectl apply -k apps/web/k8s
|
||||
```
|
||||
|
||||
## Key Files
|
||||
- `README.md` -- Project documentation
|
||||
- `package.json` -- Dependencies and scripts
|
||||
- `Dockerfile` -- Container build
|
||||
## Adding a brand
|
||||
|
||||
Brand maintainer:
|
||||
1. Publish a brand package to npm exposing `brand.json`. Convention:
|
||||
`@<org>/brand` containing
|
||||
`{ "brand": { name, title, description, appDomain, logoUrl, faviconUrl, ... } }`.
|
||||
`logoUrl` and `faviconUrl` are absolute (CDN — jsDelivr works).
|
||||
|
||||
Deploy maintainer:
|
||||
2. Add the host(s) to `apps/web/k8s/tenant-catalog.yaml` with
|
||||
`orgId` / `clientId` / `appName` / `brandUrl`. No source change.
|
||||
3. Add the host to `apps/web/k8s/ingress.yaml` for cert-manager TLS.
|
||||
4. DNS → cluster ingress IP.
|
||||
|
||||
The image never changes. There is no `pnpm add @brand/foo`, no
|
||||
`vite.config.ts` edit, no `tenant.ts` edit.
|
||||
|
||||
## Plugging an IDV provider
|
||||
|
||||
```ts
|
||||
import { registerProvider, createPersonaProvider } from '@hanzo/id-idv'
|
||||
registerProvider(createPersonaProvider({ templateId, apiKey, environment: 'production' }))
|
||||
```
|
||||
|
||||
| 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 |
|
||||
|
||||
## Backend
|
||||
|
||||
Go IAM backend at `~/work/hanzo/iam` (image `ghcr.io/hanzoai/iam`).
|
||||
This portal talks to it same-origin via Traefik file routes per-host
|
||||
(`/v1/iam/*` and `/oauth/*` paths on every identity hostname are
|
||||
proxied to the IAM service; the rest goes to this SPA).
|
||||
|
||||
@@ -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,80 @@
|
||||
# Hanzo ID — brand-neutral identity portal. Single Deployment serves
|
||||
# any identity host. Tenant resolution is per-request by hostname; the
|
||||
# runtime tenant catalog lives in the `id-tenant-catalog` ConfigMap and
|
||||
# is templated into /config.json by the spa runtime at boot.
|
||||
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
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: id-tenant-catalog
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3000
|
||||
protocol: TCP
|
||||
resources:
|
||||
requests: { cpu: 25m, memory: 64Mi }
|
||||
limits: { cpu: 500m, memory: 256Mi }
|
||||
readinessProbe:
|
||||
httpGet: { path: /health, port: 3000 }
|
||||
initialDelaySeconds: 2
|
||||
periodSeconds: 5
|
||||
livenessProbe:
|
||||
httpGet: { path: /health, port: 3000 }
|
||||
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: 3000
|
||||
protocol: TCP
|
||||
@@ -0,0 +1,141 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod-cf
|
||||
kubernetes.io/ingress.class: ingress
|
||||
name: id
|
||||
namespace: hanzo
|
||||
spec:
|
||||
rules:
|
||||
- host: hanzo.id
|
||||
http:
|
||||
paths:
|
||||
- backend:
|
||||
service:
|
||||
name: id
|
||||
port:
|
||||
number: 80
|
||||
path: /
|
||||
pathType: Prefix
|
||||
- host: lux.id
|
||||
http:
|
||||
paths:
|
||||
- backend:
|
||||
service:
|
||||
name: id
|
||||
port:
|
||||
number: 80
|
||||
path: /
|
||||
pathType: Prefix
|
||||
- host: id.lux.network
|
||||
http:
|
||||
paths:
|
||||
- backend:
|
||||
service:
|
||||
name: id
|
||||
port:
|
||||
number: 80
|
||||
path: /
|
||||
pathType: Prefix
|
||||
- host: iam.lux.network
|
||||
http:
|
||||
paths:
|
||||
- backend:
|
||||
service:
|
||||
name: id
|
||||
port:
|
||||
number: 80
|
||||
path: /
|
||||
pathType: Prefix
|
||||
- host: pars.id
|
||||
http:
|
||||
paths:
|
||||
- backend:
|
||||
service:
|
||||
name: id
|
||||
port:
|
||||
number: 80
|
||||
path: /
|
||||
pathType: Prefix
|
||||
- host: zoolabs.id
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: id
|
||||
port:
|
||||
number: 80
|
||||
- host: www.zoolabs.id
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: id
|
||||
port:
|
||||
number: 80
|
||||
- host: osage.id
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: id
|
||||
port:
|
||||
number: 80
|
||||
- host: www.osage.id
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: id
|
||||
port:
|
||||
number: 80
|
||||
- host: id.zoo.network
|
||||
http:
|
||||
paths:
|
||||
- backend:
|
||||
service:
|
||||
name: id
|
||||
port:
|
||||
number: 80
|
||||
path: /
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- hosts:
|
||||
- hanzo.id
|
||||
secretName: hanzo-id-tls
|
||||
- hosts:
|
||||
- lux.id
|
||||
secretName: lux-id-tls
|
||||
- hosts:
|
||||
- id.lux.network
|
||||
secretName: id-lux-network-tls
|
||||
- hosts:
|
||||
- iam.lux.network
|
||||
secretName: iam-lux-network-tls
|
||||
- hosts:
|
||||
- pars.id
|
||||
secretName: pars-id-tls
|
||||
- hosts:
|
||||
- zoolabs.id
|
||||
secretName: zoolabs-id-tls
|
||||
- hosts:
|
||||
- www.zoolabs.id
|
||||
secretName: www-zoolabs-id-tls
|
||||
- hosts:
|
||||
- osage.id
|
||||
secretName: osage-id-tls
|
||||
- hosts:
|
||||
- www.osage.id
|
||||
secretName: www-osage-id-tls
|
||||
- hosts:
|
||||
- id.zoo.network
|
||||
secretName: id-zoo-network-tls
|
||||
@@ -0,0 +1,12 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
namespace: hanzo
|
||||
resources:
|
||||
- tenant-catalog.yaml
|
||||
- deployment.yaml
|
||||
- ingress.yaml
|
||||
labels:
|
||||
- pairs:
|
||||
app.kubernetes.io/managed-by: universe
|
||||
app.kubernetes.io/part-of: platform
|
||||
includeSelectors: false
|
||||
@@ -0,0 +1,35 @@
|
||||
# Hanzo ID — runtime tenant catalog.
|
||||
#
|
||||
# Brand-neutral image: zero brand-specific data is bundled. This ConfigMap
|
||||
# carries the per-host overrides — `orgId`, `brandUrl`, `clientId`. The
|
||||
# image's hostname-derived defaults handle everything else (and would
|
||||
# handle the simple cases here too — `hanzo.id` → org=`hanzo` — but the
|
||||
# org-vs-npm-scope mismatches (`lux` → `@luxfi/brand`, `pars` → `@parsdao/brand`,
|
||||
# `zoolabs.id` → org=`zoo` (not `zoolabs`), etc.) require explicit overrides).
|
||||
#
|
||||
# Hanzo-ID-specific deploys (Liquidity, 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-id-portal","appName":"hanzo-id","brandUrl":"https://cdn.jsdelivr.net/npm/@hanzo/brand@latest/brand.json"},
|
||||
"lux.id": {"orgId":"lux", "clientId":"lux-id-portal", "appName":"lux-id", "brandUrl":"https://cdn.jsdelivr.net/npm/@luxfi/brand@latest/brand.json"},
|
||||
"id.lux.network": {"orgId":"lux", "clientId":"lux-id-portal", "appName":"lux-id", "brandUrl":"https://cdn.jsdelivr.net/npm/@luxfi/brand@latest/brand.json"},
|
||||
"iam.lux.network": {"orgId":"lux", "clientId":"lux-id-portal", "appName":"lux-id", "brandUrl":"https://cdn.jsdelivr.net/npm/@luxfi/brand@latest/brand.json"},
|
||||
"zoolabs.id": {"orgId":"zoo", "clientId":"zoo-id-portal", "appName":"zoo-id", "brandUrl":"https://cdn.jsdelivr.net/npm/@zooai/brand@latest/brand.json"},
|
||||
"www.zoolabs.id": {"orgId":"zoo", "clientId":"zoo-id-portal", "appName":"zoo-id", "brandUrl":"https://cdn.jsdelivr.net/npm/@zooai/brand@latest/brand.json"},
|
||||
"id.zoo.network": {"orgId":"zoo", "clientId":"zoo-id-portal", "appName":"zoo-id", "brandUrl":"https://cdn.jsdelivr.net/npm/@zooai/brand@latest/brand.json"},
|
||||
"pars.id": {"orgId":"pars", "clientId":"pars-id-portal", "appName":"pars-id", "brandUrl":"https://cdn.jsdelivr.net/npm/@parsdao/brand@latest/brand.json"},
|
||||
"id.pars.network": {"orgId":"pars", "clientId":"pars-id-portal", "appName":"pars-id", "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,28 @@
|
||||
{
|
||||
"name": "@hanzo/id-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"description": "Hanzo ID — brand-neutral white-label login / signup / IDV portal. Vite + React 19 + @hanzo/gui. One image serves any identity host; brand is fetched at runtime from the URL specified by the tenant catalog.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 5174",
|
||||
"tc": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/iam": "^0.9.4",
|
||||
"@hanzo/id-auth": "workspace:*",
|
||||
"@hanzo/id-idv": "workspace:*",
|
||||
"@hanzo/id-shared": "workspace:*",
|
||||
"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.brandUrl)
|
||||
.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="id-portal-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;
|
||||
}
|
||||
|
||||
.id-portal-page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.id-portal-brand-header {
|
||||
padding: 16px 0 32px;
|
||||
}
|
||||
|
||||
.id-portal-page main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.id-portal-page h1 { margin: 0; font-size: 28px; }
|
||||
.id-portal-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; }
|
||||
|
||||
.id-portal-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;
|
||||
}
|
||||
.id-portal-btn[aria-disabled='true'], form button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.id-portal-btn.primary { background: var(--brand); }
|
||||
|
||||
.id-portal-cta-row { display: flex; gap: 12px; }
|
||||
.id-portal-footer-links { color: var(--muted); font-size: 14px; }
|
||||
.id-portal-footer-links a { color: var(--fg); }
|
||||
|
||||
.id-portal-error {
|
||||
background: #2d0a0a;
|
||||
color: #ff7878;
|
||||
border: 1px solid #5a1414;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.id-portal-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="id-portal-brand-header">
|
||||
<a href="/" aria-label={brand.name}>
|
||||
<img src={brand.logoUrl} alt={brand.name} height={32} />
|
||||
</a>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
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())
|
||||
|
||||
/**
|
||||
* Load the runtime tenant catalog before mounting.
|
||||
* `/config.json` is templated by hanzoai/spa at pod startup from
|
||||
* `SPA_IAM_TENANT_CONFIG_JSON` (and other SPA_* env vars). Absence is fine —
|
||||
* the SPA falls back to hostname-derived defaults.
|
||||
*/
|
||||
async function loadCatalog(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch('/config.json', { cache: 'no-store' })
|
||||
if (!res.ok) return
|
||||
const cfg = await res.json()
|
||||
const raw = cfg?.iamTenantConfigJson
|
||||
if (typeof raw === 'string' && raw.length > 0) {
|
||||
;(window as unknown as { __ID_CATALOG__?: string }).__ID_CATALOG__ = raw
|
||||
}
|
||||
} catch {
|
||||
// Catalog is optional. Hostname-derived defaults will be used.
|
||||
}
|
||||
}
|
||||
|
||||
const root = document.getElementById('root')
|
||||
if (!root) throw new Error('#root missing')
|
||||
|
||||
loadCatalog().then(() => {
|
||||
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="id-portal-page id-portal-callback">
|
||||
<BrandHeader brand={brand} />
|
||||
<main>
|
||||
{error ? <p role="alert" className="id-portal-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="id-portal-page id-portal-forgot">
|
||||
<BrandHeader brand={brand} />
|
||||
<main>
|
||||
<h1>Reset your {brand.name} password</h1>
|
||||
<ForgotForm client={client} />
|
||||
<p className="id-portal-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="id-portal-page id-portal-login">
|
||||
<BrandHeader brand={brand} />
|
||||
<main>
|
||||
<h1>Sign in to {brand.name}</h1>
|
||||
<LoginForm
|
||||
client={client}
|
||||
redirectUri={redirectUri}
|
||||
state={state}
|
||||
clientIdOverride={clientIdOverride ?? undefined}
|
||||
/>
|
||||
<p className="id-portal-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="id-portal-page id-portal-portal">
|
||||
<BrandHeader brand={brand} />
|
||||
<main>
|
||||
<h1>Welcome to {brand.name}</h1>
|
||||
<p className="lede">{brand.description}</p>
|
||||
<div className="id-portal-cta-row">
|
||||
<a className="id-portal-btn primary" href="/login">Sign in</a>
|
||||
<a className="id-portal-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="id-portal-page id-portal-signup">
|
||||
<BrandHeader brand={brand} />
|
||||
<main>
|
||||
<h1>Create your {brand.name} account</h1>
|
||||
<SignupForm client={client} inviteCode={inviteCode} />
|
||||
<p className="id-portal-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,25 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { resolve, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
/**
|
||||
* Brand-neutral identity portal.
|
||||
*
|
||||
* NO brand packages are bundled. The runtime fetches `brand.json` from
|
||||
* the absolute URL supplied by the tenant catalog (typically the brand's
|
||||
* npm package served via jsDelivr, but any URL works). Adding a brand
|
||||
* means publishing a brand package and pointing the catalog at it — this
|
||||
* repo never changes.
|
||||
*/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
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.0",
|
||||
"description": "Hanzo ID — white-label login + identity verification portal (Vite + @hanzo/gui)",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"pages:build": "npx @cloudflare/next-on-pages 2>&1 || true && node scripts/patch-not-found.mjs && npx @cloudflare/next-on-pages --skip-build",
|
||||
"deploy": "pnpm pages:build && wrangler pages deploy .vercel/output/static --project-name hanzo-id --commit-dirty=true",
|
||||
"deploy:docker": "docker build -t hanzo-id . && docker push ghcr.io/hanzoai/id:latest"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "^15.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.4.0"
|
||||
"build": "pnpm -r build",
|
||||
"dev": "pnpm --filter @hanzo/id-web dev",
|
||||
"tc": "pnpm -r tc",
|
||||
"clean": "bash scripts/clean.sh"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/next-on-pages": "^1.13.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"wrangler": "^3.0.0"
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@hanzo/id-auth",
|
||||
"version": "0.1.0",
|
||||
"description": "Composable login / signup / OTP / OAuth-PKCE flows on top of @hanzo/iam. UI primitives in @hanzo/gui.",
|
||||
"license": "BSD-3-Clause",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./client": "./src/client.ts",
|
||||
"./forms": "./src/ui/index.ts",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": ["src"],
|
||||
"scripts": {
|
||||
"tc": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/id-shared": "workspace:*",
|
||||
"@hanzo/iam": "^0.9.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=19",
|
||||
"react-dom": ">=19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import type { TenantConfig } from '@hanzo/id-shared'
|
||||
import type {
|
||||
ForgotRequest,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
OAuthAuthorizeRequest,
|
||||
SignupRequest,
|
||||
TokenResponse,
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
* Composable IAM client.
|
||||
*
|
||||
* Stateless wrapper around the canonical IAM REST surface (Casdoor-compat
|
||||
* paths under `/v1/iam/*` and the OIDC paths under `/oauth/*`). One client
|
||||
* instance per tenant. The portal creates one in `createRoot()`; downstream
|
||||
* pages call `.login()`, `.signup()`, `.forgot()`, `.authorize()` directly.
|
||||
*
|
||||
* Token storage is intentionally NOT part of this client — the portal is a
|
||||
* white-label OIDC provider, so tokens are minted then immediately redirected
|
||||
* back to the requesting app via `redirectUri`. The browser never holds them
|
||||
* past the redirect.
|
||||
*/
|
||||
export interface AuthClient {
|
||||
readonly tenant: TenantConfig
|
||||
login(req: LoginRequest): Promise<LoginResponse>
|
||||
signup(req: SignupRequest): Promise<LoginResponse>
|
||||
forgot(req: ForgotRequest): Promise<{ ok: boolean; error?: string }>
|
||||
authorize(req: OAuthAuthorizeRequest): string
|
||||
exchange(code: string, codeVerifier?: string): Promise<TokenResponse>
|
||||
logout(idTokenHint?: string, postLogoutRedirectUri?: string): string
|
||||
}
|
||||
|
||||
export interface AuthClientOptions {
|
||||
readonly tenant: TenantConfig
|
||||
/** Override fetch impl (testing). Defaults to global fetch. */
|
||||
readonly fetchImpl?: typeof fetch
|
||||
}
|
||||
|
||||
export function createAuthClient(opts: AuthClientOptions): AuthClient {
|
||||
const tenant = opts.tenant
|
||||
const f = opts.fetchImpl ?? fetch
|
||||
|
||||
async function login(req: LoginRequest): Promise<LoginResponse> {
|
||||
const url = new URL('/v1/iam/login', tenant.iamUrl)
|
||||
url.searchParams.set('clientId', req.clientId)
|
||||
url.searchParams.set('application', req.application)
|
||||
url.searchParams.set('organization', req.organization)
|
||||
if (req.redirectUri) url.searchParams.set('redirect_uri', req.redirectUri)
|
||||
if (req.state) url.searchParams.set('state', req.state)
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
username: req.identifier,
|
||||
password: req.password,
|
||||
signinMethod: 'Password',
|
||||
language: 'en',
|
||||
autoSignin: true,
|
||||
}),
|
||||
})
|
||||
return parseLoginResponse(res)
|
||||
}
|
||||
|
||||
async function signup(req: SignupRequest): Promise<LoginResponse> {
|
||||
const url = new URL('/v1/iam/signup', tenant.iamUrl)
|
||||
url.searchParams.set('clientId', req.clientId)
|
||||
url.searchParams.set('application', req.application)
|
||||
url.searchParams.set('organization', req.organization)
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
email: req.email,
|
||||
password: req.password,
|
||||
inviteCode: req.inviteCode,
|
||||
}),
|
||||
})
|
||||
return parseLoginResponse(res)
|
||||
}
|
||||
|
||||
async function forgot(req: ForgotRequest): Promise<{ ok: boolean; error?: string }> {
|
||||
const url = new URL('/v1/iam/send-verification-code', tenant.iamUrl)
|
||||
url.searchParams.set('clientId', req.clientId)
|
||||
url.searchParams.set('organization', req.organization)
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ dest: req.identifier, type: 'login' }),
|
||||
})
|
||||
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
function authorize(req: OAuthAuthorizeRequest): string {
|
||||
const url = new URL('/oauth/authorize', tenant.iamUrl)
|
||||
url.searchParams.set('client_id', req.clientId)
|
||||
url.searchParams.set('redirect_uri', req.redirectUri)
|
||||
url.searchParams.set('response_type', req.responseType ?? 'code')
|
||||
url.searchParams.set('scope', req.scope ?? 'openid profile email')
|
||||
url.searchParams.set('state', req.state)
|
||||
if (req.nonce) url.searchParams.set('nonce', req.nonce)
|
||||
if (req.codeChallenge) {
|
||||
url.searchParams.set('code_challenge', req.codeChallenge)
|
||||
url.searchParams.set('code_challenge_method', req.codeChallengeMethod ?? 'S256')
|
||||
}
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
async function exchange(code: string, codeVerifier?: string): Promise<TokenResponse> {
|
||||
const url = new URL('/oauth/token', tenant.iamUrl)
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: tenant.clientId,
|
||||
redirect_uri: `${tenant.publicOrigin}/callback`,
|
||||
})
|
||||
if (codeVerifier) body.set('code_verifier', codeVerifier)
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
})
|
||||
if (!res.ok) throw new Error(`token exchange failed: ${res.status}`)
|
||||
const data = (await res.json()) as Record<string, unknown>
|
||||
return {
|
||||
accessToken: String(data.access_token ?? ''),
|
||||
refreshToken: typeof data.refresh_token === 'string' ? data.refresh_token : undefined,
|
||||
idToken: typeof data.id_token === 'string' ? data.id_token : undefined,
|
||||
tokenType: String(data.token_type ?? 'Bearer'),
|
||||
expiresIn: typeof data.expires_in === 'number' ? data.expires_in : undefined,
|
||||
scope: typeof data.scope === 'string' ? data.scope : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function logout(idTokenHint?: string, postLogoutRedirectUri?: string): string {
|
||||
const url = new URL('/oauth/logout', tenant.iamUrl)
|
||||
if (idTokenHint) url.searchParams.set('id_token_hint', idTokenHint)
|
||||
url.searchParams.set(
|
||||
'post_logout_redirect_uri',
|
||||
postLogoutRedirectUri ?? `${tenant.publicOrigin}/login`,
|
||||
)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
return { tenant, login, signup, forgot, authorize, exchange, logout }
|
||||
}
|
||||
|
||||
async function parseLoginResponse(res: Response): Promise<LoginResponse> {
|
||||
let body: Record<string, unknown> = {}
|
||||
try {
|
||||
body = (await res.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
return { error: `HTTP ${res.status} non-JSON response` }
|
||||
}
|
||||
if (!res.ok || body.status === 'error') {
|
||||
return { error: typeof body.msg === 'string' ? body.msg : `HTTP ${res.status}` }
|
||||
}
|
||||
const data = (body.data ?? body) as Record<string, unknown>
|
||||
return {
|
||||
accessToken: typeof data.access_token === 'string' ? data.access_token : undefined,
|
||||
refreshToken: typeof data.refresh_token === 'string' ? data.refresh_token : undefined,
|
||||
idToken: typeof data.id_token === 'string' ? data.id_token : undefined,
|
||||
expiresAt: typeof data.expires_at === 'number' ? data.expires_at : undefined,
|
||||
redirectUrl: typeof data.redirect_url === 'string' ? data.redirect_url : undefined,
|
||||
mfaRequired: data.mfa_required === true,
|
||||
mfaChannel: typeof data.mfa_channel === 'string' ? (data.mfa_channel as LoginResponse['mfaChannel']) : undefined,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export { createAuthClient, type AuthClient, type AuthClientOptions } from './client'
|
||||
export type {
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
SignupRequest,
|
||||
ForgotRequest,
|
||||
OAuthAuthorizeRequest,
|
||||
TokenResponse,
|
||||
} from './types'
|
||||
export * from './ui'
|
||||
@@ -0,0 +1,55 @@
|
||||
export interface LoginRequest {
|
||||
readonly identifier: string
|
||||
readonly password: string
|
||||
readonly clientId: string
|
||||
readonly application: string
|
||||
readonly organization: string
|
||||
readonly redirectUri?: string
|
||||
readonly state?: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
readonly accessToken?: string
|
||||
readonly refreshToken?: string
|
||||
readonly idToken?: string
|
||||
readonly expiresAt?: number
|
||||
readonly redirectUrl?: string
|
||||
readonly mfaRequired?: boolean
|
||||
readonly mfaChannel?: 'totp' | 'sms' | 'email'
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
export interface SignupRequest {
|
||||
readonly email: string
|
||||
readonly password: string
|
||||
readonly clientId: string
|
||||
readonly application: string
|
||||
readonly organization: string
|
||||
readonly inviteCode?: string
|
||||
}
|
||||
|
||||
export interface ForgotRequest {
|
||||
readonly identifier: string
|
||||
readonly clientId: string
|
||||
readonly organization: string
|
||||
}
|
||||
|
||||
export interface OAuthAuthorizeRequest {
|
||||
readonly clientId: string
|
||||
readonly redirectUri: string
|
||||
readonly state: string
|
||||
readonly scope?: string
|
||||
readonly nonce?: string
|
||||
readonly responseType?: 'code' | 'token'
|
||||
readonly codeChallenge?: string
|
||||
readonly codeChallengeMethod?: 'S256' | 'plain'
|
||||
}
|
||||
|
||||
export interface TokenResponse {
|
||||
readonly accessToken: string
|
||||
readonly refreshToken?: string
|
||||
readonly idToken?: string
|
||||
readonly tokenType: string
|
||||
readonly expiresIn?: number
|
||||
readonly scope?: string
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import type { AuthClient } from '../client'
|
||||
|
||||
export interface ForgotFormProps {
|
||||
readonly client: AuthClient
|
||||
readonly onSent?: () => void
|
||||
}
|
||||
|
||||
export function ForgotForm(props: ForgotFormProps) {
|
||||
const { client } = props
|
||||
const [identifier, setIdentifier] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [sent, setSent] = useState(false)
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await client.forgot({
|
||||
identifier,
|
||||
clientId: client.tenant.clientId,
|
||||
organization: client.tenant.orgId,
|
||||
})
|
||||
if (!res.ok) setError(res.error ?? 'send failed')
|
||||
else {
|
||||
setSent(true)
|
||||
props.onSent?.()
|
||||
}
|
||||
} catch (err) {
|
||||
setError(String(err))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (sent) {
|
||||
return <p className="id-portal-info">Check your inbox for a reset link.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="id-portal-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="id-portal-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="id-portal-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="id-portal-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="id-portal-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="id-portal-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="id-portal-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="id-portal-error">{error}</p>
|
||||
if (!handle) return <p>Starting verification…</p>
|
||||
if (handle.embed) {
|
||||
return (
|
||||
<div className="id-portal-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="id-portal-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,43 @@
|
||||
import type { BrandContract } from './types'
|
||||
|
||||
/**
|
||||
* Resolve a BrandContract from an absolute URL.
|
||||
*
|
||||
* No coupling to specific brand packages. The TenantConfig's `brandUrl`
|
||||
* is a full URL — npm CDN (`https://cdn.jsdelivr.net/npm/@foo/brand@latest/brand.json`),
|
||||
* a brand-owned host, or anywhere else. Whatever the URL is, it must
|
||||
* serve a JSON document `{ "brand": BrandContract }`.
|
||||
*
|
||||
* brand.json's `logoUrl` and `faviconUrl` are absolute URLs and used as-is.
|
||||
* Brands are responsible for hosting their own assets.
|
||||
*/
|
||||
export async function loadBrand(brandUrl: string): Promise<BrandContract> {
|
||||
const res = await fetch(brandUrl, { cache: 'no-store' })
|
||||
if (!res.ok) throw new Error(`brand.json fetch failed: ${res.status} for ${brandUrl}`)
|
||||
const raw = await res.json()
|
||||
if (!raw || typeof raw !== 'object' || !raw.brand) {
|
||||
throw new Error(`brand.json malformed (missing .brand): ${brandUrl}`)
|
||||
}
|
||||
return raw.brand as BrandContract
|
||||
}
|
||||
|
||||
/** 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,95 @@
|
||||
import type { TenantConfig } from './types'
|
||||
|
||||
/**
|
||||
* Resolve a TenantConfig by hostname.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. Runtime catalog (parsed from `window.__ID_CATALOG__`, served by the
|
||||
* pod's `/config.json` from `SPA_IAM_TENANT_CONFIG_JSON` at deploy time).
|
||||
* 2. Hostname-derived defaults (no brand-specific entries in source).
|
||||
*
|
||||
* The catalog supplies all brand-specific knowledge (npm-scope-vs-org
|
||||
* mismatch like `lux` → `@luxfi/brand`, custom clientId, etc.). The image
|
||||
* carries zero brand-specific data — adding a new brand never touches
|
||||
* this repo.
|
||||
*
|
||||
* Hostname derivation rules (covers `<org>.id`, `id.<org>.<tld>`,
|
||||
* `iam.<org>.<tld>`, and `www.` variants) just give a sensible default.
|
||||
* Anything more nuanced belongs in the catalog.
|
||||
*/
|
||||
|
||||
const TRIM_TRAILING_SLASH = (s: string): string => s.replace(/\/+$/, '')
|
||||
|
||||
export interface ResolveOptions {
|
||||
/** Runtime catalog, host → partial TenantConfig overrides. */
|
||||
readonly catalog?: Record<string, Partial<TenantConfig>>
|
||||
}
|
||||
|
||||
export function resolveTenant(hostname: string, opts: ResolveOptions = {}): TenantConfig {
|
||||
const host = stripPort(hostname).toLowerCase()
|
||||
const derived = deriveTenant(host)
|
||||
const override = opts.catalog?.[host] ?? {}
|
||||
return normalize({ ...derived, ...override })
|
||||
}
|
||||
|
||||
/**
|
||||
* Brand-agnostic hostname → TenantConfig derivation.
|
||||
* No hardcoded org names, no hardcoded brand packages.
|
||||
*/
|
||||
function deriveTenant(host: string): TenantConfig {
|
||||
const org = deriveOrg(host)
|
||||
return {
|
||||
orgId: org,
|
||||
iamUrl: `https://${host}`,
|
||||
iamIssuer: `https://${host}`,
|
||||
clientId: `${org}-id-portal`,
|
||||
appName: `${org}-id`,
|
||||
publicOrigin: `https://${host}`,
|
||||
brandUrl: `https://cdn.jsdelivr.net/npm/@${org}/brand@latest/brand.json`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract an org slug from a hostname.
|
||||
*
|
||||
* foo.id → foo
|
||||
* www.foo.id → foo
|
||||
* id.foo.network → foo
|
||||
* iam.foo.network → foo
|
||||
* anything else → first label
|
||||
*/
|
||||
function deriveOrg(host: string): string {
|
||||
const h = host.startsWith('www.') ? host.slice(4) : host
|
||||
if (h.endsWith('.id')) {
|
||||
const labels = h.slice(0, -3).split('.')
|
||||
return labels[labels.length - 1] || 'hanzo'
|
||||
}
|
||||
const m = /^(?:id|iam)\.([^.]+)\.[^.]+$/.exec(h)
|
||||
if (m && m[1]) return m[1]
|
||||
return h.split('.')[0] || 'hanzo'
|
||||
}
|
||||
|
||||
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),
|
||||
brandUrl: TRIM_TRAILING_SLASH(t.brandUrl),
|
||||
}
|
||||
}
|
||||
|
||||
/** 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,52 @@
|
||||
/**
|
||||
* 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 contract URL are all wired from this single object.
|
||||
*
|
||||
* NOTHING in this repo is brand-specific. Built-in defaults are derived
|
||||
* from the hostname (e.g. `foo.id` → orgId=`foo`). Any non-derivable
|
||||
* value (npm scope mismatch like `lux` → `@luxfi/brand`, custom clientId,
|
||||
* etc.) is supplied at deploy time via the runtime catalog.
|
||||
*/
|
||||
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. Defaults to same-origin. */
|
||||
readonly iamUrl: string
|
||||
/** Pinned OIDC issuer claim. Defaults to `https://<hostname>`. */
|
||||
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. */
|
||||
readonly publicOrigin: string
|
||||
/** Absolute URL to the brand contract's `brand.json` (npm CDN, brand-owned host, anywhere). */
|
||||
readonly brandUrl: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Brand contract that any brand pkg MUST satisfy.
|
||||
* Read at runtime from the URL specified by `TenantConfig.brandUrl`.
|
||||
*/
|
||||
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 (absolute — 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"
|
||||