Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4180ac6481 | ||
|
|
99db5965cf | ||
|
|
97e799caba | ||
|
|
3df982b9fc | ||
|
|
18d0ae0eb0 | ||
|
|
a8b4d9a007 | ||
|
|
187b74cbad | ||
|
|
debd1e71bb | ||
|
|
31a720dc77 | ||
|
|
ee2eee959c | ||
|
|
965f7cfd91 | ||
|
|
ce87a7f57f | ||
|
|
9d7d64797b | ||
|
|
6d8753c9ab | ||
|
|
c7b794c857 | ||
|
|
2913f5b2db | ||
|
|
05ddadc91a | ||
|
|
8b6a6471d2 | ||
|
|
33e2cb1a0c | ||
|
|
7c2607b899 | ||
|
|
fd145f190a | ||
|
|
851282b797 | ||
|
|
5dca171915 | ||
|
|
645828077d | ||
|
|
afc6ae06fa | ||
|
|
c69d05921e | ||
|
|
7b6ef89048 | ||
|
|
dd3045757c | ||
|
|
f2211f7e27 | ||
|
|
a4c5610acc | ||
|
|
95e71621de | ||
|
|
b04faad9d8 | ||
|
|
bb972779f6 | ||
|
|
b2253b6b03 | ||
|
|
7c15bd3640 | ||
|
|
48bb754ab5 | ||
|
|
94aff5915a | ||
|
|
f5fe4086b1 | ||
|
|
563d5482b7 | ||
|
|
afd9982e00 | ||
|
|
7808400d58 | ||
|
|
007a8fc262 | ||
|
|
4170fd1f3e | ||
|
|
cf0a70f5fa | ||
|
|
73a7d5be9a | ||
|
|
a5d696b772 | ||
|
|
7b73828908 | ||
|
|
1fccc6234c | ||
|
|
0046cb6417 | ||
|
|
12d271025a | ||
|
|
ed2b42617c | ||
|
|
664e86624b | ||
|
|
737171bc3b | ||
|
|
8241a409b8 | ||
|
|
0e01abba27 | ||
|
|
edcc3dc71e | ||
|
|
3ba61da43b | ||
|
|
b2ba1669cb | ||
|
|
1bbb6e8b5e | ||
|
|
57dec0a33e | ||
|
|
1ac00b4b2b | ||
|
|
85ce3e93c5 | ||
|
|
652c8150b6 | ||
|
|
514bf2704d | ||
|
|
95c9dbe899 | ||
|
|
894dd58091 | ||
|
|
b0779c7db9 | ||
|
|
8369a4a68e | ||
|
|
983c2b7a3e |
@@ -1,78 +0,0 @@
|
||||
name: Deploy to Cloudflare Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install pnpm
|
||||
run: npm i -g pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build for Cloudflare Pages
|
||||
run: |
|
||||
# First pass: builds Next.js via Vercel, may fail on _not-found Node.js function
|
||||
npx @cloudflare/next-on-pages 2>&1 || true
|
||||
# Patch out _not-found (Next.js 15 generates it as Node.js even with edge runtime)
|
||||
node scripts/patch-not-found.mjs
|
||||
# Second pass: convert pre-built output (skip build step)
|
||||
npx @cloudflare/next-on-pages --skip-build
|
||||
# Verify functions were actually generated (catches silent build failures)
|
||||
if ! ls .vercel/output/static/_worker.js/__next-on-pages-dist__/functions/*.func.js 1>/dev/null 2>&1; then
|
||||
echo "::error::Build failed — no edge functions generated. Check for TypeScript errors above."
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ Functions verified:"
|
||||
ls .vercel/output/static/_worker.js/__next-on-pages-dist__/functions/
|
||||
|
||||
- name: Fetch CF credentials from KMS
|
||||
id: kms
|
||||
env:
|
||||
KMS_CLIENT_ID: ${{ secrets.KMS_CLIENT_ID }}
|
||||
KMS_CLIENT_SECRET: ${{ secrets.KMS_CLIENT_SECRET }}
|
||||
HANZO_API_KEY: ${{ secrets.HANZO_API_KEY }}
|
||||
KMS_ENDPOINT: ${{ vars.KMS_ENDPOINT || 'https://kms.hanzo.ai' }}
|
||||
run: |
|
||||
# Auth: Universal Auth (preferred) or legacy API key
|
||||
if [ -n "${KMS_CLIENT_ID}" ] && [ -n "${KMS_CLIENT_SECRET}" ]; then
|
||||
HANZO_API_KEY=$(curl -sf "${KMS_ENDPOINT}/api/v1/auth/universal-auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"clientId\":\"${KMS_CLIENT_ID}\",\"clientSecret\":\"${KMS_CLIENT_SECRET}\"}" | jq -r '.accessToken')
|
||||
fi
|
||||
response=$(curl -sf "${KMS_ENDPOINT}/api/v3/secrets/raw?workspaceId=e1359bf4-31b4-4dfa-bb90-323e2c298ad8&secretPath=/deploy&environment=prod" \
|
||||
-H "Authorization: Bearer ${HANZO_API_KEY}" 2>/dev/null || echo "")
|
||||
if [ -n "$response" ]; then
|
||||
cf_token=$(echo "$response" | jq -r '.secrets[] | select(.secretKey=="CLOUDFLARE_API_TOKEN") | .secretValue // empty')
|
||||
cf_account=$(echo "$response" | jq -r '.secrets[] | select(.secretKey=="CLOUDFLARE_ACCOUNT_ID") | .secretValue // empty')
|
||||
fi
|
||||
if [ -z "${cf_token:-}" ]; then
|
||||
echo "::error::CF credentials not found in KMS."
|
||||
exit 1
|
||||
fi
|
||||
echo "::add-mask::${cf_token}"
|
||||
echo "::add-mask::${cf_account}"
|
||||
echo "cf_token=${cf_token}" >> "$GITHUB_OUTPUT"
|
||||
echo "cf_account=${cf_account}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ steps.kms.outputs.cf_token }}
|
||||
accountId: ${{ steps.kms.outputs.cf_account }}
|
||||
packageManager: npm
|
||||
command: pages deploy .vercel/output/static --project-name hanzo-id --commit-dirty=true
|
||||
@@ -1,38 +1,53 @@
|
||||
name: Docker
|
||||
|
||||
# Self-contained build on Hanzo self-hosted runners. The shared reusable
|
||||
# workflow (hanzoai/.github docker-build.yml@main) is currently failing graph
|
||||
# validation for every caller (org-wide startup_failure), so this repo builds
|
||||
# its own image directly. Cluster nodes are linux/amd64 → build that arch.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
push:
|
||||
branches: [main, dev, test]
|
||||
tags: ['v*']
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
jobs:
|
||||
build-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
docker:
|
||||
# Route to the org-level ARC scale set (always-on, autoscales 0→100).
|
||||
# The bare [self-hosted, linux, amd64] labels target the native dbc/evo/
|
||||
# spark runners, which queue indefinitely when offline. ARC v0.14 routes
|
||||
# by scale-set NAME, so name the pool directly (matches universe + the
|
||||
# shared hanzoai/.github docker-build.yml default).
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Compute tags
|
||||
id: tags
|
||||
run: |
|
||||
SHA="sha-${GITHUB_SHA:0:7}"
|
||||
VER="$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' package.json | head -1)"
|
||||
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "ver=$VER" >> "$GITHUB_OUTPUT"
|
||||
echo "Tags: $SHA, $VER"
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/login-action@v3
|
||||
|
||||
- name: Login to GHCR
|
||||
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
|
||||
|
||||
- name: Build and push (linux/amd64)
|
||||
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
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/hanzoai/id:${{ steps.tags.outputs.sha }}
|
||||
ghcr.io/hanzoai/id:${{ steps.tags.outputs.ver }}
|
||||
cache-from: type=gha,scope=hanzoai-id
|
||||
cache-to: type=gha,scope=hanzoai-id,mode=max
|
||||
|
||||
@@ -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,28 @@
|
||||
FROM node:22-alpine AS base
|
||||
# syntax=docker/dockerfile:1.7
|
||||
# Hanzo ID — Vite SPA built once, served by hanzoai/spa.
|
||||
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/
|
||||
COPY pkgs/onboarding/package.json pkgs/onboarding/
|
||||
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 server stage — hanzoai/spa is the correct base for a Vite SPA:
|
||||
# history-API fallthrough for client-side routes AND a SPA-safe CSP.
|
||||
# hanzoai/static defaults to `Content-Security-Policy: default-src 'none'`
|
||||
# (built for static assets, not an app that loads its own bundle), which
|
||||
# blocks the SPA's own scripts and leaves a blank page. hanzoai/spa serves
|
||||
# index.html for all routes with a sane CSP. Defaults: PORT=3000, ROOT=/public.
|
||||
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,223 @@
|
||||
# LLM.md - Hanzo Id
|
||||
# LLM.md — Hanzo ID
|
||||
|
||||
## Overview
|
||||
White-label login portal for Hanzo IAM - forkable, multi-tenant, RFC-compliant OAuth2/OIDC
|
||||
## PKCE on password login (fixed 0.1.13)
|
||||
|
||||
## Tech Stack
|
||||
- **Language**: TypeScript/JavaScript
|
||||
`client.login()` (POST `/v1/iam/login`) must forward `code_challenge`
|
||||
/`code_challenge_method` on the QUERY string, exactly like `authorize()`.
|
||||
IAM's Login handler (`hanzoai/iam` `controllers/account.go`) reads
|
||||
`code_challenge` from the query first, body fallback, then threads it into
|
||||
`GetOAuthCode` so the minted code stores the challenge. Omitting it makes
|
||||
IAM store an EMPTY challenge; the downstream public SPA client (e.g.
|
||||
`hanzo-platform`) then fails token exchange with
|
||||
`token.CodeChallenge: empty` → `invalid_client` (it falls back to a
|
||||
client_secret check the public client can't satisfy — see
|
||||
`object/token_oauth.go:809-844`: with a non-empty stored challenge AND no
|
||||
client_secret sent, the secret check is bypassed). Social login was never
|
||||
affected (it rides `authorize()`). Plumb the URL's `code_challenge`
|
||||
through Login page → LoginForm → `client.login()`; do NOT default a
|
||||
challenge — only forward what the downstream OAuth request put on the URL.
|
||||
|
||||
Known SEPARATE blocker (NOT this repo): after a 200 token exchange,
|
||||
platform's `/v1/iam/session` (`hanzo/platform` `pkg/platform/src/lib/iam.ts`
|
||||
→ `@hanzo/iam/server` `getServerSession`) returns 401 "Invalid IAM token"
|
||||
for a valid `aud:[hanzo-platform]` RS256 JWT signed by `cert-hanzo` (key IS
|
||||
in hanzo.id JWKS; the duplicate `cert-hanzo` entry is identical, harmless).
|
||||
That is a platform/SDK-side verification bug, tracked separately.
|
||||
|
||||
## What this is
|
||||
|
||||
White-label login + identity verification portal. Vite SPA, served from
|
||||
the Hanzo K8s cluster, white-labels per hostname.
|
||||
|
||||
Replaces:
|
||||
- `~/work/hanzo/hanzo.id-worker` (Cloudflare Worker) — being decommissioned
|
||||
- `~/work/hanzo/id/legacy-nextjs/` (Next.js 15) — frozen, kept for diff
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
DNS → hanzo cluster ingress (129.212.164.5)
|
||||
hanzo.id ──┐
|
||||
lux.id ──┤ ─ TLS → ingress ─ K8s ─→ Service id ─→ Deployment id (2 replicas)
|
||||
zoo.id ──┤ (Traefik) ghcr.io/hanzoai/id:vX.Y.Z
|
||||
pars.id ──┘ │
|
||||
│ on boot:
|
||||
│ resolveTenant(hostname)
|
||||
│ loadBrand(tenant.brandPackage)
|
||||
▼
|
||||
@hanzo|luxfi|zooai|parsdao /brand
|
||||
│
|
||||
▼
|
||||
createAuthClient(tenant)
|
||||
│
|
||||
▼
|
||||
per-brand OIDC issuer host: hanzo.id / lux.id /
|
||||
zoo.id / pars.id (serves /.well-known + /v1/iam/*;
|
||||
same Casdoor-fork backend, tenant-scoped by org)
|
||||
│
|
||||
▼
|
||||
iam-* postgres in hanzo namespace
|
||||
```
|
||||
|
||||
`iamUrl` is the brand's OWN `*.id` host, NOT `iam.hanzo.ai` (HIP-0111:
|
||||
discovery must be host-relative or the SDK resolves to the IAM SPA HTML
|
||||
catch-all). One backend serves every brand behind its issuer host.
|
||||
|
||||
## Auth methods — the full set
|
||||
|
||||
Email/password + email/SMS code + GitHub + Google + Web3 (wallet). The enabled
|
||||
set is read LIVE from `/v1/iam/get-app-login` (`AuthClient.getAppLogin`), which
|
||||
mirrors each `-id` app's provider config in
|
||||
`universe/infra/k8s/iam/init_data.json`. Password sign-in goes through the IAM
|
||||
REST `login` and returns an auth code directly. Both honor a downstream
|
||||
`redirect_uri`.
|
||||
|
||||
### Social providers — render only when configured; redirect via the "hop"
|
||||
|
||||
`SocialButtons` renders ONLY providers IAM holds a REAL credential for
|
||||
(`AppProvider.configured` = non-placeholder clientId). With the seed's
|
||||
placeholders every social button is hidden, so a user never hits a dead-end;
|
||||
they reappear automatically once real creds land. Clicking a configured OAuth
|
||||
provider runs the **hop** (`social.ts::startProviderLogin`), which redirects
|
||||
straight to the provider with a base64 `state` that round-trips the original
|
||||
authorize request — matching the IAM (Casdoor) `getAuthUrl` contract. The
|
||||
provider returns to `/callback`; `Callback.tsx` detects the provider state and
|
||||
calls `client.providerLogin` to exchange the code at the IAM backend, then
|
||||
follows the continue-URL (which re-enters `/callback` as the normal OIDC code).
|
||||
(NOT `@hanzo/iam` `signinRedirect` — that loops back to the login page.)
|
||||
|
||||
**To ENABLE real social login (the only remaining work):**
|
||||
1. Register an OAuth app per provider (GitHub/Google) with callback
|
||||
**`https://<brand>/v1/iam/callback`** AND the app authorize redirect
|
||||
`https://<brand>/callback` (per brand host: hanzo.id, lux.id, pars.id …).
|
||||
2. Put the client id/secret in KMS at **project `hanzo-iam`, env `prod`**, keys
|
||||
`IAM_GITHUB_CLIENT_ID` / `IAM_GITHUB_CLIENT_SECRET` (and `IAM_GOOGLE_*`). The
|
||||
`iam-kms-sync` KMSSecret (`universe/infra/k8s/iam/secret.yaml`) syncs that
|
||||
path into `iam-secrets`; init_data.json substitutes `${IAM_GITHUB_CLIENT_ID}`
|
||||
at deploy. The whole sync + env-ref chain already exists — today those keys
|
||||
just hold placeholder values, so providers read as unconfigured (buttons
|
||||
hidden). Replace the values; nothing else to wire.
|
||||
3. The buttons appear automatically (no portal change). **Live-verify** the
|
||||
round-trip reaches the provider and completes — the hop + exchange are wired
|
||||
and unit-tested (`pkgs/auth/src/social.test.ts`) but can only be exercised
|
||||
end-to-end once real creds exist.
|
||||
|
||||
## Workspace
|
||||
|
||||
```
|
||||
apps/
|
||||
web/ @hanzo/id-web — Vite + React 19 + @hanzo/gui SPA
|
||||
pkgs/
|
||||
shared/ @hanzo/id-shared — TenantConfig, resolveTenant, loadBrand
|
||||
auth/ @hanzo/id-auth — composable login/signup/OTP/forgot forms +
|
||||
SocialButtons (GitHub/Google/Web3) +
|
||||
AuthClient (wraps @hanzo/iam REST + SDK PKCE)
|
||||
onboarding/ @hanzo/id-onboarding — post-login org → project → wallet flow.
|
||||
domain (serializable step machine) / service
|
||||
(IAM-backed writes) / ui (self-contained flow).
|
||||
Tests: `pnpm --filter @hanzo/id-onboarding test`
|
||||
(Node built-in runner, no test-framework dep).
|
||||
idv/ @hanzo/id-idv — pluggable identity verification
|
||||
(Persona, Onfido, Veriff, stub)
|
||||
legacy-nextjs/ Frozen predecessor. Delete after v0.1.0 ships.
|
||||
```
|
||||
|
||||
## Why this layout (and not just Next.js)
|
||||
|
||||
1. **Vite > Next.js for an SPA.** No SSR needed; auth is post-load only.
|
||||
~200kb gzip vs ~600kb. Build is 3s vs 90s.
|
||||
2. **@hanzo/gui v7 is the canonical UI.** Same shell as every other
|
||||
Hanzo admin surface. No bespoke components.
|
||||
3. **Per-org brand packages.** `@hanzo/brand`, `@luxfi/brand`,
|
||||
`@zooai/brand`, `@parsdao/brand` already ship `brand.json` files;
|
||||
we fetch them at runtime. Adding a brand = `pnpm add @newco/brand`
|
||||
+ one line in `pkgs/shared/src/tenant.ts` (or a runtime catalog
|
||||
entry — no rebuild).
|
||||
4. **Provider-pluggable IDV.** Same surface for Persona, Onfido, Veriff,
|
||||
custom backends. No vendor lock-in at the portal layer.
|
||||
5. **Mirrors downstream tenant id-app forks.** Same `apps/` + `pkgs/`
|
||||
pattern, same `@hanzo/gui` shell, same per-tenant brand resolution.
|
||||
|
||||
## Local dev
|
||||
|
||||
## Build & Run
|
||||
```bash
|
||||
pnpm install && pnpm build
|
||||
pnpm test
|
||||
pnpm install
|
||||
pnpm dev # http://localhost:5173 → defaults to hanzo brand
|
||||
```
|
||||
|
||||
## Structure
|
||||
For multi-tenant preview:
|
||||
```
|
||||
id/
|
||||
Dockerfile
|
||||
LICENSE
|
||||
README.md
|
||||
app/
|
||||
components/
|
||||
config/
|
||||
lib/
|
||||
middleware.ts
|
||||
next-env.d.ts
|
||||
next.config.ts
|
||||
package.json
|
||||
pnpm-lock.yaml
|
||||
postcss.config.mjs
|
||||
public/
|
||||
scripts/
|
||||
echo "127.0.0.1 lux.id zoo.id pars.id" | sudo tee -a /etc/hosts
|
||||
```
|
||||
then visit `http://lux.id:5173` etc.
|
||||
|
||||
## Build + deploy
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
# Deploy is managed in hanzoai/universe (infra/k8s/id/): operator/kustomize
|
||||
# applies it; CI image bump flows via universe. App repo ships the
|
||||
# brand-neutral image + Dockerfile only — no deploy/brand config here.
|
||||
```
|
||||
|
||||
## Key Files
|
||||
- `README.md` -- Project documentation
|
||||
- `package.json` -- Dependencies and scripts
|
||||
- `Dockerfile` -- Container build
|
||||
## Adding a new brand
|
||||
|
||||
1. `pnpm add -F @hanzo/id-web @newco/brand`
|
||||
2. Add `TenantConfig` for the hostname in `pkgs/shared/src/tenant.ts`
|
||||
(or put it in `IAM_TENANT_CONFIG_JSON` at runtime — no rebuild).
|
||||
3. Add the hostname to `BRAND_PACKAGES` in `apps/web/vite.config.ts`.
|
||||
4. Add host + TLS secret to `apps/web/k8s/ingress.yaml`.
|
||||
5. DNS → cluster ingress IP.
|
||||
|
||||
## Plugging an IDV provider
|
||||
|
||||
```ts
|
||||
import { registerProvider, createPersonaProvider } from '@hanzo/id-idv'
|
||||
registerProvider(createPersonaProvider({ templateId, apiKey, environment: 'production' }))
|
||||
```
|
||||
|
||||
## Pre-built providers
|
||||
|
||||
| id | source | docs |
|
||||
|---|---|---|
|
||||
| `stub` | `@hanzo/id-idv/providers/stub` | in-memory, dev-only |
|
||||
| `persona` | `@hanzo/id-idv/providers/persona` | https://docs.withpersona.com |
|
||||
| `onfido` | `@hanzo/id-idv/providers/onfido` | https://documentation.onfido.com |
|
||||
| `veriff` | `@hanzo/id-idv/providers/veriff` | https://developers.veriff.com |
|
||||
|
||||
Custom providers: implement the `IDVProvider` interface in
|
||||
`pkgs/idv/src/provider.ts` and register it.
|
||||
|
||||
## Cutover from the CF Worker
|
||||
|
||||
1. Build + push image (PR scaffolds; image bump comes after CI green).
|
||||
2. `kubectl apply -k apps/web/k8s` — Deployment + Service + Ingress live.
|
||||
3. cert-manager issues TLS for all 4 hosts.
|
||||
4. Remove the Cloudflare Worker routes for the 4 identity hosts.
|
||||
5. Repoint CF A records: `hanzo.id`, `lux.id`, `zoo.id`, `pars.id` →
|
||||
`129.212.164.5` (hanzo cluster ingress LB), CF-proxied.
|
||||
6. Verify each host loads its own brand.
|
||||
7. Archive `hanzo.id-worker` repo.
|
||||
|
||||
## Backend
|
||||
|
||||
The Go IAM backend lives at `~/work/hanzo/iam` (Casdoor fork, module
|
||||
`github.com/hanzoai/iam`, image `ghcr.io/hanzoai/iam`). All paths are under
|
||||
the `/v1/iam` prefix — no legacy `/oauth/*`, no `/api/`. This portal talks
|
||||
to it via:
|
||||
|
||||
- auth (`pkgs/auth/src/client.ts`): `/v1/iam/login` `/v1/iam/signup`
|
||||
`/v1/iam/send-verification-code` `/v1/iam/get-app-login`, and the OIDC
|
||||
PKCE endpoints `/v1/iam/oauth/{authorize,token,userinfo,logout}` (via the
|
||||
`@hanzo/iam` SDK).
|
||||
- onboarding (`pkgs/onboarding/src/service/onboarding.ts`):
|
||||
`/v1/iam/get-organizations` (allowed for any signed-in user, scoped to
|
||||
their memberships server-side), `/v1/iam/add-organization` +
|
||||
`/v1/iam/add-project` (admin-gated in IAM authz — the create path surfaces
|
||||
a permission message for non-admins and stays skippable),
|
||||
`/v1/iam/get-account` + `/v1/iam/update-user?columns=web3onboard` (wallet
|
||||
link).
|
||||
|
||||
All hostnames talk to the same IAM backend — the org is carried in the
|
||||
request body (`organization: <orgId>`), and the IAM backend tenant-scopes
|
||||
on that.
|
||||
|
||||
@@ -1,146 +1,73 @@
|
||||
# Hanzo ID - Hosted Login Pages
|
||||
# @hanzo/id
|
||||
|
||||
Configurable, white-label login pages for Hanzo IAM. Each organization can customize their login experience based on their domain (CNAME).
|
||||
White-label login + identity verification portal. One Vite SPA, four hosts
|
||||
(`hanzo.id`, `lux.id`, `zoo.id`, `pars.id`), per-tenant brand resolved from
|
||||
the request hostname at runtime.
|
||||
|
||||
## Architecture
|
||||
## Layout
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ hanzo.id │ │ pars.id │ │ lux.id │
|
||||
│ (CNAME) │ │ (CNAME) │ │ (CNAME) │
|
||||
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
|
||||
│ │ │
|
||||
└───────────────────┴───────────────────┘
|
||||
│
|
||||
┌──────▼──────┐
|
||||
│ Hanzo ID │ ← This repo (frontend)
|
||||
│ (Next.js) │
|
||||
└──────┬──────┘
|
||||
│
|
||||
┌──────▼──────┐
|
||||
│ Hanzo IAM │ ← Backend auth services
|
||||
│ (Go API) │
|
||||
└─────────────┘
|
||||
apps/
|
||||
web/ Vite + React 19 + @hanzo/gui — the actual SPA
|
||||
k8s/ Deployment + Service + Ingress (4 hosts, 4 TLS secrets)
|
||||
pkgs/
|
||||
shared/ @hanzo/id-shared — TenantConfig + brand resolver
|
||||
auth/ @hanzo/id-auth — composable login/signup/OTP flows
|
||||
on top of @hanzo/iam SDK
|
||||
idv/ @hanzo/id-idv — pluggable identity verification
|
||||
(Persona, Onfido, Veriff, stub)
|
||||
legacy-nextjs/ Frozen — predecessor Next.js implementation. Kept
|
||||
for reference until v0.1.0 ships to production.
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Domain-based branding**: Logo, colors, content based on CNAME
|
||||
- **Configurable auth methods**: Password, code, WebAuthn, Face ID
|
||||
- **Social providers**: Google, GitHub, and more
|
||||
- **Customizable content**: Quotes, testimonials, feature highlights
|
||||
- **Dark mode by default**: Clean, modern design
|
||||
- **Easy to fork**: Simple structure for white-labeling
|
||||
|
||||
## Configuration
|
||||
|
||||
Branding can be configured in two ways:
|
||||
|
||||
### 1. Static Configuration (for known domains)
|
||||
|
||||
Edit `lib/branding.ts` to add your domain:
|
||||
|
||||
```typescript
|
||||
export const staticBranding: Record<string, Partial<BrandingConfig>> = {
|
||||
'your-domain.com': {
|
||||
orgId: 'your-org',
|
||||
orgName: 'Your Organization',
|
||||
logo: '/logos/your-logo.svg',
|
||||
colors: {
|
||||
primary: '#3b82f6',
|
||||
primaryText: '#ffffff',
|
||||
background: '#000000',
|
||||
surface: '#0a0a0a',
|
||||
text: '#ffffff',
|
||||
textMuted: '#a1a1aa',
|
||||
border: '#27272a',
|
||||
error: '#dc2626',
|
||||
},
|
||||
content: {
|
||||
title: 'Welcome to Your App',
|
||||
subtitle: 'Sign in to continue',
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Dynamic Configuration (from IAM backend)
|
||||
|
||||
The login page fetches branding from IAM API:
|
||||
|
||||
```
|
||||
GET https://api.hanzo.id/api/branding?domain=your-domain.com
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"orgId": "your-org",
|
||||
"orgName": "Your Organization",
|
||||
"logo": "https://...",
|
||||
"colors": { ... },
|
||||
"content": { ... },
|
||||
"links": { ... },
|
||||
"auth": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
## Development
|
||||
## Local dev
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start development server
|
||||
npm run dev
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
|
||||
# Start production server
|
||||
npm start
|
||||
pnpm install
|
||||
pnpm dev # http://localhost:5173 (defaults to hanzo brand)
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
To preview a different brand locally, edit `/etc/hosts`:
|
||||
|
||||
```
|
||||
127.0.0.1 lux.id zoo.id pars.id
|
||||
```
|
||||
|
||||
then visit `http://lux.id:5173`.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
# IAM backend URL
|
||||
HANZO_IAM_URL=https://api.hanzo.id
|
||||
|
||||
# Public IAM URL (for client-side redirects)
|
||||
NEXT_PUBLIC_IAM_URL=https://api.hanzo.id
|
||||
pnpm build # builds apps/web -> dist/
|
||||
docker build -t ghcr.io/hanzoai/id:0.1.0 .
|
||||
```
|
||||
|
||||
## Forking for White-Label
|
||||
## Adding a brand
|
||||
|
||||
1. Fork this repository
|
||||
2. Update `lib/branding.ts` with your default branding
|
||||
3. Add your logo to `public/logos/`
|
||||
4. Update `app/globals.css` for custom styling
|
||||
5. Deploy to your infrastructure
|
||||
1. Publish or workspace-link the new per-org brand pkg (must ship
|
||||
`brand.json` at the package root and match the `BrandContract` shape
|
||||
in `pkgs/shared/src/types.ts`).
|
||||
2. Add a `DEFAULT_TENANTS` entry in `pkgs/shared/src/tenant.ts` OR put
|
||||
the override in the runtime catalog (`IAM_TENANT_CONFIG_JSON` env)
|
||||
so no rebuild is needed.
|
||||
3. Add the hostname to `apps/web/vite.config.ts::BRAND_PACKAGES`
|
||||
(lets dev + build serve `/brand/<pkg>/brand.json`).
|
||||
4. Add the hostname + TLS secret to `apps/web/k8s/ingress.yaml`.
|
||||
5. DNS: CNAME or A record → cluster ingress IP.
|
||||
|
||||
## Directory Structure
|
||||
That's it — no per-brand Worker, no per-brand image, no per-brand
|
||||
deployment. One binary, four brands.
|
||||
|
||||
```
|
||||
hanzo-id/
|
||||
├── app/
|
||||
│ ├── layout.tsx # Root layout with metadata
|
||||
│ ├── page.tsx # Redirects to /login
|
||||
│ ├── login/
|
||||
│ │ └── page.tsx # Main login page
|
||||
│ ├── signup/ # Sign up page
|
||||
│ ├── forgot-password # Password reset
|
||||
│ └── callback/ # OAuth callback handler
|
||||
├── components/
|
||||
│ ├── LoginForm.tsx # Login form component
|
||||
│ └── MarketingPanel.tsx # Right side marketing content
|
||||
├── lib/
|
||||
│ └── branding.ts # Branding configuration
|
||||
├── public/
|
||||
│ └── logos/ # Organization logos
|
||||
└── config/ # Additional configuration
|
||||
## Plugging an IDV provider
|
||||
|
||||
```ts
|
||||
import { registerProvider, createPersonaProvider } from '@hanzo/id-idv'
|
||||
registerProvider(createPersonaProvider({
|
||||
templateId: import.meta.env.VITE_PERSONA_TEMPLATE_ID,
|
||||
apiKey: import.meta.env.VITE_PERSONA_API_KEY,
|
||||
environment: 'production',
|
||||
}))
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT - Fork and customize freely!
|
||||
The portal stays unchanged — switching providers is a single registration
|
||||
call at boot.
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
'use client'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { fetchUserInfo } from '@/lib/oauth'
|
||||
import { getIamUrl, getOrg } from '@/lib/iam'
|
||||
import { staticBranding, defaultBranding, resolveBrandingDomain, type BrandingConfig } from '@/lib/branding'
|
||||
|
||||
interface User {
|
||||
sub: string
|
||||
name?: string
|
||||
displayName?: string
|
||||
email?: string
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
// Per-org app links
|
||||
const orgApps: Record<string, { name: string; href: string; description: string }[]> = {
|
||||
hanzo: [
|
||||
{ name: 'Console', href: 'https://console.hanzo.ai', description: 'Observability & traces' },
|
||||
{ name: 'Chat', href: 'https://hanzo.chat', description: 'AI chat interface' },
|
||||
{ name: 'Cloud', href: 'https://cloud.hanzo.ai', description: 'AI model API' },
|
||||
{ name: 'Analytics', href: 'https://analytics.hanzo.ai', description: 'Web analytics' },
|
||||
{ name: 'Platform', href: 'https://platform.hanzo.ai', description: 'PaaS deployments' },
|
||||
{ name: 'Storage', href: 'https://s3.hanzo.ai', description: 'S3-compatible storage' },
|
||||
],
|
||||
lux: [
|
||||
{ name: 'Bridge', href: 'https://bridge.lux.network', description: 'Cross-chain bridge' },
|
||||
{ name: 'Exchange', href: 'https://lux.exchange', description: 'DEX trading' },
|
||||
{ name: 'Cloud', href: 'https://cloud.lux.network', description: 'Lux Cloud' },
|
||||
{ name: 'Explorer', href: 'https://explore.lux.network', description: 'Block explorer' },
|
||||
],
|
||||
zoo: [
|
||||
{ name: 'Network', href: 'https://zoo.ngo', description: 'Zoo Labs Foundation' },
|
||||
{ name: 'ZIPs', href: 'https://zips.zoo.ngo', description: 'Improvement proposals' },
|
||||
],
|
||||
pars: [
|
||||
{ name: 'Network', href: 'https://pars.network', description: 'Pars Network' },
|
||||
{ name: 'Foundation', href: 'https://parsis.foundation', description: 'Parsis Foundation' },
|
||||
],
|
||||
}
|
||||
|
||||
// Per-org billing URL
|
||||
function getBillingUrl(org: string): string {
|
||||
switch (org) {
|
||||
case 'lux': return 'https://billing.lux.network'
|
||||
case 'zoo': return 'https://billing.zoo.network'
|
||||
case 'pars': return 'https://billing.pars.network'
|
||||
default: return 'https://billing.hanzo.ai'
|
||||
}
|
||||
}
|
||||
|
||||
export default function AccountPage() {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const host = typeof window !== 'undefined' ? window.location.hostname : 'hanzo.id'
|
||||
const domain = resolveBrandingDomain(host)
|
||||
const staticConfig = staticBranding[domain]
|
||||
const branding: BrandingConfig = staticConfig
|
||||
? { ...defaultBranding, ...staticConfig, domain }
|
||||
: { ...defaultBranding, domain }
|
||||
|
||||
const org = getOrg(host)
|
||||
const apps = orgApps[org] || orgApps.hanzo
|
||||
const billingUrl = getBillingUrl(org)
|
||||
|
||||
useEffect(() => {
|
||||
loadUser()
|
||||
}, [])
|
||||
|
||||
async function loadUser() {
|
||||
const token = localStorage.getItem('hanzo_access_token')
|
||||
if (!token) {
|
||||
window.location.href = '/login'
|
||||
return
|
||||
}
|
||||
|
||||
// Try cached user first
|
||||
try {
|
||||
const cached = localStorage.getItem('hanzo_user')
|
||||
if (cached) {
|
||||
setUser(JSON.parse(cached))
|
||||
setIsLoading(false)
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Fetch fresh user info — use same-origin proxy to avoid CORS
|
||||
try {
|
||||
const userinfoUrl = window.location.origin + '/oauth/userinfo'
|
||||
const info = await fetchUserInfo(userinfoUrl.replace('/oauth/userinfo', ''), token)
|
||||
const userData: User = {
|
||||
sub: info.sub,
|
||||
name: info.name,
|
||||
displayName: info.displayName,
|
||||
email: info.email,
|
||||
avatar: info.avatar || info.permanentAvatar,
|
||||
}
|
||||
// Also try decoding id_token for richer claims
|
||||
if ((!userData.email || !userData.displayName) && localStorage.getItem('hanzo_id_token')) {
|
||||
try {
|
||||
const idToken = localStorage.getItem('hanzo_id_token')!
|
||||
const p = JSON.parse(atob(idToken.split('.')[1]))
|
||||
userData.email = userData.email || p.email
|
||||
userData.displayName = userData.displayName || p.displayName || p.name || p.preferred_username
|
||||
userData.name = userData.name || p.name || p.preferred_username
|
||||
userData.avatar = userData.avatar || p.avatar || p.picture || p.permanentAvatar
|
||||
} catch {}
|
||||
}
|
||||
setUser(userData)
|
||||
localStorage.setItem('hanzo_user', JSON.stringify(userData))
|
||||
} catch {
|
||||
// Token expired or invalid
|
||||
localStorage.removeItem('hanzo_access_token')
|
||||
localStorage.removeItem('hanzo_user')
|
||||
window.location.href = '/login'
|
||||
return
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
localStorage.removeItem('hanzo_access_token')
|
||||
localStorage.removeItem('hanzo_refresh_token')
|
||||
localStorage.removeItem('hanzo_user')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
const cssVars = {
|
||||
'--color-primary': branding.colors.primary,
|
||||
'--color-primary-text': branding.colors.primaryText,
|
||||
'--color-background': branding.colors.background,
|
||||
'--color-surface': branding.colors.surface,
|
||||
'--color-text': branding.colors.text,
|
||||
'--color-text-muted': branding.colors.textMuted,
|
||||
'--color-border': branding.colors.border,
|
||||
'--color-error': branding.colors.error,
|
||||
} as React.CSSProperties
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-black">
|
||||
<div
|
||||
className="animate-spin w-8 h-8 border-2 border-zinc-700 rounded-full"
|
||||
style={{ borderTopColor: branding.colors.primary }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black" style={cssVars}>
|
||||
{/* Nav */}
|
||||
<nav className="flex items-center justify-between px-6 md:px-12 py-4 border-b border-zinc-800/50">
|
||||
<a href="/" className="flex items-center gap-3">
|
||||
<img src={branding.logo} alt={branding.orgName} className="h-8" />
|
||||
</a>
|
||||
<div className="flex items-center gap-4">
|
||||
<a
|
||||
href={billingUrl}
|
||||
className="text-sm text-zinc-400 hover:text-white transition-colors"
|
||||
>
|
||||
Billing
|
||||
</a>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="text-sm text-zinc-400 hover:text-white transition-colors"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="max-w-4xl mx-auto px-6 md:px-12 py-12">
|
||||
{/* Profile header */}
|
||||
<div className="flex items-center gap-6 mb-12">
|
||||
{user?.avatar ? (
|
||||
<img src={user.avatar} alt="" className="w-20 h-20 rounded-full" />
|
||||
) : (
|
||||
<div
|
||||
className="w-20 h-20 rounded-full flex items-center justify-center text-3xl font-bold"
|
||||
style={{ backgroundColor: branding.colors.primary + '20', color: branding.colors.primary }}
|
||||
>
|
||||
{(user?.displayName || user?.name || user?.email || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">
|
||||
{user?.displayName || user?.name || 'User'}
|
||||
</h1>
|
||||
{user?.email && (
|
||||
<p className="text-zinc-400 mt-1">{user.email}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{/* Account info */}
|
||||
<div className="p-6 rounded-xl border border-zinc-800 bg-zinc-900/30">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Account</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500 uppercase tracking-wider mb-1">User ID</div>
|
||||
<div className="text-white font-mono text-sm">{user?.sub}</div>
|
||||
</div>
|
||||
{user?.name && (
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500 uppercase tracking-wider mb-1">Username</div>
|
||||
<div className="text-white">{user.name}</div>
|
||||
</div>
|
||||
)}
|
||||
{user?.email && (
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500 uppercase tracking-wider mb-1">Email</div>
|
||||
<div className="text-white">{user.email}</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500 uppercase tracking-wider mb-1">Organization</div>
|
||||
<div className="text-white">{branding.orgName}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Billing */}
|
||||
<div className="p-6 rounded-xl border border-zinc-800 bg-zinc-900/30">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Billing & Usage</h2>
|
||||
<p className="text-zinc-400 text-sm mb-6">
|
||||
Manage your subscription, payment methods, and usage.
|
||||
</p>
|
||||
<a
|
||||
href={billingUrl}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg font-medium text-sm transition-opacity hover:opacity-90"
|
||||
style={{ backgroundColor: branding.colors.primary, color: branding.colors.primaryText }}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
|
||||
</svg>
|
||||
Manage Billing
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Apps */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">{branding.orgName} Apps</h2>
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{apps.map((app) => (
|
||||
<a
|
||||
key={app.name}
|
||||
href={app.href}
|
||||
className="p-4 rounded-xl border border-zinc-800 bg-zinc-900/30 hover:bg-zinc-900/60 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium text-white">{app.name}</span>
|
||||
<svg className="w-4 h-4 text-zinc-600 group-hover:text-zinc-400 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-500">{app.description}</p>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-12 pt-8 border-t border-zinc-800 flex items-center justify-between">
|
||||
<a
|
||||
href="/"
|
||||
className="text-sm text-zinc-500 hover:text-white transition-colors"
|
||||
>
|
||||
← Back to {branding.orgName}
|
||||
</a>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-4 py-2 rounded-lg border border-zinc-700 text-zinc-400 hover:text-white hover:border-zinc-500 transition-colors text-sm"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
/**
|
||||
* OAuth bridge callback for downstream apps (Platform, MPC, etc.)
|
||||
*
|
||||
* Handles the code exchange on behalf of apps that need IAM tokens.
|
||||
* Decodes the state param to determine the redirect target.
|
||||
*
|
||||
* Usage:
|
||||
* GET /api/auth/bridge?code=...&state=base64({redirect,clientId,app})
|
||||
*
|
||||
* The state is a base64-encoded JSON object:
|
||||
* { redirect: "https://platform.hanzo.ai/login", clientId: "...", app: "platform" }
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getIamUrl } from '@/lib/iam'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
// Allowed redirect origins for security
|
||||
const ALLOWED_ORIGINS = [
|
||||
'https://platform.hanzo.ai',
|
||||
'https://console.hanzo.ai',
|
||||
'https://cloud.hanzo.ai',
|
||||
'https://mpc.hanzo.ai',
|
||||
'https://mpc.lux.network',
|
||||
'https://mpc.zoo.network',
|
||||
'https://mpc.pars.network',
|
||||
'https://commerce.hanzo.ai',
|
||||
'https://billing.hanzo.ai',
|
||||
'https://analytics.hanzo.ai',
|
||||
'https://insights.hanzo.ai',
|
||||
'https://hanzo.ai',
|
||||
'https://lux.id',
|
||||
'https://zoo.id',
|
||||
'https://pars.id',
|
||||
'https://hanzo.id',
|
||||
...(process.env.NODE_ENV !== 'production' ? [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:3001',
|
||||
'http://localhost:4000',
|
||||
'http://localhost:5173',
|
||||
] : []),
|
||||
]
|
||||
|
||||
function validateRedirectOrigin(redirect: string, fallback: string): string {
|
||||
try {
|
||||
const url = new URL(redirect)
|
||||
if (ALLOWED_ORIGINS.some(o => url.origin === new URL(o).origin)) {
|
||||
return redirect
|
||||
}
|
||||
} catch {}
|
||||
return fallback
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const url = new URL(request.url)
|
||||
const code = url.searchParams.get('code')
|
||||
const state = url.searchParams.get('state')
|
||||
const directAccessToken = url.searchParams.get('access_token')
|
||||
const directRefreshToken = url.searchParams.get('refresh_token')
|
||||
const host = url.hostname
|
||||
|
||||
const iamOrigin = getIamUrl(host)
|
||||
const iamHost = new URL(iamOrigin).host
|
||||
|
||||
const defaultRedirect = `${url.origin}/login`
|
||||
|
||||
// Reject oversized state
|
||||
if (state && state.length > 4096) {
|
||||
return NextResponse.redirect(`${url.origin}/login?error=invalid_state`)
|
||||
}
|
||||
|
||||
// Decode state for redirect target and client info
|
||||
let redirect = defaultRedirect
|
||||
let clientId = process.env.NEXT_PUBLIC_CLIENT_ID || 'hanzo-id'
|
||||
let codeVerifier = ''
|
||||
try {
|
||||
const decoded = JSON.parse(atob(state || ''))
|
||||
if (decoded.redirect) {
|
||||
redirect = validateRedirectOrigin(decoded.redirect, defaultRedirect)
|
||||
}
|
||||
if (decoded.clientId) {
|
||||
clientId = decoded.clientId
|
||||
}
|
||||
if (decoded.code_verifier) {
|
||||
codeVerifier = decoded.code_verifier
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const redirectUrl = new URL(redirect)
|
||||
|
||||
// Direct token passthrough (from implicit flow / password login)
|
||||
if (directAccessToken) {
|
||||
redirectUrl.searchParams.set('access_token', directAccessToken)
|
||||
redirectUrl.searchParams.set('refresh_token', directRefreshToken || '')
|
||||
redirectUrl.searchParams.set('provider', 'hanzo')
|
||||
redirectUrl.searchParams.set('status', '200')
|
||||
return NextResponse.redirect(redirectUrl.toString())
|
||||
}
|
||||
|
||||
// Authorization code exchange
|
||||
if (!code) {
|
||||
redirectUrl.searchParams.set('error', 'no_code')
|
||||
return NextResponse.redirect(redirectUrl.toString())
|
||||
}
|
||||
|
||||
const callbackUri = `${url.origin}/api/auth/bridge`
|
||||
const tokenPayload: Record<string, string> = {
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: clientId,
|
||||
redirect_uri: callbackUri,
|
||||
}
|
||||
|
||||
// Forward PKCE code_verifier if provided (prevents authorization code interception)
|
||||
if (codeVerifier) {
|
||||
tokenPayload.code_verifier = codeVerifier
|
||||
}
|
||||
|
||||
const clientSecret = process.env.IAM_CLIENT_SECRET || process.env.HANZO_IAM_CLIENT_SECRET
|
||||
if (clientSecret) {
|
||||
tokenPayload.client_secret = clientSecret
|
||||
}
|
||||
|
||||
const tokenRes = await fetch(`${iamOrigin}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Host': iamHost,
|
||||
},
|
||||
body: JSON.stringify(tokenPayload),
|
||||
})
|
||||
|
||||
const tokens = await tokenRes.json().catch(() => ({} as Record<string, unknown>))
|
||||
|
||||
if (tokens.access_token) {
|
||||
redirectUrl.searchParams.set('access_token', tokens.access_token as string)
|
||||
redirectUrl.searchParams.set('refresh_token', (tokens.refresh_token as string) || '')
|
||||
redirectUrl.searchParams.set(
|
||||
'expires_at',
|
||||
tokens.expires_in
|
||||
? String(Math.floor(Date.now() / 1000) + Number(tokens.expires_in))
|
||||
: '0',
|
||||
)
|
||||
redirectUrl.searchParams.set('provider', 'hanzo')
|
||||
redirectUrl.searchParams.set('status', '200')
|
||||
} else {
|
||||
redirectUrl.searchParams.set('error', (tokens.error as string) || 'token_exchange_failed')
|
||||
redirectUrl.searchParams.set(
|
||||
'error_description',
|
||||
(tokens.error_description as string) || (tokens.message as string) || 'Failed to exchange code',
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.redirect(redirectUrl.toString())
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
/**
|
||||
* Server-side social OAuth callback handler.
|
||||
*
|
||||
* When a user logs in via Google/GitHub/etc, the social provider redirects
|
||||
* back to /callback with ?code=&state=. The IAM SPA callback relies on
|
||||
* sessionStorage which breaks through our proxy layer, so we handle the
|
||||
* full exchange server-side.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Decode state (base64 query string or JSON) to extract app/org/provider
|
||||
* 2. Read _oauth_ctx cookie as fallback context
|
||||
* 3. POST to IAM /api/login with type:'token' to complete the login
|
||||
* 4. Redirect to the original redirect_uri with tokens
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { resolveClient } from '@/lib/clients'
|
||||
import { getIamUrl } from '@/lib/iam'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
// Allowed redirect origins — must match bridge handler allowlist
|
||||
const ALLOWED_ORIGINS = [
|
||||
'https://platform.hanzo.ai',
|
||||
'https://console.hanzo.ai',
|
||||
'https://cloud.hanzo.ai',
|
||||
'https://mpc.hanzo.ai',
|
||||
'https://mpc.lux.network',
|
||||
'https://mpc.zoo.network',
|
||||
'https://mpc.pars.network',
|
||||
'https://commerce.hanzo.ai',
|
||||
'https://billing.hanzo.ai',
|
||||
'https://analytics.hanzo.ai',
|
||||
'https://insights.hanzo.ai',
|
||||
'https://hanzo.ai',
|
||||
'https://lux.id',
|
||||
'https://zoo.id',
|
||||
'https://pars.id',
|
||||
'https://hanzo.id',
|
||||
...(process.env.NODE_ENV !== 'production' ? [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:3001',
|
||||
'http://localhost:4000',
|
||||
'http://localhost:5173',
|
||||
] : []),
|
||||
]
|
||||
|
||||
function validateRedirectOrigin(redirect: string, fallback: string): string {
|
||||
try {
|
||||
const url = new URL(redirect)
|
||||
if (ALLOWED_ORIGINS.some(o => url.origin === new URL(o).origin)) {
|
||||
return redirect
|
||||
}
|
||||
} catch {}
|
||||
return fallback
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const url = new URL(request.url)
|
||||
const code = url.searchParams.get('code')
|
||||
const state = url.searchParams.get('state')
|
||||
const host = url.hostname
|
||||
|
||||
const iamOrigin = getIamUrl(host)
|
||||
const iamHost = new URL(iamOrigin).host
|
||||
|
||||
if (!code || !state) {
|
||||
return NextResponse.redirect(new URL('/login?error=missing_code_or_state', url.origin))
|
||||
}
|
||||
|
||||
// Reject oversized state to prevent abuse
|
||||
if (state.length > 4096) {
|
||||
return NextResponse.redirect(new URL('/login?error=invalid_state', url.origin))
|
||||
}
|
||||
|
||||
// Decode state — IAM encodes as base64 query string or JSON
|
||||
let stateParams = new URLSearchParams()
|
||||
let stateObj: Record<string, string> = {}
|
||||
try {
|
||||
const decoded = atob(state)
|
||||
if (decoded.startsWith('?') || decoded.includes('=')) {
|
||||
stateParams = new URLSearchParams(decoded)
|
||||
} else {
|
||||
stateObj = JSON.parse(decoded)
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.redirect(new URL('/login?error=invalid_state', url.origin))
|
||||
}
|
||||
|
||||
// Read _oauth_ctx cookie as fallback
|
||||
const cookieHeader = request.headers.get('cookie') || ''
|
||||
let oauthCtx: Record<string, string> = {}
|
||||
const ctxMatch = cookieHeader.match(/_oauth_ctx=([^;]+)/)
|
||||
if (ctxMatch) {
|
||||
try {
|
||||
oauthCtx = JSON.parse(atob(decodeURIComponent(ctxMatch[1])))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Resolve context from state (primary), cookie (secondary), JSON (tertiary)
|
||||
const application = stateParams.get('application') || oauthCtx.application || stateObj.application || ''
|
||||
const provider = stateParams.get('provider') || oauthCtx.provider || stateObj.provider || ''
|
||||
const method = stateParams.get('method') || stateObj.method || 'link'
|
||||
const stateClientId = stateParams.get('client_id') || oauthCtx.clientId || ''
|
||||
const originalRedirectUri = stateParams.get('redirect_uri') || oauthCtx.redirectUri || stateObj.redirectUri || ''
|
||||
|
||||
// Resolve organization from client map — ALWAYS prefer client map over untrusted sources
|
||||
// to prevent cross-tenant org bypass attacks
|
||||
let organization = ''
|
||||
if (stateClientId) {
|
||||
const client = resolveClient(stateClientId)
|
||||
if (client) organization = client.organization
|
||||
}
|
||||
// Only fall back to cookie/state if no client map match, and validate it's a known org
|
||||
if (!organization) {
|
||||
const KNOWN_ORGS = ['hanzo', 'lux', 'zoo', 'pars', 'zen', 'adnexus']
|
||||
const candidateOrg = oauthCtx.organization || stateObj.organization || ''
|
||||
if (KNOWN_ORGS.includes(candidateOrg)) {
|
||||
organization = candidateOrg
|
||||
}
|
||||
}
|
||||
|
||||
// Call IAM to complete the social login
|
||||
// type:'token' because our IAM version has a bug where type:'code'
|
||||
// maps to an empty grant_type and fails
|
||||
const loginRes = await fetch(`${iamOrigin}/api/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Cookie': cookieHeader,
|
||||
'Host': iamHost,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: 'token',
|
||||
code,
|
||||
state: 'hanzo',
|
||||
redirectUri: `${url.origin}/callback`,
|
||||
application,
|
||||
organization,
|
||||
provider,
|
||||
method,
|
||||
}),
|
||||
})
|
||||
|
||||
const loginData = await loginRes.json().catch(() => ({} as Record<string, unknown>))
|
||||
|
||||
// Clear the oauth context cookie
|
||||
const clearCookie = '_oauth_ctx=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0'
|
||||
|
||||
if (loginData.status === 'ok' && loginData.data) {
|
||||
// Validate redirect URI against allowlist to prevent open redirect
|
||||
const candidateRedirect = originalRedirectUri
|
||||
? originalRedirectUri.replaceAll(iamHost, host)
|
||||
: `${url.origin}/login`
|
||||
const targetRedirectUri = validateRedirectOrigin(candidateRedirect, `${url.origin}/login`)
|
||||
const targetUrl = new URL(targetRedirectUri)
|
||||
targetUrl.searchParams.set('access_token', loginData.data as string)
|
||||
targetUrl.searchParams.set('refresh_token', (loginData.data2 as string) || '')
|
||||
targetUrl.searchParams.set('provider', 'hanzo')
|
||||
targetUrl.searchParams.set('status', '200')
|
||||
return new NextResponse(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
'Location': targetUrl.toString(),
|
||||
'Set-Cookie': clearCookie,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Error path — never include sensitive debug info in redirect params
|
||||
if (originalRedirectUri) {
|
||||
const candidateError = originalRedirectUri.replaceAll(iamHost, host)
|
||||
const safeErrorRedirect = validateRedirectOrigin(candidateError, `${url.origin}/login`)
|
||||
const errorUrl = new URL(safeErrorRedirect)
|
||||
errorUrl.searchParams.set('error', (loginData.msg as string) || 'social_login_failed')
|
||||
return new NextResponse(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
'Location': errorUrl.toString(),
|
||||
'Set-Cookie': clearCookie,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return new NextResponse(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
'Location': `${url.origin}/login?error=${encodeURIComponent((loginData.msg as string) || 'social_login_failed')}`,
|
||||
'Set-Cookie': clearCookie,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* Server-side logout handler.
|
||||
*
|
||||
* Calls IAM to invalidate the session, clears cookies,
|
||||
* and redirects to the login page.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getIamUrl } from '@/lib/iam'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const url = new URL(request.url)
|
||||
const host = url.hostname
|
||||
const iamOrigin = getIamUrl(host)
|
||||
const iamHost = new URL(iamOrigin).host
|
||||
|
||||
const idTokenHint = url.searchParams.get('id_token_hint') || ''
|
||||
const postLogoutRedirectUri = url.searchParams.get('post_logout_redirect_uri') || `${url.origin}/login?prompt=login`
|
||||
const state = url.searchParams.get('state') || ''
|
||||
|
||||
// Call IAM logout
|
||||
const logoutUrl = new URL('/api/logout', iamOrigin)
|
||||
logoutUrl.searchParams.set('id_token_hint', idTokenHint)
|
||||
logoutUrl.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri)
|
||||
logoutUrl.searchParams.set('state', state)
|
||||
|
||||
try {
|
||||
await fetch(logoutUrl.toString(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Host': iamHost,
|
||||
},
|
||||
})
|
||||
} catch {}
|
||||
|
||||
return new NextResponse(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: '/login?prompt=login',
|
||||
'Set-Cookie': 'iam_session_id=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return GET(request)
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
'use client'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { exchangeCode } from '@/lib/oauth'
|
||||
import { getIamUrl, getDefaultClientId } from '@/lib/iam'
|
||||
|
||||
/**
|
||||
* Claim a referral code after successful login/signup.
|
||||
* Fire-and-forget: never blocks redirect on failure.
|
||||
*/
|
||||
function claimReferral(accessToken: string, userId: string, email: string) {
|
||||
const refCode = sessionStorage.getItem('hanzo_ref_code')
|
||||
if (!refCode) return
|
||||
|
||||
sessionStorage.removeItem('hanzo_ref_code')
|
||||
|
||||
fetch('https://commerce.hanzo.ai/api/v1/referral/claim', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({ code: refCode, userId, email }),
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
function CallbackHandler() {
|
||||
const searchParams = useSearchParams()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
handleCallback()
|
||||
}, [])
|
||||
|
||||
async function handleCallback() {
|
||||
const errorParam = searchParams.get('error')
|
||||
if (errorParam) {
|
||||
setError(searchParams.get('error_description') || errorParam)
|
||||
return
|
||||
}
|
||||
|
||||
// Token passthrough from social login / bridge callback
|
||||
const accessToken = searchParams.get('access_token')
|
||||
if (accessToken) {
|
||||
localStorage.setItem('hanzo_access_token', accessToken)
|
||||
const refreshToken = searchParams.get('refresh_token')
|
||||
if (refreshToken) {
|
||||
localStorage.setItem('hanzo_refresh_token', refreshToken)
|
||||
}
|
||||
const idToken = searchParams.get('id_token')
|
||||
if (idToken) {
|
||||
localStorage.setItem('hanzo_id_token', idToken)
|
||||
}
|
||||
|
||||
// Extract user info: prefer id_token (has full claims), fall back to access_token
|
||||
try {
|
||||
const idPayload = idToken
|
||||
? JSON.parse(atob(idToken.split('.')[1]))
|
||||
: null
|
||||
const atPayload = JSON.parse(atob(accessToken.split('.')[1]))
|
||||
const p = idPayload || atPayload
|
||||
localStorage.setItem('hanzo_user', JSON.stringify({
|
||||
sub: p.sub || atPayload.sub || atPayload.name,
|
||||
name: p.name || p.preferred_username || atPayload.name,
|
||||
displayName: p.displayName || p.name || p.preferred_username,
|
||||
email: p.email || atPayload.email,
|
||||
avatar: p.avatar || p.picture || p.permanentAvatar,
|
||||
}))
|
||||
claimReferral(accessToken, p.sub || atPayload.sub || atPayload.name, p.email || atPayload.email)
|
||||
} catch {}
|
||||
|
||||
const postLoginRedirect = sessionStorage.getItem('hanzo_auth_post_login_redirect')
|
||||
if (postLoginRedirect) {
|
||||
sessionStorage.removeItem('hanzo_auth_post_login_redirect')
|
||||
window.location.href = postLoginRedirect
|
||||
} else {
|
||||
window.location.href = '/account'
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// PKCE authorization code flow
|
||||
const code = searchParams.get('code')
|
||||
const state = searchParams.get('state')
|
||||
|
||||
if (!code || !state) {
|
||||
setError('Missing authorization code or state')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const host = window.location.hostname
|
||||
const iamUrl = getIamUrl(host)
|
||||
const clientId = getDefaultClientId(host)
|
||||
const redirectUri = `${window.location.origin}/callback`
|
||||
|
||||
const tokens = await exchangeCode({
|
||||
iamUrl,
|
||||
code,
|
||||
state,
|
||||
clientId,
|
||||
redirectUri,
|
||||
})
|
||||
|
||||
localStorage.setItem('hanzo_access_token', tokens.access_token)
|
||||
if (tokens.refresh_token) {
|
||||
localStorage.setItem('hanzo_refresh_token', tokens.refresh_token)
|
||||
}
|
||||
if (tokens.id_token) {
|
||||
localStorage.setItem('hanzo_id_token', tokens.id_token)
|
||||
}
|
||||
|
||||
// Extract user info: prefer id_token (has full claims), fall back to access_token
|
||||
try {
|
||||
const idPayload = tokens.id_token
|
||||
? JSON.parse(atob(tokens.id_token.split('.')[1]))
|
||||
: null
|
||||
const atPayload = JSON.parse(atob(tokens.access_token.split('.')[1]))
|
||||
const p = idPayload || atPayload
|
||||
localStorage.setItem('hanzo_user', JSON.stringify({
|
||||
sub: p.sub || atPayload.sub || atPayload.name,
|
||||
name: p.name || p.preferred_username || atPayload.name,
|
||||
displayName: p.displayName || p.name || p.preferred_username,
|
||||
email: p.email || atPayload.email,
|
||||
avatar: p.avatar || p.picture || p.permanentAvatar,
|
||||
}))
|
||||
claimReferral(tokens.access_token, p.sub || atPayload.sub || atPayload.name, p.email || atPayload.email)
|
||||
} catch {}
|
||||
|
||||
const postLoginRedirect = sessionStorage.getItem('hanzo_auth_post_login_redirect')
|
||||
if (postLoginRedirect) {
|
||||
sessionStorage.removeItem('hanzo_auth_post_login_redirect')
|
||||
window.location.href = postLoginRedirect
|
||||
} else {
|
||||
window.location.href = '/account'
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Authentication failed')
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="login-card max-w-md w-full p-8 text-center">
|
||||
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
<a href="/login" className="link text-sm">Back to login</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className="animate-spin w-8 h-8 border-2 border-zinc-700 border-t-white rounded-full mx-auto mb-4" />
|
||||
<p className="text-zinc-400 text-sm">Completing sign in...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CallbackPage() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-black">
|
||||
<Suspense fallback={
|
||||
<div className="text-center">
|
||||
<div className="animate-spin w-8 h-8 border-2 border-zinc-700 border-t-white rounded-full mx-auto mb-4" />
|
||||
<p className="text-zinc-400 text-sm">Loading...</p>
|
||||
</div>
|
||||
}>
|
||||
<CallbackHandler />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
'use client'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { getIamUrl, getOrg } from '@/lib/iam'
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [sent, setSent] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const host = typeof window !== 'undefined' ? window.location.hostname : 'hanzo.id'
|
||||
const iamUrl = getIamUrl(host)
|
||||
const org = getOrg(host)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
if (!email) throw new Error('Please enter your email address')
|
||||
|
||||
const res = await fetch(`${iamUrl}/api/send-verification-code`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
dest: email,
|
||||
type: 'reset',
|
||||
organization: org,
|
||||
applicationId: `admin/${org}`,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (data.status === 'error') throw new Error(data.msg || 'Failed to send reset email')
|
||||
|
||||
setSent(true)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Something went wrong')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-black p-8">
|
||||
<div className="login-card w-full max-w-md p-8">
|
||||
<h1 className="text-2xl font-bold text-white mb-2">Reset password</h1>
|
||||
<p className="text-zinc-400 text-sm mb-6">
|
||||
{sent
|
||||
? 'Check your email for a password reset link.'
|
||||
: 'Enter your email and we\'ll send you a reset link.'}
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sent ? (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 rounded-lg bg-green-500/10 border border-green-500/20 text-green-400 text-sm">
|
||||
If an account exists for {email}, you will receive a password reset email shortly.
|
||||
</div>
|
||||
<a href="/login" className="block text-center link text-sm">
|
||||
Back to login
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="relative">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email address"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="input w-full pl-10 py-3 rounded-lg"
|
||||
autoComplete="email"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="btn-primary w-full py-3 rounded-lg font-medium disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Sending...' : 'Send reset link'}
|
||||
</button>
|
||||
|
||||
<p className="text-center text-sm text-zinc-500">
|
||||
Remember your password?{' '}
|
||||
<a href="/login" className="link">Sign in</a>
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
/* Default Hanzo colors - overridden by branding config */
|
||||
--color-primary: #e4e4e7;
|
||||
--color-primary-text: #09090b;
|
||||
--color-background: #000000;
|
||||
--color-surface: #0a0a0a;
|
||||
--color-text: #ffffff;
|
||||
--color-text-muted: #a1a1aa;
|
||||
--color-border: #27272a;
|
||||
--color-error: #dc2626;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* Login card styling */
|
||||
.login-card {
|
||||
background-color: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* Button styling */
|
||||
.btn-primary {
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-primary-text);
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Input styling */
|
||||
.input {
|
||||
background-color: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--color-primary);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(228, 228, 231, 0.2);
|
||||
}
|
||||
|
||||
/* Link styling */
|
||||
.link {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Quote card styling */
|
||||
.quote-card {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.02) 100%);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
export const runtime = 'edge'
|
||||
|
||||
import type { Metadata } from 'next'
|
||||
import { headers } from 'next/headers'
|
||||
import { staticBranding, defaultBranding, resolveBrandingDomain } from '@/lib/branding'
|
||||
import './globals.css'
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const headersList = await headers()
|
||||
const host = headersList.get('host') || 'hanzo.id'
|
||||
const domain = resolveBrandingDomain(host)
|
||||
|
||||
const staticConfig = staticBranding[domain]
|
||||
const orgName = staticConfig?.orgName || defaultBranding.orgName
|
||||
|
||||
return {
|
||||
title: `${orgName} ID`,
|
||||
description: `Secure identity for ${orgName}. Sign in, manage your account, and access all ${orgName} services.`,
|
||||
}
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="antialiased">{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { Suspense } from 'react'
|
||||
import { headers } from 'next/headers'
|
||||
import { getBranding, staticBranding, defaultBranding, resolveBrandingDomain, BrandingConfig } from '@/lib/branding'
|
||||
import LoginForm from '@/components/LoginForm'
|
||||
import MarketingPanel from '@/components/MarketingPanel'
|
||||
import LanguageDropdown from '@/components/LanguageDropdown'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
async function getBrandingForDomain(): Promise<BrandingConfig> {
|
||||
const headersList = await headers()
|
||||
const host = headersList.get('host') || 'hanzo.id'
|
||||
const domain = resolveBrandingDomain(host)
|
||||
|
||||
// First check static configs, then fetch from IAM
|
||||
const staticConfig = staticBranding[domain]
|
||||
if (staticConfig) {
|
||||
return { ...defaultBranding, ...staticConfig, domain }
|
||||
}
|
||||
|
||||
return getBranding(domain)
|
||||
}
|
||||
|
||||
export default async function LoginPage() {
|
||||
const branding = await getBrandingForDomain()
|
||||
|
||||
// Generate CSS variables from branding
|
||||
const cssVars = {
|
||||
'--color-primary': branding.colors.primary,
|
||||
'--color-primary-text': branding.colors.primaryText,
|
||||
'--color-background': branding.colors.background,
|
||||
'--color-surface': branding.colors.surface,
|
||||
'--color-text': branding.colors.text,
|
||||
'--color-text-muted': branding.colors.textMuted,
|
||||
'--color-border': branding.colors.border,
|
||||
'--color-error': branding.colors.error,
|
||||
} as React.CSSProperties
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex" style={cssVars}>
|
||||
{/* Left side - Login Form */}
|
||||
<div className="w-full lg:w-1/2 flex items-center justify-center p-8">
|
||||
<div className="login-card w-full max-w-md p-8">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<img
|
||||
src={branding.logo}
|
||||
alt={branding.orgName}
|
||||
className="h-10"
|
||||
/>
|
||||
<Suspense><LanguageDropdown /></Suspense>
|
||||
</div>
|
||||
|
||||
<Suspense><LoginForm branding={branding} /></Suspense>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Marketing Panel */}
|
||||
<div className="hidden lg:flex w-1/2 items-center justify-center p-12 bg-gradient-to-br from-black via-zinc-900 to-black">
|
||||
<MarketingPanel branding={branding} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
export const runtime = 'edge'
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-black">
|
||||
<div className="text-center">
|
||||
<h1 className="text-6xl font-bold text-white mb-4">404</h1>
|
||||
<p className="text-zinc-400 mb-8">Page not found</p>
|
||||
<a href="/login" className="text-sm text-zinc-500 hover:text-white transition-colors">
|
||||
Go to login
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,470 +0,0 @@
|
||||
import { headers } from 'next/headers'
|
||||
import Link from 'next/link'
|
||||
import { staticBranding, defaultBranding, resolveBrandingDomain, type BrandingConfig } from '@/lib/branding'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
// Per-org landing page content
|
||||
interface LandingContent {
|
||||
headline: string
|
||||
description: string
|
||||
features: { title: string; description: string; icon: string }[]
|
||||
standards: { label: string; value: string }[]
|
||||
cta: string
|
||||
secondaryCta?: { label: string; href: string }
|
||||
}
|
||||
|
||||
const landingContent: Record<string, LandingContent> = {
|
||||
lux: {
|
||||
headline: 'Your Identity on Lux',
|
||||
description: 'Decentralized identity anchored on high-performance blockchain infrastructure. Own your credentials, prove who you are without exposing what you are, and authenticate across the entire Lux ecosystem with one login.',
|
||||
features: [
|
||||
{
|
||||
title: 'Decentralized Identifiers (DIDs)',
|
||||
description: 'W3C-standard DIDs anchored on Lux Network. Your identity is portable, censorship-resistant, and fully under your control. No central authority can revoke or freeze your credentials.',
|
||||
icon: 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z',
|
||||
},
|
||||
{
|
||||
title: 'Verifiable Credentials',
|
||||
description: 'Issue and present tamper-proof credentials — KYC attestations, membership proofs, reputation scores — without revealing unnecessary personal data. Selective disclosure by default.',
|
||||
icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
},
|
||||
{
|
||||
title: 'Cross-Chain Single Sign-On',
|
||||
description: 'One identity across Lux mainnet, subnets, the bridge, DEX, and every ecosystem dApp. OAuth2/OIDC compliant — works with traditional apps too.',
|
||||
icon: 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1',
|
||||
},
|
||||
{
|
||||
title: 'Post-Quantum Cryptography',
|
||||
description: 'Forward-looking key management with lattice-based and hash-based signatures. Your identity stays secure against quantum computing threats — today and tomorrow.',
|
||||
icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z',
|
||||
},
|
||||
{
|
||||
title: 'Key Recovery & Social Recovery',
|
||||
description: 'Lost your keys? Recover your identity through trusted guardians, multi-sig recovery, or hardware backup — no single point of failure.',
|
||||
icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z',
|
||||
},
|
||||
{
|
||||
title: 'Privacy-Preserving Auth',
|
||||
description: 'Zero-knowledge proofs let you prove eligibility, age, membership, or accreditation without revealing the underlying data. Your privacy is non-negotiable.',
|
||||
icon: 'M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
},
|
||||
],
|
||||
standards: [
|
||||
{ label: 'W3C DID', value: 'Core v1.0' },
|
||||
{ label: 'OAuth 2.0', value: 'RFC 6749' },
|
||||
{ label: 'OIDC', value: 'Core 1.0' },
|
||||
{ label: 'PKCE', value: 'RFC 7636' },
|
||||
{ label: 'WebAuthn', value: 'L2' },
|
||||
{ label: 'FIDO2', value: 'Passkeys' },
|
||||
],
|
||||
cta: 'Create Your Lux ID',
|
||||
secondaryCta: { label: 'Explore Lux Network', href: 'https://lux.network' },
|
||||
},
|
||||
pars: {
|
||||
headline: 'Your Identity on Pars',
|
||||
description: 'Self-sovereign identity for the next generation of decentralized infrastructure. Own your data, control your credentials, and authenticate across the Pars ecosystem with confidence.',
|
||||
features: [
|
||||
{
|
||||
title: 'Self-Sovereign Identity',
|
||||
description: 'Your identity belongs to you — not a corporation, not a government. W3C DID-compliant identifiers give you full ownership and portability.',
|
||||
icon: 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z',
|
||||
},
|
||||
{
|
||||
title: 'Verifiable Credentials',
|
||||
description: 'Carry tamper-proof digital credentials — academic degrees, professional certifications, KYC attestations — verified on-chain, shared on your terms.',
|
||||
icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
},
|
||||
{
|
||||
title: 'Ecosystem-Wide Access',
|
||||
description: 'One account for all Pars applications, governance, staking, and partner integrations. Standards-based SSO that just works.',
|
||||
icon: 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
|
||||
},
|
||||
{
|
||||
title: 'Privacy by Design',
|
||||
description: 'Zero-knowledge proofs and selective disclosure — share only what you choose. Prove you\'re eligible without revealing why.',
|
||||
icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z',
|
||||
},
|
||||
{
|
||||
title: 'Multi-Factor Security',
|
||||
description: 'Hardware keys, biometrics, TOTP, passkeys — layer security however you need. Enterprise-grade protection for every user.',
|
||||
icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z',
|
||||
},
|
||||
{
|
||||
title: 'Open Standards',
|
||||
description: 'Built on OAuth 2.0, OpenID Connect, W3C DIDs, and Verifiable Credentials. No vendor lock-in, interoperable with any standards-compliant system.',
|
||||
icon: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4',
|
||||
},
|
||||
],
|
||||
standards: [
|
||||
{ label: 'W3C DID', value: 'Core v1.0' },
|
||||
{ label: 'OAuth 2.0', value: 'RFC 6749' },
|
||||
{ label: 'OIDC', value: 'Core 1.0' },
|
||||
{ label: 'PKCE', value: 'RFC 7636' },
|
||||
{ label: 'WebAuthn', value: 'L2' },
|
||||
{ label: 'VC', value: 'Data Model' },
|
||||
],
|
||||
cta: 'Create Your Pars ID',
|
||||
secondaryCta: { label: 'Explore Pars Network', href: 'https://pars.network' },
|
||||
},
|
||||
zoo: {
|
||||
headline: 'Your Identity on Zoo',
|
||||
description: 'Verifiable research identity for the open AI research network. Collaborate on decentralized science, participate in governance, and build reputation across the Zoo ecosystem.',
|
||||
features: [
|
||||
{
|
||||
title: 'Research Identity',
|
||||
description: 'A verifiable, portable identity for researchers, contributors, and AI practitioners. Link your publications, models, and contributions to a cryptographic identity you own.',
|
||||
icon: 'M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z',
|
||||
},
|
||||
{
|
||||
title: 'Governance & ZIPs',
|
||||
description: 'Participate in Zoo Improvement Proposals (ZIPs) with a verified identity. Vote on protocol upgrades, fund allocation, and research priorities.',
|
||||
icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10',
|
||||
},
|
||||
{
|
||||
title: 'Cross-Network Reputation',
|
||||
description: 'Build reputation that travels with you. Contributions to Zoo, Hanzo, and partner networks all feed into a unified, verifiable reputation graph.',
|
||||
icon: 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1',
|
||||
},
|
||||
{
|
||||
title: 'Decentralized Science (DeSci)',
|
||||
description: 'Credential your research contributions on-chain. Peer review, data sharing, and reproducibility — all backed by verifiable credentials.',
|
||||
icon: 'M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z',
|
||||
},
|
||||
{
|
||||
title: 'Privacy-First',
|
||||
description: 'Selective disclosure lets you prove qualifications without exposing personal data. Research anonymously when you need to.',
|
||||
icon: 'M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
},
|
||||
{
|
||||
title: 'Open & Interoperable',
|
||||
description: 'W3C DID, OAuth 2.0, OIDC — standards-based identity that works with ORCID, institutional logins, and any research platform.',
|
||||
icon: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4',
|
||||
},
|
||||
],
|
||||
standards: [
|
||||
{ label: 'W3C DID', value: 'Core v1.0' },
|
||||
{ label: 'OAuth 2.0', value: 'RFC 6749' },
|
||||
{ label: 'OIDC', value: 'Core 1.0' },
|
||||
{ label: 'PKCE', value: 'RFC 7636' },
|
||||
{ label: 'VC', value: 'Data Model' },
|
||||
{ label: 'DeSci', value: 'ZIPs' },
|
||||
],
|
||||
cta: 'Create Your Zoo ID',
|
||||
secondaryCta: { label: 'Explore Zoo Network', href: 'https://zoo.ngo' },
|
||||
},
|
||||
hanzo: {
|
||||
headline: 'Your AI Identity',
|
||||
description: 'One identity across the entire Hanzo AI ecosystem. Secure, standards-based authentication for developers building the future of AI.',
|
||||
features: [
|
||||
{
|
||||
title: 'Unified AI Access',
|
||||
description: 'Single sign-in to Console, Chat, Cloud, Gateway, and every Hanzo service. One identity, one API key namespace, one billing account.',
|
||||
icon: 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1',
|
||||
},
|
||||
{
|
||||
title: 'Developer-First Auth',
|
||||
description: 'OAuth 2.0, OpenID Connect, PKCE, API keys, service tokens — all RFC-standard. SDKs in Python, TypeScript, Go, and Rust. No vendor lock-in.',
|
||||
icon: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4',
|
||||
},
|
||||
{
|
||||
title: 'Enterprise Security',
|
||||
description: 'SSO with SAML/OIDC, hardware-backed MFA, fine-grained RBAC, audit logs, and SOC 2 compliance. Built for teams that ship.',
|
||||
icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z',
|
||||
},
|
||||
{
|
||||
title: 'Multi-Tenant Organizations',
|
||||
description: 'Create organizations, invite team members, assign roles, and scope API keys — all from a single identity. White-label ready for your own domains.',
|
||||
icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z',
|
||||
},
|
||||
{
|
||||
title: 'Passkeys & Biometrics',
|
||||
description: 'FIDO2 passkeys, Face ID, Touch ID, hardware security keys — passwordless authentication that\'s both more secure and more convenient.',
|
||||
icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z',
|
||||
},
|
||||
{
|
||||
title: 'Web3 + Traditional',
|
||||
description: 'Connect with MetaMask, WalletConnect, or hardware wallets alongside traditional email/password and social login. Bridge Web2 and Web3 seamlessly.',
|
||||
icon: 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
|
||||
},
|
||||
],
|
||||
standards: [
|
||||
{ label: 'OAuth 2.0', value: 'RFC 6749' },
|
||||
{ label: 'OIDC', value: 'Core 1.0' },
|
||||
{ label: 'PKCE', value: 'RFC 7636' },
|
||||
{ label: 'WebAuthn', value: 'L2' },
|
||||
{ label: 'SAML', value: '2.0' },
|
||||
{ label: 'FIDO2', value: 'Passkeys' },
|
||||
],
|
||||
cta: 'Get Started',
|
||||
secondaryCta: { label: 'Read the Docs', href: 'https://docs.hanzo.ai' },
|
||||
},
|
||||
zen: {
|
||||
headline: 'Your Zen Identity',
|
||||
description: 'Access frontier AI models with a single identity. Zen LM powers the next generation of language models — your identity unlocks them all.',
|
||||
features: [
|
||||
{
|
||||
title: 'Model Access',
|
||||
description: 'Authenticate once to access all Zen LM models — from 600M to 480B parameters. Inference, fine-tuning, and evaluation with one API key.',
|
||||
icon: 'M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z',
|
||||
},
|
||||
{
|
||||
title: 'Usage & Billing',
|
||||
description: 'Track model usage, manage API keys, set spending limits, and control team access — all from your Zen ID dashboard.',
|
||||
icon: 'M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z',
|
||||
},
|
||||
{
|
||||
title: 'Open Standards',
|
||||
description: 'OAuth 2.0 / OIDC compliant — integrate with any platform, CI/CD pipeline, or workflow. SDKs for every major language.',
|
||||
icon: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4',
|
||||
},
|
||||
{
|
||||
title: 'Developer Experience',
|
||||
description: 'CLI login, API key management, scoped tokens, and seamless integration with development tools. Built for AI engineers.',
|
||||
icon: 'M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z',
|
||||
},
|
||||
{
|
||||
title: 'Team Management',
|
||||
description: 'Create organizations, invite collaborators, and share model access with fine-grained permissions.',
|
||||
icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z',
|
||||
},
|
||||
{
|
||||
title: 'Cross-Ecosystem',
|
||||
description: 'Your Zen ID works across Hanzo, Lux, Zoo, and partner platforms. One identity, every AI service.',
|
||||
icon: 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
|
||||
},
|
||||
],
|
||||
standards: [
|
||||
{ label: 'OAuth 2.0', value: 'RFC 6749' },
|
||||
{ label: 'OIDC', value: 'Core 1.0' },
|
||||
{ label: 'PKCE', value: 'RFC 7636' },
|
||||
{ label: 'WebAuthn', value: 'L2' },
|
||||
{ label: 'FIDO2', value: 'Passkeys' },
|
||||
{ label: 'JWT', value: 'RFC 7519' },
|
||||
],
|
||||
cta: 'Get Started with Zen',
|
||||
secondaryCta: { label: 'Explore Models', href: 'https://zenlm.org' },
|
||||
},
|
||||
adnexus: {
|
||||
headline: 'Your Ad Nexus Identity',
|
||||
description: 'Secure identity for the programmatic advertising platform. Manage campaigns, analytics, and integrations with enterprise-grade authentication.',
|
||||
features: [
|
||||
{
|
||||
title: 'Campaign Access',
|
||||
description: 'Single sign-on to all Ad Nexus tools — campaign manager, analytics dashboard, creative studio, and billing.',
|
||||
icon: 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1',
|
||||
},
|
||||
{
|
||||
title: 'Team Permissions',
|
||||
description: 'Role-based access control for agencies and brands. Scoped permissions for campaign managers, analysts, and billing admins.',
|
||||
icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z',
|
||||
},
|
||||
{
|
||||
title: 'Enterprise SSO',
|
||||
description: 'SAML, OIDC, and OAuth 2.0 federation. Connect your existing identity provider for seamless onboarding.',
|
||||
icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z',
|
||||
},
|
||||
],
|
||||
standards: [
|
||||
{ label: 'OAuth 2.0', value: 'RFC 6749' },
|
||||
{ label: 'OIDC', value: 'Core 1.0' },
|
||||
{ label: 'SAML', value: '2.0' },
|
||||
{ label: 'PKCE', value: 'RFC 7636' },
|
||||
],
|
||||
cta: 'Get Started',
|
||||
secondaryCta: { label: 'Learn More', href: 'https://ad.nexus' },
|
||||
},
|
||||
}
|
||||
|
||||
const defaultLanding = landingContent.hanzo
|
||||
|
||||
async function getBrandingForDomain() {
|
||||
const headersList = await headers()
|
||||
const host = headersList.get('host') || 'hanzo.id'
|
||||
const domain = resolveBrandingDomain(host)
|
||||
const staticConfig = staticBranding[domain]
|
||||
const branding: BrandingConfig = staticConfig
|
||||
? { ...defaultBranding, ...staticConfig, domain }
|
||||
: { ...defaultBranding, domain }
|
||||
return branding
|
||||
}
|
||||
|
||||
export default async function Home() {
|
||||
const branding = await getBrandingForDomain()
|
||||
const content = landingContent[branding.orgId] || defaultLanding
|
||||
|
||||
const cssVars = {
|
||||
'--color-primary': branding.colors.primary,
|
||||
'--color-primary-text': branding.colors.primaryText,
|
||||
'--color-background': branding.colors.background,
|
||||
'--color-surface': branding.colors.surface,
|
||||
'--color-text': branding.colors.text,
|
||||
'--color-text-muted': branding.colors.textMuted,
|
||||
'--color-border': branding.colors.border,
|
||||
'--color-error': branding.colors.error,
|
||||
} as React.CSSProperties
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col" style={cssVars}>
|
||||
{/* Nav */}
|
||||
<nav className="flex items-center justify-between px-6 md:px-12 py-4 border-b border-zinc-800/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<img src={branding.logo} alt={branding.orgName} className="h-8" />
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/login" className="text-sm text-zinc-400 hover:text-white transition-colors">
|
||||
Sign In
|
||||
</Link>
|
||||
<Link
|
||||
href="/signup"
|
||||
className="text-sm px-4 py-2 rounded-lg font-medium transition-opacity hover:opacity-90"
|
||||
style={{ backgroundColor: branding.colors.primary, color: branding.colors.primaryText }}
|
||||
>
|
||||
Get Started
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Hero */}
|
||||
<section className="px-6 md:px-12 py-20 md:py-32">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<div
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full border text-sm mb-8"
|
||||
style={{ borderColor: branding.colors.primary + '40', color: branding.colors.primary }}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
Decentralized Identity
|
||||
</div>
|
||||
|
||||
<h1 className="text-5xl sm:text-6xl lg:text-7xl font-bold text-white mb-6 leading-tight tracking-tight">
|
||||
{content.headline}
|
||||
</h1>
|
||||
<p className="text-lg md:text-xl text-zinc-400 max-w-2xl mx-auto mb-12 leading-relaxed">
|
||||
{content.description}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-8">
|
||||
<Link
|
||||
href="/signup"
|
||||
className="px-8 py-3.5 rounded-lg font-medium text-lg transition-opacity hover:opacity-90 w-full sm:w-auto"
|
||||
style={{ backgroundColor: branding.colors.primary, color: branding.colors.primaryText }}
|
||||
>
|
||||
{content.cta}
|
||||
</Link>
|
||||
{content.secondaryCta ? (
|
||||
<a
|
||||
href={content.secondaryCta.href}
|
||||
className="px-8 py-3.5 rounded-lg font-medium text-lg border border-zinc-700 text-zinc-300 hover:text-white hover:border-zinc-500 transition-colors w-full sm:w-auto text-center"
|
||||
>
|
||||
{content.secondaryCta.label}
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
href="/login"
|
||||
className="px-8 py-3.5 rounded-lg font-medium text-lg border border-zinc-700 text-zinc-300 hover:text-white hover:border-zinc-500 transition-colors w-full sm:w-auto text-center"
|
||||
>
|
||||
Sign In
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Standards bar */}
|
||||
<section className="border-y border-zinc-800/50 px-6 md:px-12 py-6">
|
||||
<div className="max-w-5xl mx-auto flex flex-wrap items-center justify-center gap-6 md:gap-10">
|
||||
{content.standards.map((s, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm">
|
||||
<span className="text-zinc-500">{s.label}</span>
|
||||
<span className="text-zinc-300 font-mono text-xs px-1.5 py-0.5 rounded bg-zinc-800">{s.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features */}
|
||||
<section className="px-6 md:px-12 py-20 md:py-28">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-white mb-4">
|
||||
Built for the future of identity
|
||||
</h2>
|
||||
<p className="text-zinc-400 max-w-2xl mx-auto">
|
||||
Standards-compliant, privacy-preserving, and designed for decentralized ecosystems.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{content.features.map((feature, i) => (
|
||||
<div key={i} className="p-6 rounded-xl border border-zinc-800 bg-zinc-900/30 hover:bg-zinc-900/60 transition-colors">
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center mb-4"
|
||||
style={{ backgroundColor: branding.colors.primary + '15' }}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
style={{ color: branding.colors.primary }}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={feature.icon} />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">{feature.title}</h3>
|
||||
<p className="text-sm text-zinc-400 leading-relaxed">{feature.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="px-6 md:px-12 py-20">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<div className="p-12 rounded-2xl border border-zinc-800 bg-gradient-to-br from-zinc-900/80 to-zinc-900/30">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-white mb-4">
|
||||
Ready to own your identity?
|
||||
</h2>
|
||||
<p className="text-zinc-400 mb-8 max-w-lg mx-auto">
|
||||
Create your {branding.orgName} ID in seconds. Free, open, and yours forever.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<Link
|
||||
href="/signup"
|
||||
className="px-8 py-3.5 rounded-lg font-medium text-lg transition-opacity hover:opacity-90"
|
||||
style={{ backgroundColor: branding.colors.primary, color: branding.colors.primaryText }}
|
||||
>
|
||||
{content.cta}
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-zinc-400 hover:text-white transition-colors"
|
||||
>
|
||||
Already have an account? Sign in
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-zinc-800/50 px-6 md:px-12 py-8">
|
||||
<div className="max-w-6xl mx-auto flex flex-col md:flex-row items-center justify-between gap-4 text-sm text-zinc-500">
|
||||
<div className="flex items-center gap-3">
|
||||
<img src={branding.logo} alt={branding.orgName} className="h-5 opacity-50" />
|
||||
<span>© {new Date().getFullYear()} {branding.orgName}</span>
|
||||
</div>
|
||||
<div className="flex gap-6">
|
||||
{branding.links.terms && <a href={branding.links.terms} className="hover:text-zinc-300 transition-colors">Terms</a>}
|
||||
{branding.links.privacy && <a href={branding.links.privacy} className="hover:text-zinc-300 transition-colors">Privacy</a>}
|
||||
{branding.links.docs && <a href={branding.links.docs} className="hover:text-zinc-300 transition-colors">Documentation</a>}
|
||||
{branding.links.home && <a href={branding.links.home} className="hover:text-zinc-300 transition-colors">{branding.orgName}</a>}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Suspense } from 'react'
|
||||
import { headers } from 'next/headers'
|
||||
import { getBranding, staticBranding, defaultBranding, resolveBrandingDomain, BrandingConfig } from '@/lib/branding'
|
||||
import SignUpForm from '@/components/SignUpForm'
|
||||
import MarketingPanel from '@/components/MarketingPanel'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
async function getBrandingForDomain(): Promise<BrandingConfig> {
|
||||
const headersList = await headers()
|
||||
const host = headersList.get('host') || 'hanzo.id'
|
||||
const domain = resolveBrandingDomain(host)
|
||||
|
||||
const staticConfig = staticBranding[domain]
|
||||
if (staticConfig) {
|
||||
return { ...defaultBranding, ...staticConfig, domain }
|
||||
}
|
||||
|
||||
return getBranding(domain)
|
||||
}
|
||||
|
||||
export default async function SignUpPage() {
|
||||
const branding = await getBrandingForDomain()
|
||||
|
||||
const cssVars = {
|
||||
'--color-primary': branding.colors.primary,
|
||||
'--color-primary-text': branding.colors.primaryText,
|
||||
'--color-background': branding.colors.background,
|
||||
'--color-surface': branding.colors.surface,
|
||||
'--color-text': branding.colors.text,
|
||||
'--color-text-muted': branding.colors.textMuted,
|
||||
'--color-border': branding.colors.border,
|
||||
'--color-error': branding.colors.error,
|
||||
} as React.CSSProperties
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex" style={cssVars}>
|
||||
<div className="w-full lg:w-1/2 flex items-center justify-center p-8">
|
||||
<div className="login-card w-full max-w-md p-8">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<img
|
||||
src={branding.logo}
|
||||
alt={branding.logoAlt || branding.orgName}
|
||||
className="h-10"
|
||||
/>
|
||||
</div>
|
||||
<Suspense><SignUpForm branding={branding} /></Suspense>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:flex w-1/2 items-center justify-center p-12 bg-gradient-to-br from-black via-zinc-900 to-black">
|
||||
<MarketingPanel branding={branding} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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,35 @@
|
||||
{
|
||||
"name": "@hanzo/id-web",
|
||||
"private": true,
|
||||
"version": "0.1.22",
|
||||
"description": "Hanzo ID — white-label login / signup / IDV portal. Vite + React 19 + @hanzo/gui. Same image serves hanzo.id / lux.id / zoo.id / pars.id.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 5174",
|
||||
"tc": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/brand": "^1.3.0",
|
||||
"@hanzo/gui": "^7.2.4",
|
||||
"@hanzo/iam": "^0.11.0",
|
||||
"@hanzo/id-auth": "workspace:*",
|
||||
"@hanzo/id-idv": "workspace:*",
|
||||
"@hanzo/id-onboarding": "workspace:*",
|
||||
"@hanzo/id-shared": "workspace:*",
|
||||
"@luxfi/brand": "^1.0.0",
|
||||
"@parsdao/brand": "^1.0.0",
|
||||
"@tanstack/react-router": "^1.168.0",
|
||||
"@zooai/brand": "^1.3.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||
<rect width="1024" height="1024" fill="#000000"/>
|
||||
<g transform="translate(128, 128) scale(11.46)">
|
||||
<path d="M22.21 67V44.6369H0V67H22.21Z" fill="#ffffff"/>
|
||||
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#ffffff"/>
|
||||
<path d="M22.21 0H0V22.3184H22.21V0Z" fill="#ffffff"/>
|
||||
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#ffffff"/>
|
||||
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#ffffff"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 541 B |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||
<!-- Hanzo brand mark: black square + canonical block-H. Red is the brand accent (links/CTAs); the mark itself is always black + white. -->
|
||||
<rect width="1024" height="1024" fill="#000000"/>
|
||||
<g transform="translate(128, 128) scale(11.46)">
|
||||
<path d="M22.21 67V44.6369H0V67H22.21Z" fill="#ffffff"/>
|
||||
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#ffffff"/>
|
||||
<path d="M22.21 0H0V22.3184H22.21V0Z" fill="#ffffff"/>
|
||||
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#ffffff"/>
|
||||
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#ffffff"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 683 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="1024" height="1024" fill="#000000"/>
|
||||
<text x="512" y="720" font-family="ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" font-weight="700" font-size="800" fill="#FFFFFF" text-anchor="middle" letter-spacing="-40">L</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 361 B |
@@ -0,0 +1,5 @@
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Lux brand mark: solid black square -->
|
||||
<rect width="1024" height="1024" fill="#000000"/>
|
||||
<text x="512" y="640" font-family="ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" font-weight="700" font-size="640" fill="#FFFFFF" text-anchor="middle" letter-spacing="-32">L</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 407 B |
@@ -0,0 +1,69 @@
|
||||
<svg viewBox="-120 -120 240 240" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!--
|
||||
Pars Network Logo - Persian 8-Pointed Star (Khatam/Shamseh)
|
||||
The traditional Persian geometric motif - recursive fractal star
|
||||
Used in mosques, palaces, and tilework across Persia for millennia.
|
||||
-->
|
||||
<defs>
|
||||
<linearGradient id="pars-gold" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#f5d06f"/>
|
||||
<stop offset="50%" stop-color="#caa24a"/>
|
||||
<stop offset="100%" stop-color="#f3dc8f"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="pars-blue" x1="0" y1="1" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#003355"/>
|
||||
<stop offset="50%" stop-color="#00abff"/>
|
||||
<stop offset="100%" stop-color="#66d0ff"/>
|
||||
</linearGradient>
|
||||
<filter id="pars-glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="2" result="blur"/>
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Outer 8-pointed star -->
|
||||
<g filter="url(#pars-glow)">
|
||||
<path
|
||||
d="M0,-100 L30,-60 L100,-40 L60,0 L100,40 L30,60 L0,100 L-30,60 L-100,40 L-60,0 L-100,-40 L-30,-60 Z"
|
||||
fill="none"
|
||||
stroke="url(#pars-gold)"
|
||||
stroke-width="4"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- Inner star with blue fill -->
|
||||
<path
|
||||
d="M0,-70 L22,-42 L70,-28 L42,0 L70,28 L22,42 L0,70 L-22,42 L-70,28 L-42,0 L-70,-28 L-22,-42 Z"
|
||||
fill="url(#pars-blue)"
|
||||
stroke="url(#pars-gold)"
|
||||
stroke-width="3"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
|
||||
<!-- Recursive inner star -->
|
||||
<path
|
||||
d="M0,-45 L14,-27 L45,-18 L27,0 L45,18 L14,27 L0,45 L-14,27 L-45,18 L-27,0 L-45,-18 L-14,-27 Z"
|
||||
fill="none"
|
||||
stroke="url(#pars-gold)"
|
||||
stroke-width="2"
|
||||
stroke-linejoin="round"
|
||||
opacity="0.8"
|
||||
/>
|
||||
|
||||
<!-- Interlaced circles (Persian geometric pattern) -->
|
||||
<g fill="none" stroke="#eaf7ff" stroke-width="1.5" opacity="0.6">
|
||||
<circle r="55"/>
|
||||
<circle r="35"/>
|
||||
</g>
|
||||
|
||||
<!-- Center rosette -->
|
||||
<circle r="8" fill="url(#pars-gold)"/>
|
||||
<path
|
||||
d="M0,-20 L6,-6 L20,0 L6,6 L0,20 L-6,6 L-20,0 L-6,-6 Z"
|
||||
fill="#002a47"
|
||||
stroke="url(#pars-gold)"
|
||||
stroke-width="1.5"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,38 @@
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Zoo brand mark: three-circle CMYK Venn diagram, full color.
|
||||
Filled to the viewport so it scales cleanly into 32x32 nav slots
|
||||
and 256x256 OG cards without tiny dead margins. -->
|
||||
<defs>
|
||||
<clipPath id="logoClip">
|
||||
<circle cx="512" cy="511" r="500"/>
|
||||
</clipPath>
|
||||
<clipPath id="logoYellow">
|
||||
<circle cx="512" cy="250" r="430"/>
|
||||
</clipPath>
|
||||
<clipPath id="logoMagenta">
|
||||
<circle cx="240" cy="670" r="430"/>
|
||||
</clipPath>
|
||||
<clipPath id="logoCyan">
|
||||
<circle cx="784" cy="670" r="430"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#logoClip)">
|
||||
<circle cx="512" cy="250" r="430" fill="#FCF006"/>
|
||||
<circle cx="240" cy="670" r="430" fill="#EA018E"/>
|
||||
<circle cx="784" cy="670" r="430" fill="#01ACF1"/>
|
||||
<g clip-path="url(#logoYellow)">
|
||||
<circle cx="240" cy="670" r="430" fill="#ED1C24"/>
|
||||
</g>
|
||||
<g clip-path="url(#logoYellow)">
|
||||
<circle cx="784" cy="670" r="430" fill="#00A652"/>
|
||||
</g>
|
||||
<g clip-path="url(#logoMagenta)">
|
||||
<circle cx="784" cy="670" r="430" fill="#2E3192"/>
|
||||
</g>
|
||||
<g clip-path="url(#logoYellow)">
|
||||
<g clip-path="url(#logoMagenta)">
|
||||
<circle cx="784" cy="670" r="430" fill="#000000"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,75 @@
|
||||
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'
|
||||
import { Onboarding } from './pages/Onboarding'
|
||||
|
||||
/**
|
||||
* 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(() => {
|
||||
let cancelled = false
|
||||
async function boot() {
|
||||
// The runtime serves the per-host tenant catalog at /config.json
|
||||
// (templated from SPA_IAM_TENANT_CONFIG_JSON by the static server). Read
|
||||
// it from there — NOT a `window.__ID_CATALOG__` global, which the runtime
|
||||
// never injects (relying on it silently dropped every catalog-only host,
|
||||
// e.g. osage.id, to the bundled Hanzo default). Fall back to the inlined
|
||||
// global, then empty, so a host always resolves to something.
|
||||
let catalogRaw: string | undefined
|
||||
try {
|
||||
const res = await fetch('/config.json', { cache: 'no-store' })
|
||||
if (res.ok) {
|
||||
const cfg = (await res.json()) as { iamTenantConfigJson?: string }
|
||||
catalogRaw = cfg.iamTenantConfigJson
|
||||
}
|
||||
} catch {
|
||||
// network/parse error → fall back below
|
||||
}
|
||||
if (!catalogRaw) {
|
||||
catalogRaw = (window as unknown as { __ID_CATALOG__?: string }).__ID_CATALOG__
|
||||
}
|
||||
const t = resolveTenant(window.location.hostname, { catalog: parseCatalog(catalogRaw) })
|
||||
if (cancelled) return
|
||||
setTenant(t)
|
||||
try {
|
||||
const b = await loadBrand(t.brandPackage)
|
||||
if (cancelled) return
|
||||
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) {
|
||||
if (!cancelled) setError(String(e))
|
||||
}
|
||||
}
|
||||
void boot()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const client = useMemo(() => (tenant ? createAuthClient({ tenant }) : null), [tenant])
|
||||
|
||||
if (error) return <div className="hanzo-id-error">{error}</div>
|
||||
if (!tenant || !brand || !client) return <div>Loading…</div>
|
||||
|
||||
const path = window.location.pathname
|
||||
if (path === '/login' || path.startsWith('/login/')) return <Login client={client} brand={brand} />
|
||||
if (path === '/signup' || path.startsWith('/signup/')) return <Signup client={client} brand={brand} />
|
||||
if (path === '/forget' || path === '/forgot' || path.startsWith('/forg')) return <Forgot client={client} brand={brand} />
|
||||
if (path === '/callback' || path.startsWith('/callback/')) return <Callback tenant={tenant} brand={brand} />
|
||||
if (path === '/onboarding' || path.startsWith('/onboarding/')) return <Onboarding tenant={tenant} brand={brand} />
|
||||
return <Portal client={client} brand={brand} tenant={tenant} />
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
:root {
|
||||
--brand: #ffffff;
|
||||
--bg: #0a0a0a;
|
||||
--fg: #fafafa;
|
||||
--muted: #a3a3a3;
|
||||
--border: #262626;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { height: 100%; margin: 0; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hanzo-id-page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.hanzo-id-brand-header {
|
||||
padding: 16px 0 32px;
|
||||
}
|
||||
|
||||
/* Text fallback when a brand ships no logo asset (see BrandHeader). */
|
||||
.hanzo-id-wordmark {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.hanzo-id-page main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.hanzo-id-page h1 { margin: 0; font-size: 28px; }
|
||||
.hanzo-id-page .lede { color: var(--muted); margin: 0; }
|
||||
|
||||
form { display: flex; flex-direction: column; gap: 16px; }
|
||||
form label { display: flex; flex-direction: column; gap: 6px; font-size: 14px; color: var(--muted); }
|
||||
form input {
|
||||
background: #111;
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
font-size: 16px;
|
||||
}
|
||||
form input:focus { outline: 2px solid var(--brand); outline-offset: -1px; }
|
||||
|
||||
.hanzo-id-btn, form button {
|
||||
background: var(--fg);
|
||||
color: var(--bg);
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
display: inline-block;
|
||||
}
|
||||
.hanzo-id-btn[aria-disabled='true'], form button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.hanzo-id-btn.primary { background: var(--brand); }
|
||||
|
||||
.hanzo-id-cta-row { display: flex; gap: 12px; }
|
||||
.hanzo-id-footer-links { color: var(--muted); font-size: 14px; }
|
||||
.hanzo-id-footer-links a { color: var(--fg); }
|
||||
|
||||
/* A2P SMS consent disclosure (shown on phone/SMS surfaces). */
|
||||
.hanzo-id-sms-consent { color: var(--muted); font-size: 12px; line-height: 1.5; }
|
||||
.hanzo-id-sms-consent p { margin: 0 0 6px; }
|
||||
.hanzo-id-sms-consent-links { margin: 0; }
|
||||
.hanzo-id-sms-consent a { color: var(--fg); }
|
||||
|
||||
.hanzo-id-error {
|
||||
background: #2d0a0a;
|
||||
color: #ff7878;
|
||||
border: 1px solid #5a1414;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.hanzo-id-info {
|
||||
background: #0a1f2d;
|
||||
color: #78b8ff;
|
||||
border: 1px solid #14385a;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ── Forced TOTP enrollment ────────────────────────────────────── */
|
||||
.hanzo-id-mfa-enroll { display: flex; flex-direction: column; gap: 16px; }
|
||||
.hanzo-id-mfa-enroll h2 { margin: 0; font-size: 22px; }
|
||||
.hanzo-id-mfa-qr {
|
||||
align-self: center;
|
||||
background: #fff;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
.hanzo-id-mfa-qr svg { width: 100%; height: 100%; display: block; }
|
||||
.hanzo-id-mfa-manual { font-size: 14px; color: var(--muted); }
|
||||
.hanzo-id-mfa-manual summary { cursor: pointer; }
|
||||
.hanzo-id-mfa-secret,
|
||||
.hanzo-id-mfa-recovery code {
|
||||
display: inline-block;
|
||||
margin-top: 8px;
|
||||
padding: 6px 10px;
|
||||
background: #111;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
letter-spacing: 2px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.hanzo-id-mfa-recovery { font-size: 13px; color: var(--muted); line-height: 1.6; }
|
||||
.hanzo-id-mfa-recovery code { letter-spacing: normal; }
|
||||
|
||||
/* ── Social / Web3 sign-in buttons ─────────────────────────────── */
|
||||
.hanzo-id-social { display: flex; flex-direction: column; gap: 10px; }
|
||||
.hanzo-id-social-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
background: #111;
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 11px 14px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.hanzo-id-social-btn:hover { border-color: #3a3a3a; background: #161616; }
|
||||
.hanzo-id-social-btn svg { flex: none; }
|
||||
|
||||
/* Labeled divider between social row and email form. */
|
||||
.hanzo-id-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.hanzo-id-divider::before,
|
||||
.hanzo-id-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
.hanzo-id-divider span { padding: 0 12px; }
|
||||
|
||||
/* ── Onboarding flow ───────────────────────────────────────────── */
|
||||
.hanzo-id-onboarding { display: flex; flex-direction: column; gap: 24px; }
|
||||
.hanzo-id-onboarding-head { display: flex; flex-direction: column; gap: 6px; }
|
||||
.hanzo-id-onboarding-head h1 { margin: 0; font-size: 26px; }
|
||||
.hanzo-id-onboarding-body { display: flex; flex-direction: column; gap: 16px; }
|
||||
.hanzo-id-onboarding-done { display: flex; flex-direction: column; gap: 16px; }
|
||||
.hanzo-id-onboarding-done h1 { margin: 0; font-size: 26px; }
|
||||
|
||||
.hanzo-id-stepdots { display: flex; gap: 8px; }
|
||||
.hanzo-id-stepdots span {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--border);
|
||||
}
|
||||
.hanzo-id-stepdots span.on { background: var(--brand); }
|
||||
|
||||
.hanzo-id-org-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.hanzo-id-org-row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #111;
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.hanzo-id-org-row:hover { border-color: #3a3a3a; background: #161616; }
|
||||
.hanzo-id-org-slug { color: var(--muted); font-size: 13px; font-family: ui-monospace, monospace; }
|
||||
|
||||
.hanzo-id-linkbtn {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--fg);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
padding: 4px 0;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.hanzo-id-slug-preview { color: var(--muted); font-size: 13px; margin: -8px 0 0; font-family: ui-monospace, monospace; }
|
||||
|
||||
.hanzo-id-onboarding-actions { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.hanzo-id-onboarding-actions .hanzo-id-btn { flex: 1; min-width: 120px; }
|
||||
.hanzo-id-btn.ghost { background: transparent; color: var(--fg); border: 1px solid var(--border); }
|
||||
.hanzo-id-btn.ghost:hover { border-color: #3a3a3a; }
|
||||
|
||||
.hanzo-id-summary { display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; margin: 0; }
|
||||
.hanzo-id-summary dt { color: var(--muted); font-size: 14px; }
|
||||
.hanzo-id-summary dd { margin: 0; font-size: 14px; font-family: ui-monospace, monospace; }
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useState } from 'react'
|
||||
import type { BrandContract } from '@hanzo/id-shared'
|
||||
|
||||
/**
|
||||
* Brand lockup for the auth pages. Renders the brand logo when it loads, and
|
||||
* falls back to the brand NAME as a text wordmark when the logo is absent or
|
||||
* fails to load — so a 404 logo (e.g. a brand package that doesn't ship its
|
||||
* assets) never shows a broken-image icon. The name always exists on the
|
||||
* brand contract, so the header is always presentable.
|
||||
*/
|
||||
export function BrandHeader({ brand }: { brand: BrandContract }) {
|
||||
const [imgOk, setImgOk] = useState(true)
|
||||
const showImg = Boolean(brand.logoUrl) && imgOk
|
||||
return (
|
||||
<header className="hanzo-id-brand-header">
|
||||
<a href="/" aria-label={brand.name}>
|
||||
{showImg ? (
|
||||
<img src={brand.logoUrl} alt={brand.name} height={32} onError={() => setImgOk(false)} />
|
||||
) : (
|
||||
<span className="hanzo-id-wordmark">{brand.name}</span>
|
||||
)}
|
||||
</a>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { App } from './App'
|
||||
import { registerProvider } from '@hanzo/id-idv'
|
||||
import { createStubProvider } from '@hanzo/id-idv/providers/stub'
|
||||
import './app.css'
|
||||
|
||||
// Default IDV provider — replace at boot via env-driven config.
|
||||
registerProvider(createStubProvider())
|
||||
|
||||
const root = document.getElementById('root')
|
||||
if (!root) throw new Error('#root missing')
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Per-org marketing content + app launcher links.
|
||||
*
|
||||
* The brand-neutral `BrandContract` (from `loadBrand`) carries only the
|
||||
* visual essentials (name, logo, accent). The split-view login's marketing
|
||||
* panel and the post-login apps launcher need richer, org-specific copy —
|
||||
* ported verbatim from the frozen `legacy-nextjs` design (`staticBranding`
|
||||
* content + `orgApps`). Keyed by `tenant.orgId` so it stays decoupled from
|
||||
* hostname switches; unknown orgs fall back to `hanzo`.
|
||||
*/
|
||||
|
||||
export interface Quote {
|
||||
readonly text: string
|
||||
readonly author: string
|
||||
readonly role?: string
|
||||
}
|
||||
|
||||
export interface Marketing {
|
||||
/** Pill above the hero ("✦ <tagline>"). */
|
||||
readonly tagline?: string
|
||||
/** Hero heading. */
|
||||
readonly title: string
|
||||
/** Hero subheading. */
|
||||
readonly subtitle: string
|
||||
/** Rotating testimonials. */
|
||||
readonly quotes: readonly Quote[]
|
||||
}
|
||||
|
||||
export interface AppLink {
|
||||
readonly name: string
|
||||
readonly href: string
|
||||
readonly description: string
|
||||
}
|
||||
|
||||
const MARKETING: Record<string, Marketing> = {
|
||||
hanzo: {
|
||||
tagline: 'AI-powered development',
|
||||
title: 'Start building in seconds',
|
||||
subtitle: 'Describe your idea and watch AI bring it to life instantly.',
|
||||
quotes: [
|
||||
{ text: 'Hanzo is amazing. It is revolutionizing how we build and deploy applications.', author: 'Developer', role: 'Software Engineer' },
|
||||
],
|
||||
},
|
||||
lux: {
|
||||
tagline: 'Lux-powered infrastructure',
|
||||
title: 'Start deploying in seconds',
|
||||
subtitle: 'High-performance blockchain infrastructure for the Lux ecosystem.',
|
||||
quotes: [
|
||||
{ text: 'Lux is fast. We deploy chains in minutes, not weeks.', author: 'Validator', role: 'Node Operator' },
|
||||
],
|
||||
},
|
||||
zoo: {
|
||||
tagline: 'Open AI research network',
|
||||
title: 'Build the future of DeAI',
|
||||
subtitle: 'Open AI research and decentralized science for everyone.',
|
||||
quotes: [
|
||||
{ text: 'Zoo is where bleeding-edge DeAI experiments actually ship.', author: 'Researcher', role: 'ML Engineer' },
|
||||
],
|
||||
},
|
||||
pars: {
|
||||
tagline: 'Sovereign digital identity',
|
||||
title: 'Welcome to Pars',
|
||||
subtitle: 'The decentralized network for the next generation.',
|
||||
quotes: [
|
||||
{ text: 'Pars gives our community a sovereign, verifiable identity layer.', author: 'Member', role: 'Community Lead' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const APPS: Record<string, readonly AppLink[]> = {
|
||||
hanzo: [
|
||||
{ name: 'Console', href: 'https://console.hanzo.ai', description: 'Observability & traces' },
|
||||
{ name: 'Chat', href: 'https://hanzo.chat', description: 'AI chat interface' },
|
||||
{ name: 'Cloud', href: 'https://cloud.hanzo.ai', description: 'AI model API' },
|
||||
{ name: 'Analytics', href: 'https://analytics.hanzo.ai', description: 'Web analytics' },
|
||||
{ name: 'Platform', href: 'https://platform.hanzo.ai', description: 'PaaS deployments' },
|
||||
{ name: 'Storage', href: 'https://s3.hanzo.ai', description: 'S3-compatible storage' },
|
||||
],
|
||||
lux: [
|
||||
{ name: 'Bridge', href: 'https://bridge.lux.network', description: 'Cross-chain bridge' },
|
||||
{ name: 'Exchange', href: 'https://lux.exchange', description: 'DEX trading' },
|
||||
{ name: 'Cloud', href: 'https://lux.cloud', description: 'Lux Cloud' },
|
||||
{ name: 'Explorer', href: 'https://explore.lux.network', description: 'Block explorer' },
|
||||
],
|
||||
zoo: [
|
||||
{ name: 'Network', href: 'https://zoo.ngo', description: 'Zoo Labs Foundation' },
|
||||
{ name: 'ZIPs', href: 'https://zips.zoo.ngo', description: 'Improvement proposals' },
|
||||
{ name: 'Chat', href: 'https://chat.zoo.ngo', description: 'DeAI chat interface' },
|
||||
],
|
||||
pars: [
|
||||
{ name: 'Network', href: 'https://pars.network', description: 'Pars Network' },
|
||||
{ name: 'Vote', href: 'https://pars.vote', description: 'Governance & proposals' },
|
||||
],
|
||||
}
|
||||
|
||||
const BILLING: Record<string, string> = {
|
||||
hanzo: 'https://billing.hanzo.ai',
|
||||
lux: 'https://billing.lux.network',
|
||||
zoo: 'https://billing.zoo.network',
|
||||
pars: 'https://billing.pars.network',
|
||||
}
|
||||
|
||||
export function marketingFor(orgId: string): Marketing {
|
||||
return MARKETING[orgId] ?? MARKETING.hanzo
|
||||
}
|
||||
|
||||
export function appsFor(orgId: string): readonly AppLink[] {
|
||||
return APPS[orgId] ?? APPS.hanzo
|
||||
}
|
||||
|
||||
export function billingFor(orgId: string): string {
|
||||
return BILLING[orgId] ?? BILLING.hanzo
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
|
||||
import { createIam, createAuthClient } from '@hanzo/id-auth'
|
||||
import { BrandHeader } from '../components/BrandHeader'
|
||||
|
||||
/**
|
||||
* OAuth/OIDC callback.
|
||||
*
|
||||
* Two kinds of return land here:
|
||||
* 1. The portal's own OIDC PKCE return (password / SDK `signinRedirect`) —
|
||||
* completed by the `@hanzo/iam` SDK's `handleCallback`, which reads back
|
||||
* the exact PKCE verifier/state it stored.
|
||||
* 2. A SOCIAL provider return (GitHub/Google), where `social.ts` sent the
|
||||
* user out with a base64 `state` that encodes the original authorize
|
||||
* request. We detect that, exchange the provider `code` at the IAM backend
|
||||
* (`client.providerLogin`), and follow the continue-URL it returns — which
|
||||
* re-enters this callback as case (1). (Pending live verification; only
|
||||
* reachable once real provider creds are seeded.)
|
||||
*
|
||||
* Routing after the OIDC exchange:
|
||||
* - A downstream app left its target in `post_login_redirect` → forward tokens.
|
||||
* - A bare portal sign-in → `/onboarding`.
|
||||
*/
|
||||
|
||||
/** Decode a social-provider `state` (base64 of the original authorize query). */
|
||||
function decodeProviderState(state: string | null): URLSearchParams | null {
|
||||
if (!state) return null
|
||||
try {
|
||||
const decoded = atob(state)
|
||||
const params = new URLSearchParams(decoded.replace(/^\?/, ''))
|
||||
// A provider-login state always carries application + provider markers.
|
||||
if (params.get('provider') && params.get('application')) return params
|
||||
} catch {
|
||||
// not base64 → an SDK/OIDC state, not a provider return
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function Callback({ tenant, brand }: { tenant: TenantConfig; brand: BrandContract }) {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
useEffect(() => {
|
||||
const search = new URLSearchParams(window.location.search)
|
||||
const providerState = decodeProviderState(search.get('state'))
|
||||
|
||||
// Case (2): social provider return → exchange the provider code, then follow
|
||||
// the continue-URL back into case (1).
|
||||
if (providerState && search.get('code')) {
|
||||
const client = createAuthClient({ tenant })
|
||||
const oidcQuery = atob(search.get('state')!)
|
||||
client
|
||||
.providerLogin({
|
||||
application: providerState.get('application') ?? '',
|
||||
provider: providerState.get('provider') ?? '',
|
||||
code: search.get('code') ?? '',
|
||||
oidcQuery,
|
||||
method: providerState.get('method') ?? 'signin',
|
||||
})
|
||||
.then((r) => {
|
||||
if (r.redirectUrl) window.location.replace(r.redirectUrl)
|
||||
else setError(r.error ?? 'Sign-in failed')
|
||||
})
|
||||
.catch((e) => setError(String(e)))
|
||||
return
|
||||
}
|
||||
|
||||
// Case (1): the portal's own OIDC PKCE return.
|
||||
const iam = createIam(tenant)
|
||||
iam
|
||||
.handleCallback(window.location.href)
|
||||
.then((tok) => {
|
||||
const target = sessionStorage.getItem('post_login_redirect')
|
||||
sessionStorage.removeItem('post_login_redirect')
|
||||
if (target) {
|
||||
// Forward tokens to whichever app initiated this flow.
|
||||
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)
|
||||
window.location.replace(url.toString())
|
||||
return
|
||||
}
|
||||
// Bare portal sign-in → onboarding.
|
||||
window.location.replace('/onboarding')
|
||||
})
|
||||
.catch((e) => setError(String(e)))
|
||||
}, [tenant])
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-callback">
|
||||
<BrandHeader brand={brand} />
|
||||
<main>
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : <p>Completing sign-in…</p>}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { BrandContract } from '@hanzo/id-shared'
|
||||
import { ForgotForm, type AuthClient } from '@hanzo/id-auth'
|
||||
import { BrandHeader } from '../components/BrandHeader'
|
||||
|
||||
export function Forgot({ client, brand }: { client: AuthClient; brand: BrandContract }) {
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-forgot">
|
||||
<BrandHeader brand={brand} />
|
||||
<main>
|
||||
<h1>Reset your {brand.name} password</h1>
|
||||
<ForgotForm client={client} />
|
||||
<p className="hanzo-id-footer-links">
|
||||
<a href="/login">Back to sign in</a>
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useState } from 'react'
|
||||
import type { BrandContract } from '@hanzo/id-shared'
|
||||
import {
|
||||
LoginForm,
|
||||
MfaEnrollForm,
|
||||
OTPForm,
|
||||
SocialButtons,
|
||||
mfaChannelOf,
|
||||
type AuthClient,
|
||||
type LoginResponse,
|
||||
} 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
|
||||
const codeChallenge = sp.get('code_challenge') ?? undefined
|
||||
const codeChallengeMethod = (sp.get('code_challenge_method') as 'S256' | 'plain' | null) ?? undefined
|
||||
|
||||
// null = show the credential form; otherwise IAM returned an MFA signal and
|
||||
// we render the matching step instead of navigating on.
|
||||
const [mfa, setMfa] = useState<LoginResponse | null>(null)
|
||||
const [challengeError, setChallengeError] = useState<string | null>(null)
|
||||
|
||||
const clientId = clientIdOverride ?? client.tenant.clientId
|
||||
|
||||
// The credential check succeeded (or MFA was satisfied). For a downstream
|
||||
// OIDC request, re-enter authorize with the now-established IAM session so it
|
||||
// mints the code; for a bare portal sign-in, land on onboarding.
|
||||
function completeAfterAuth() {
|
||||
if (redirectUri) {
|
||||
window.location.href = client.authorize({
|
||||
clientId,
|
||||
redirectUri,
|
||||
state: state ?? '',
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
})
|
||||
} else {
|
||||
window.location.href = '/onboarding'
|
||||
}
|
||||
}
|
||||
|
||||
if (mfa?.mfaStage === 'enroll') {
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-login">
|
||||
<BrandHeader brand={brand} />
|
||||
<main>
|
||||
<MfaEnrollForm client={client} onComplete={completeAfterAuth} />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (mfa?.mfaStage === 'challenge') {
|
||||
const iamType = mfa.mfaTypes?.[0] ?? 'app'
|
||||
async function onChallenge(code: string) {
|
||||
setChallengeError(null)
|
||||
const res = await client.mfaChallenge({
|
||||
mfaType: iamType,
|
||||
passcode: code,
|
||||
clientId,
|
||||
application: client.tenant.appName,
|
||||
organization: client.tenant.orgId,
|
||||
redirectUri,
|
||||
state,
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
})
|
||||
if (res.error) {
|
||||
setChallengeError(res.error)
|
||||
} else if (res.redirectUrl) {
|
||||
window.location.href = res.redirectUrl
|
||||
} else {
|
||||
completeAfterAuth()
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-login">
|
||||
<BrandHeader brand={brand} />
|
||||
<main>
|
||||
<h1>Two-factor authentication</h1>
|
||||
<p className="lede">Enter the code from your authenticator app to finish signing in.</p>
|
||||
{challengeError ? <p role="alert" className="hanzo-id-error">{challengeError}</p> : null}
|
||||
<OTPForm channel={mfaChannelOf(iamType)} onSubmit={onChallenge} />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-login">
|
||||
<BrandHeader brand={brand} />
|
||||
<main>
|
||||
<h1>Sign in to {brand.name}</h1>
|
||||
<SocialButtons
|
||||
client={client}
|
||||
clientIdOverride={clientIdOverride}
|
||||
intent="signin"
|
||||
postLoginRedirect={redirectUri}
|
||||
/>
|
||||
<LoginForm
|
||||
client={client}
|
||||
redirectUri={redirectUri}
|
||||
state={state}
|
||||
clientIdOverride={clientIdOverride ?? undefined}
|
||||
codeChallenge={codeChallenge}
|
||||
codeChallengeMethod={codeChallengeMethod}
|
||||
onMfaRequired={setMfa}
|
||||
/>
|
||||
<p className="hanzo-id-footer-links">
|
||||
<a href="/forget">Forgot password?</a> · <a href="/signup">Create account</a>
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
|
||||
import { createIam } from '@hanzo/id-auth'
|
||||
import { OnboardingFlow, createOnboardingService, type OnboardingState } from '@hanzo/id-onboarding'
|
||||
import { BrandHeader } from '../components/BrandHeader'
|
||||
|
||||
/**
|
||||
* Post-login onboarding page.
|
||||
*
|
||||
* Reached after a bare portal sign-in (no downstream `redirect_uri`). Mounts
|
||||
* the `@hanzo/id-onboarding` flow (org → project → wallet) wired to:
|
||||
*
|
||||
* - the IAM session token: read from the same `@hanzo/iam` PKCE client the
|
||||
* Callback stored it on, so the onboarding writes ride the logged-in
|
||||
* user's bearer token. One client, one way.
|
||||
* - a `window.ethereum` wallet connector: the host owns the wallet lib so
|
||||
* the onboarding pkg stays wallet-agnostic. Absent injected provider →
|
||||
* the wallet step is skip-only.
|
||||
*
|
||||
* On completion it lands on the portal home (`/`); a downstream app that
|
||||
* wanted a token would have carried `redirect_uri` and never reached here.
|
||||
*/
|
||||
export function Onboarding({ tenant, brand }: { tenant: TenantConfig; brand: BrandContract }) {
|
||||
const iam = useMemo(() => createIam(tenant), [tenant])
|
||||
|
||||
const service = useMemo(
|
||||
() =>
|
||||
createOnboardingService({
|
||||
iamUrl: tenant.iamUrl,
|
||||
orgId: tenant.orgId,
|
||||
getAccessToken: () => iam.getValidAccessToken(),
|
||||
}),
|
||||
[tenant, iam],
|
||||
)
|
||||
|
||||
function onComplete(_state: OnboardingState) {
|
||||
// Land on the authenticated portal (apps launcher), NOT the bare hero.
|
||||
// The marker makes the portal treat the just-established session as authed
|
||||
// even before the cross-request get-account read settles.
|
||||
window.location.replace('/?signed_in=1')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-onboarding-page">
|
||||
<BrandHeader brand={brand} />
|
||||
<main>
|
||||
<OnboardingFlow
|
||||
service={service}
|
||||
brandName={brand.name}
|
||||
connectWallet={connectInjectedWallet}
|
||||
onComplete={onComplete}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Minimal EIP-1193 `eth_requestAccounts` connector. Null on cancel/no wallet. */
|
||||
async function connectInjectedWallet(): Promise<string | null> {
|
||||
const eth = (window as unknown as { ethereum?: Eip1193 }).ethereum
|
||||
if (!eth) return null
|
||||
try {
|
||||
const accounts = (await eth.request({ method: 'eth_requestAccounts' })) as string[]
|
||||
return accounts?.[0] ?? null
|
||||
} catch {
|
||||
return null // user rejected the connection prompt
|
||||
}
|
||||
}
|
||||
|
||||
interface Eip1193 {
|
||||
request(args: { method: string; params?: unknown[] }): Promise<unknown>
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
|
||||
import type { AuthClient } from '@hanzo/id-auth'
|
||||
import { Login } from './Login'
|
||||
import { BrandHeader } from '../components/BrandHeader'
|
||||
import { appsFor, billingFor } from '../marketing'
|
||||
|
||||
type Auth =
|
||||
| { s: 'loading' }
|
||||
| { s: 'anon' }
|
||||
| { s: 'authed'; name?: string; email?: string }
|
||||
|
||||
/**
|
||||
* Root portal (`/`). The portal IS the login surface, not a marketing hero:
|
||||
*
|
||||
* - signed out → the actual `<Login>` form (GitHub/Google/email+password),
|
||||
* identical to `/login`. A bare sign-in here lands on
|
||||
* onboarding, then back on `/` authenticated.
|
||||
* - signed in → the apps launcher (the org's apps) + billing / sign-out.
|
||||
*
|
||||
* Auth is read same-origin from `/v1/iam/get-account` (cookie session;
|
||||
* `tenant.iamUrl` is the brand's own `*.id` host, so this is first-party and
|
||||
* the session cookie rides along). The `?signed_in=1` marker set by the
|
||||
* bare-login / onboarding-complete redirect is the authoritative "just
|
||||
* authenticated" signal when the cookie read hasn't propagated yet.
|
||||
*/
|
||||
export function Portal({
|
||||
client,
|
||||
brand,
|
||||
tenant,
|
||||
}: {
|
||||
client: AuthClient
|
||||
brand: BrandContract
|
||||
tenant: TenantConfig
|
||||
}) {
|
||||
const [auth, setAuth] = useState<Auth>({ s: 'loading' })
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
const justSignedIn = new URLSearchParams(window.location.search).get('signed_in') === '1'
|
||||
fetch(new URL('/v1/iam/get-account', tenant.iamUrl).toString(), {
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((b: Record<string, unknown>) => {
|
||||
if (!alive) return
|
||||
const d = b.data as Record<string, unknown> | undefined
|
||||
if (b.status === 'ok' && d && typeof d === 'object') {
|
||||
setAuth({ s: 'authed', name: str(d.displayName) ?? str(d.name), email: str(d.email) })
|
||||
} else {
|
||||
setAuth(justSignedIn ? { s: 'authed' } : { s: 'anon' })
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setAuth(justSignedIn ? { s: 'authed' } : { s: 'anon' })
|
||||
})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [tenant.iamUrl])
|
||||
|
||||
if (auth.s === 'loading') {
|
||||
return (
|
||||
<div className="hanzo-id-page" style={{ minHeight: '40vh' }}>
|
||||
<div className="hanzo-id-spinner" style={{ borderTopColor: brand.accentColor ?? '#fff' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Signed out: the root IS the login form (no marketing hero).
|
||||
if (auth.s === 'anon') return <Login client={client} brand={brand} />
|
||||
|
||||
// Signed in: the apps launcher.
|
||||
const apps = appsFor(tenant.orgId)
|
||||
const billingUrl = billingFor(tenant.orgId)
|
||||
const logoutUrl = client.logout(undefined, `${tenant.publicOrigin}/login`)
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-portal">
|
||||
<BrandHeader brand={brand} />
|
||||
<main style={{ width: '100%', maxWidth: 760 }}>
|
||||
<h1>Your {brand.name} apps</h1>
|
||||
{auth.email ? <p className="lede">{auth.email}</p> : null}
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(210px, 1fr))',
|
||||
gap: 12,
|
||||
marginTop: 24,
|
||||
}}
|
||||
>
|
||||
{apps.map((a) => (
|
||||
<a
|
||||
key={a.name}
|
||||
href={a.href}
|
||||
style={{
|
||||
display: 'block',
|
||||
padding: '16px 18px',
|
||||
border: '1px solid rgba(255,255,255,0.14)',
|
||||
borderRadius: 12,
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>{a.name}</span>
|
||||
<span aria-hidden style={{ opacity: 0.5 }}>↗</span>
|
||||
</div>
|
||||
<div style={{ opacity: 0.6, fontSize: 13, marginTop: 4 }}>{a.description}</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: 28, display: 'flex', gap: 18 }}>
|
||||
<a className="hanzo-id-linkbtn" href={billingUrl}>Billing</a>
|
||||
<a className="hanzo-id-linkbtn" href={logoutUrl}>Sign out</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function str(v: unknown): string | undefined {
|
||||
return typeof v === 'string' && v.length > 0 ? v : undefined
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { BrandContract } from '@hanzo/id-shared'
|
||||
import { SignupForm, SocialButtons, 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
|
||||
const clientIdOverride = sp.get('client_id') ?? undefined
|
||||
const redirectUri = sp.get('redirect_uri') ?? undefined
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-signup">
|
||||
<BrandHeader brand={brand} />
|
||||
<main>
|
||||
<h1>Create your {brand.name} account</h1>
|
||||
<SocialButtons
|
||||
client={client}
|
||||
clientIdOverride={clientIdOverride}
|
||||
intent="signup"
|
||||
postLoginRedirect={redirectUri}
|
||||
/>
|
||||
<SignupForm client={client} inviteCode={inviteCode} />
|
||||
<p className="hanzo-id-footer-links">
|
||||
Already have an account? <a href="/login">Sign in</a>
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { resolve } from 'path'
|
||||
import { readFileSync, existsSync } from 'fs'
|
||||
import { createRequire } from 'module'
|
||||
|
||||
// ESM Vite config has no global `require`; build one bound to this file so
|
||||
// `require.resolve('@scope/brand/brand.json')` works at config-eval time.
|
||||
const req = createRequire(import.meta.url)
|
||||
|
||||
/**
|
||||
* Per-brand brand.json copy plugin.
|
||||
*
|
||||
* Each per-org brand package (`@hanzo/brand`, `@luxfi/brand`, `@zooai/brand`,
|
||||
* `@parsdao/brand`) ships a `brand.json` at the package root. We serve each at
|
||||
* a FLAT, encoding-safe path `/brand/<scope>.json` (scope = the npm scope:
|
||||
* `@hanzo/brand` -> `hanzo`). A nested `/brand/@hanzo/brand/brand.json` URL
|
||||
* carries a literal `@` and an encoded `%2F` that the production static server
|
||||
* (hanzoai/static) cannot map to the on-disk file — it falls through to the
|
||||
* SPA catch-all and returns index.html, so the runtime brand fetch would parse
|
||||
* HTML as JSON. The flat slug avoids that entirely. `loadBrand` fetches the
|
||||
* same `/brand/<scope>.json`.
|
||||
*
|
||||
* Assets (logos, favicons) are imported by URL inside the per-brand `brand.json`
|
||||
* (CDN URLs in production), so no further asset copying is needed.
|
||||
*/
|
||||
const BRAND_PACKAGES = ['@hanzo/brand', '@luxfi/brand', '@zooai/brand', '@parsdao/brand']
|
||||
|
||||
/** npm scope -> flat brand slug: `@hanzo/brand` -> `hanzo`. */
|
||||
const brandSlug = (pkg: string): string => pkg.replace(/^@/, '').split('/')[0]!
|
||||
|
||||
function brandJsonPlugin() {
|
||||
return {
|
||||
name: 'hanzo-id-brand-json',
|
||||
configureServer(server: any) {
|
||||
server.middlewares.use((req2: any, res: any, next: any) => {
|
||||
const m = /^\/brand\/([^/]+)\.json$/.exec(req2.url ?? '')
|
||||
if (!m) return next()
|
||||
const slug = m[1]!
|
||||
const pkg = BRAND_PACKAGES.find((p) => brandSlug(p) === slug)
|
||||
if (!pkg) {
|
||||
res.statusCode = 404
|
||||
return res.end()
|
||||
}
|
||||
try {
|
||||
const path = req.resolve(`${pkg}/brand.json`)
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
return res.end(readFileSync(path, 'utf8'))
|
||||
} catch {
|
||||
res.statusCode = 404
|
||||
return res.end()
|
||||
}
|
||||
})
|
||||
},
|
||||
generateBundle(this: any) {
|
||||
for (const pkg of BRAND_PACKAGES) {
|
||||
try {
|
||||
const path = req.resolve(`${pkg}/brand.json`)
|
||||
if (!existsSync(path)) continue
|
||||
this.emitFile({
|
||||
type: 'asset',
|
||||
fileName: `brand/${brandSlug(pkg)}.json`,
|
||||
source: readFileSync(path, 'utf8'),
|
||||
})
|
||||
} catch {
|
||||
// pkg not installed — skip silently; only the brands listed in
|
||||
// package.json deps actually need their JSON shipped.
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), brandJsonPlugin()],
|
||||
resolve: {
|
||||
alias: { '@': resolve(__dirname, 'src') },
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
preview: {
|
||||
port: 5174,
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
build: {
|
||||
target: 'es2022',
|
||||
sourcemap: true,
|
||||
},
|
||||
})
|
||||
@@ -1,148 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
|
||||
const LANGUAGES = [
|
||||
{ code: 'en', label: 'English' },
|
||||
{ code: 'zh', label: '中文' },
|
||||
{ code: 'zh-TW', label: '繁體中文' },
|
||||
{ code: 'ja', label: '日本語' },
|
||||
{ code: 'ko', label: '한국어' },
|
||||
{ code: 'es', label: 'Español' },
|
||||
{ code: 'fr', label: 'Français' },
|
||||
{ code: 'de', label: 'Deutsch' },
|
||||
{ code: 'pt', label: 'Português' },
|
||||
{ code: 'pt-BR', label: 'Português (BR)' },
|
||||
{ code: 'it', label: 'Italiano' },
|
||||
{ code: 'nl', label: 'Nederlands' },
|
||||
{ code: 'pl', label: 'Polski' },
|
||||
{ code: 'cs', label: 'Čeština' },
|
||||
{ code: 'sk', label: 'Slovenčina' },
|
||||
{ code: 'hu', label: 'Magyar' },
|
||||
{ code: 'ro', label: 'Română' },
|
||||
{ code: 'bg', label: 'Български' },
|
||||
{ code: 'hr', label: 'Hrvatski' },
|
||||
{ code: 'sr', label: 'Српски' },
|
||||
{ code: 'sl', label: 'Slovenščina' },
|
||||
{ code: 'uk', label: 'Українська' },
|
||||
{ code: 'ru', label: 'Русский' },
|
||||
{ code: 'el', label: 'Ελληνικά' },
|
||||
{ code: 'tr', label: 'Türkçe' },
|
||||
{ code: 'ar', label: 'العربية' },
|
||||
{ code: 'fa', label: 'فارسی' },
|
||||
{ code: 'he', label: 'עברית' },
|
||||
{ code: 'hi', label: 'हिन्दी' },
|
||||
{ code: 'bn', label: 'বাংলা' },
|
||||
{ code: 'ta', label: 'தமிழ்' },
|
||||
{ code: 'te', label: 'తెలుగు' },
|
||||
{ code: 'mr', label: 'मराठी' },
|
||||
{ code: 'gu', label: 'ગુજરાતી' },
|
||||
{ code: 'kn', label: 'ಕನ್ನಡ' },
|
||||
{ code: 'ml', label: 'മലയാളം' },
|
||||
{ code: 'pa', label: 'ਪੰਜਾਬੀ' },
|
||||
{ code: 'ur', label: 'اردو' },
|
||||
{ code: 'th', label: 'ไทย' },
|
||||
{ code: 'vi', label: 'Tiếng Việt' },
|
||||
{ code: 'id', label: 'Bahasa Indonesia' },
|
||||
{ code: 'ms', label: 'Bahasa Melayu' },
|
||||
{ code: 'tl', label: 'Filipino' },
|
||||
{ code: 'sw', label: 'Kiswahili' },
|
||||
{ code: 'am', label: 'አማርኛ' },
|
||||
{ code: 'ha', label: 'Hausa' },
|
||||
{ code: 'yo', label: 'Yorùbá' },
|
||||
{ code: 'ig', label: 'Igbo' },
|
||||
{ code: 'zu', label: 'isiZulu' },
|
||||
{ code: 'af', label: 'Afrikaans' },
|
||||
{ code: 'sv', label: 'Svenska' },
|
||||
{ code: 'da', label: 'Dansk' },
|
||||
{ code: 'no', label: 'Norsk' },
|
||||
{ code: 'fi', label: 'Suomi' },
|
||||
{ code: 'et', label: 'Eesti' },
|
||||
{ code: 'lv', label: 'Latviešu' },
|
||||
{ code: 'lt', label: 'Lietuvių' },
|
||||
{ code: 'ca', label: 'Català' },
|
||||
{ code: 'eu', label: 'Euskara' },
|
||||
{ code: 'gl', label: 'Galego' },
|
||||
{ code: 'ka', label: 'ქართული' },
|
||||
{ code: 'hy', label: 'Հայերեն' },
|
||||
{ code: 'az', label: 'Azərbaycan' },
|
||||
{ code: 'uz', label: 'Oʻzbek' },
|
||||
{ code: 'kk', label: 'Қазақ' },
|
||||
{ code: 'mn', label: 'Монгол' },
|
||||
{ code: 'my', label: 'မြန်မာ' },
|
||||
{ code: 'km', label: 'ភាសាខ្មែរ' },
|
||||
{ code: 'lo', label: 'ລາວ' },
|
||||
{ code: 'ne', label: 'नेपाली' },
|
||||
{ code: 'si', label: 'සිංහල' },
|
||||
]
|
||||
|
||||
export default function LanguageDropdown() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [lang, setLang] = useState('en')
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Read from localStorage or browser language
|
||||
const saved = localStorage.getItem('hanzo_lang')
|
||||
if (saved) {
|
||||
setLang(saved)
|
||||
} else {
|
||||
const browserLang = navigator.language.split('-')[0]
|
||||
const match = LANGUAGES.find(l => l.code === browserLang)
|
||||
if (match) setLang(match.code)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const handleSelect = (code: string) => {
|
||||
setLang(code)
|
||||
localStorage.setItem('hanzo_lang', code)
|
||||
setOpen(false)
|
||||
// IAM uses ?lang= param for locale
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('lang', code)
|
||||
window.location.href = url.toString()
|
||||
}
|
||||
|
||||
const current = LANGUAGES.find(l => l.code === lang) || LANGUAGES[0]
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-1.5 p-2 rounded-lg hover:bg-white/5 text-zinc-400 hover:text-white transition-colors"
|
||||
aria-label="Language"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
<span className="text-xs">{current.label}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1 w-40 bg-zinc-900 border border-zinc-700 rounded-lg shadow-xl z-50 py-1 max-h-64 overflow-y-auto">
|
||||
{LANGUAGES.map((l) => (
|
||||
<button
|
||||
key={l.code}
|
||||
onClick={() => handleSelect(l.code)}
|
||||
className={`w-full text-left px-3 py-2 text-sm hover:bg-zinc-800 transition-colors ${
|
||||
l.code === lang ? 'text-white' : 'text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{l.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,431 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import type { BrandingConfig } from '@/lib/branding'
|
||||
import { passwordLogin, startAuthorize } from '@/lib/oauth'
|
||||
import { getIamUrl, getOrg, getDefaultClientId } from '@/lib/iam'
|
||||
import { CLIENT_APP_MAP } from '@/lib/clients'
|
||||
|
||||
interface LoginFormProps {
|
||||
branding: BrandingConfig
|
||||
}
|
||||
|
||||
type AuthMethod = 'password' | 'code' | 'webauthn' | 'faceid'
|
||||
|
||||
export default function LoginForm({ branding }: LoginFormProps) {
|
||||
const searchParams = useSearchParams()
|
||||
const [authMethod, setAuthMethod] = useState<AuthMethod>('password')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [autoSignIn, setAutoSignIn] = useState(true)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const host = typeof window !== 'undefined' ? window.location.hostname : 'hanzo.id'
|
||||
const iamUrl = getIamUrl(host)
|
||||
const org = getOrg(host)
|
||||
const defaultClientId = getDefaultClientId(host)
|
||||
|
||||
// OAuth params from query string (when redirected from a client app)
|
||||
const clientId = searchParams.get('client_id') ?? searchParams.get('clientId') ?? defaultClientId
|
||||
const redirectUri = searchParams.get('redirect_uri') ?? searchParams.get('redirectUri')
|
||||
const responseType = searchParams.get('response_type') ?? searchParams.get('responseType')
|
||||
const scope = searchParams.get('scope')
|
||||
const state = searchParams.get('state')
|
||||
const codeChallenge = searchParams.get('code_challenge')
|
||||
const codeChallengeMethod = searchParams.get('code_challenge_method')
|
||||
|
||||
const isOAuthFlow = !!(redirectUri && responseType)
|
||||
|
||||
// Resolve the IAM application name from clientId.
|
||||
// IAM's /api/login expects the application NAME (e.g. "app-hanzobot"),
|
||||
// not the OAuth client_id (e.g. "hanzobot-client-id").
|
||||
const [appName, setAppName] = useState<string | null>(null)
|
||||
|
||||
// Capture referral code from URL and persist through redirects
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
const ref = searchParams.get('ref')
|
||||
if (ref) {
|
||||
sessionStorage.setItem('hanzo_ref_code', ref)
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
useEffect(() => {
|
||||
if (!clientId) return
|
||||
|
||||
const params = new URLSearchParams({
|
||||
clientId,
|
||||
type: 'code',
|
||||
responseType: responseType || 'code',
|
||||
redirectUri: redirectUri || `${window.location.origin}/callback`,
|
||||
scope: scope || 'openid profile email',
|
||||
state: state || '',
|
||||
})
|
||||
|
||||
fetch(`/api/get-app-login?${params}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
// IAM returns the app data even when status is "error"
|
||||
// (e.g. redirect URI validation fails but app info is still present)
|
||||
if (data?.data?.name) {
|
||||
setAppName(data.data.name)
|
||||
} else {
|
||||
// Fallback to static client map
|
||||
const client = CLIENT_APP_MAP[clientId]
|
||||
if (client) setAppName(client.application)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// API unreachable — use static client map
|
||||
const client = CLIENT_APP_MAP[clientId]
|
||||
if (client) setAppName(client.application)
|
||||
})
|
||||
}, [clientId, responseType, redirectUri, scope, state])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
if (!email || !password) {
|
||||
throw new Error('Please enter your email and password')
|
||||
}
|
||||
|
||||
// Resolve application name: API result > static map > raw clientId
|
||||
const resolvedApp = appName || CLIENT_APP_MAP[clientId]?.application || clientId
|
||||
|
||||
if (isOAuthFlow) {
|
||||
// OAuth flow: direct code grant via /api/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({
|
||||
clientId,
|
||||
responseType: responseType!,
|
||||
redirectUri: redirectUri!,
|
||||
...(scope ? { scope } : {}),
|
||||
...(state ? { state } : {}),
|
||||
...(codeChallenge ? { code_challenge: codeChallenge } : {}),
|
||||
...(codeChallengeMethod ? { code_challenge_method: codeChallengeMethod } : {}),
|
||||
})
|
||||
|
||||
const res = await fetch(`/api/login?${loginParams}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: responseType === 'token' ? 'token' : 'code',
|
||||
organization: org,
|
||||
username: email,
|
||||
password,
|
||||
application: resolvedApp,
|
||||
clientId,
|
||||
redirectUri: redirectUri!,
|
||||
state: state || '',
|
||||
// PKCE: pass in body too (Casdoor may read from body, not just query params)
|
||||
...(codeChallenge ? { codeChallenge } : {}),
|
||||
...(codeChallengeMethod ? { codeChallengeMethod } : {}),
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (data.status !== 'ok') throw new Error(data.msg || 'Login failed')
|
||||
|
||||
// Redirect to the client's callback with the authorization code
|
||||
const redirect = new URL(redirectUri!)
|
||||
redirect.searchParams.set('code', data.data)
|
||||
if (state) redirect.searchParams.set('state', state)
|
||||
window.location.href = redirect.toString()
|
||||
} else {
|
||||
// Direct login: get token, store, redirect to account
|
||||
// Use origin (same-domain) so the request goes through middleware proxy
|
||||
const result = await passwordLogin({
|
||||
iamUrl: window.location.origin,
|
||||
org,
|
||||
username: email,
|
||||
password,
|
||||
application: resolvedApp,
|
||||
})
|
||||
|
||||
// Store token
|
||||
localStorage.setItem('hanzo_access_token', result.token)
|
||||
|
||||
// Fetch full user profile from userinfo endpoint (access token JWT has limited claims)
|
||||
try {
|
||||
const res = await fetch('/oauth/userinfo', {
|
||||
headers: { Authorization: `Bearer ${result.token}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
const info = await res.json()
|
||||
localStorage.setItem('hanzo_user', JSON.stringify({
|
||||
sub: info.sub,
|
||||
name: info.name || info.preferred_username,
|
||||
displayName: info.displayName || info.name || info.preferred_username,
|
||||
email: info.email,
|
||||
avatar: info.avatar || info.picture || info.permanentAvatar,
|
||||
}))
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Fallback: decode JWT for basic info
|
||||
if (!localStorage.getItem('hanzo_user')) {
|
||||
try {
|
||||
const payload = JSON.parse(atob(result.token.split('.')[1]))
|
||||
localStorage.setItem('hanzo_user', JSON.stringify({
|
||||
sub: payload.sub || payload.name,
|
||||
name: payload.name,
|
||||
displayName: payload.displayName || payload.name,
|
||||
email: payload.email || email,
|
||||
avatar: payload.avatar,
|
||||
}))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Redirect to account or home
|
||||
window.location.href = '/account'
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Login failed')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSSOLogin = async () => {
|
||||
const callbackUri = `${window.location.origin}/callback`
|
||||
await startAuthorize({
|
||||
iamUrl,
|
||||
clientId,
|
||||
redirectUri: callbackUri,
|
||||
scope: scope ?? 'openid profile email',
|
||||
})
|
||||
}
|
||||
|
||||
const authTabs = [
|
||||
{ key: 'password' as const, label: 'Password', enabled: branding.auth.passwordEnabled },
|
||||
{ key: 'code' as const, label: 'Code', enabled: branding.auth.codeEnabled },
|
||||
{ key: 'webauthn' as const, label: 'WebAuthn', enabled: branding.auth.webauthnEnabled },
|
||||
{ key: 'faceid' as const, label: 'Face ID', enabled: branding.auth.faceIdEnabled },
|
||||
].filter(t => t.enabled)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Auth method tabs */}
|
||||
{authTabs.length > 1 && (
|
||||
<div className="flex gap-4 mb-6 border-b border-zinc-800">
|
||||
{authTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => { setAuthMethod(tab.key); setError(null) }}
|
||||
className={`pb-3 text-sm font-medium transition-colors ${
|
||||
authMethod === tab.key
|
||||
? 'text-white border-b-2'
|
||||
: 'text-zinc-500 hover:text-zinc-300'
|
||||
}`}
|
||||
style={{
|
||||
borderColor: authMethod === tab.key ? branding.colors.primary : 'transparent'
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Email/Username */}
|
||||
<div className="relative">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Email or username"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="input w-full pl-10 py-3 rounded-lg"
|
||||
autoComplete="email"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
{authMethod === 'password' && (
|
||||
<div className="relative">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="input w-full pl-10 pr-12 py-3 rounded-lg"
|
||||
autoComplete="current-password"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-zinc-500 hover:text-zinc-300"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
{showPassword ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" />
|
||||
) : (
|
||||
<>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Auto sign in & Forgot password */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoSignIn}
|
||||
onChange={(e) => setAutoSignIn(e.target.checked)}
|
||||
className="w-4 h-4 rounded"
|
||||
style={{ accentColor: branding.colors.primary }}
|
||||
/>
|
||||
<span className="text-zinc-400">Remember me</span>
|
||||
</label>
|
||||
<a
|
||||
href="/forgot-password"
|
||||
className="link text-sm"
|
||||
style={{ color: branding.colors.primary }}
|
||||
>
|
||||
Forgot password?
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Sign in button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="btn-primary w-full py-3 rounded-lg font-medium disabled:opacity-50 transition-opacity"
|
||||
style={{ backgroundColor: branding.colors.primary }}
|
||||
>
|
||||
{isLoading ? 'Signing in...' : 'Sign In'}
|
||||
</button>
|
||||
|
||||
{/* Sign up link */}
|
||||
<p className="text-center text-sm text-zinc-500">
|
||||
No account?{' '}
|
||||
<a
|
||||
href={`/signup${typeof window !== 'undefined' ? window.location.search : ''}`}
|
||||
className="link"
|
||||
style={{ color: branding.colors.primary }}
|
||||
>
|
||||
Sign up now
|
||||
</a>
|
||||
</p>
|
||||
|
||||
{/* Social providers / SSO */}
|
||||
{branding.auth.socialProviders.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-zinc-800" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-zinc-900 text-zinc-500">Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{/* Connect Wallet — always first */}
|
||||
{branding.auth.socialProviders.includes('metamask') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.location.href = `/oauth/authorize?provider=metamask&client_id=${clientId}&redirect_uri=${encodeURIComponent(window.location.origin + '/callback')}&scope=openid+email+profile&response_type=code`}
|
||||
className="flex items-center justify-center gap-2 w-full py-3 px-4 border rounded-lg font-medium transition-colors"
|
||||
style={{ borderColor: branding.colors.primary, color: branding.colors.primary }}
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="2" y="6" width="20" height="14" rx="2" />
|
||||
<path d="M16 14h.01" />
|
||||
<path d="M2 10h20" />
|
||||
</svg>
|
||||
Connect Wallet
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{branding.auth.socialProviders.includes('google') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.location.href = `/oauth/authorize?provider=google&client_id=${clientId}&redirect_uri=${encodeURIComponent(window.location.origin + '/callback')}&scope=openid+email+profile&response_type=code`}
|
||||
className="flex items-center justify-center gap-2 py-2 px-4 border border-zinc-700 rounded-lg hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24">
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||
</svg>
|
||||
Google
|
||||
</button>
|
||||
)}
|
||||
{branding.auth.socialProviders.includes('github') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.location.href = `/oauth/authorize?provider=github&client_id=${clientId}&redirect_uri=${encodeURIComponent(window.location.origin + '/callback')}&scope=openid+email+profile&response_type=code`}
|
||||
className="flex items-center justify-center gap-2 py-2 px-4 border border-zinc-700 rounded-lg hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
GitHub
|
||||
</button>
|
||||
)}
|
||||
{branding.auth.socialProviders.includes('apple') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.location.href = `/oauth/authorize?provider=apple&client_id=${clientId}&redirect_uri=${encodeURIComponent(window.location.origin + '/callback')}&scope=openid+email+profile&response_type=code`}
|
||||
className="flex items-center justify-center gap-2 py-2 px-4 border border-zinc-700 rounded-lg hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M17.543 12.72c-.026-2.757 2.248-4.08 2.352-4.143-1.282-1.876-3.278-2.134-3.988-2.165-1.698-.173-3.315 1.001-4.179 1.001-.864 0-2.195-.976-3.608-.95-1.858.028-3.57 1.078-4.528 2.74-1.931 3.347-.493 8.293 1.388 11.008.918 1.328 2.012 2.821 3.445 2.77 1.38-.057 1.902-.894 3.57-.894 1.668 0 2.137.894 3.604.867 1.489-.027 2.434-1.355 3.343-2.686 1.054-1.547 1.489-3.044 1.515-3.122-.034-.015-2.908-1.117-2.914-4.426zM14.829 4.793c.76-.92 1.272-2.199 1.131-3.469-1.093.045-2.415.728-3.2 1.648-.706.818-1.323 2.119-1.157 3.362 1.214.093 2.454-.623 3.226-1.541z"/>
|
||||
</svg>
|
||||
Apple
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* Footer links */}
|
||||
<div className="mt-8 pt-6 border-t border-zinc-800 flex justify-center gap-4 text-xs text-zinc-500">
|
||||
{branding.links.terms && (
|
||||
<a href={branding.links.terms} className="hover:text-zinc-300">Terms</a>
|
||||
)}
|
||||
{branding.links.privacy && (
|
||||
<a href={branding.links.privacy} className="hover:text-zinc-300">Privacy</a>
|
||||
)}
|
||||
{branding.links.support && (
|
||||
<a href={branding.links.support} className="hover:text-zinc-300">Support</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { BrandingConfig } from '@/lib/branding'
|
||||
|
||||
interface MarketingPanelProps {
|
||||
branding: BrandingConfig
|
||||
}
|
||||
|
||||
export default function MarketingPanel({ branding }: MarketingPanelProps) {
|
||||
const [currentQuote, setCurrentQuote] = useState(0)
|
||||
const quotes = branding.content.quotes || []
|
||||
|
||||
// Auto-rotate quotes
|
||||
useEffect(() => {
|
||||
if (quotes.length <= 1) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setCurrentQuote((prev) => (prev + 1) % quotes.length)
|
||||
}, 5000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [quotes.length])
|
||||
|
||||
return (
|
||||
<div className="max-w-md space-y-8">
|
||||
{/* Badge */}
|
||||
{branding.content.tagline && (
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full border border-zinc-700 text-sm">
|
||||
<span className="text-yellow-400">✦</span>
|
||||
<span className="text-zinc-300">{branding.content.tagline}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
{branding.content.title && (
|
||||
<h2 className="text-4xl font-bold text-white">
|
||||
{branding.content.title}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
{/* Subtitle */}
|
||||
{branding.content.subtitle && (
|
||||
<p className="text-zinc-400 text-lg">
|
||||
{branding.content.subtitle}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Interactive prompt (optional feature showcase) */}
|
||||
{branding.content.features && branding.content.features.length > 0 && (
|
||||
<div className="bg-zinc-900/50 rounded-xl p-4 border border-zinc-800">
|
||||
<div className="text-xs text-zinc-500 mb-2 flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: branding.colors.primary }} />
|
||||
TRY SOMETHING LIKE
|
||||
</div>
|
||||
<p className="text-white">
|
||||
{branding.content.features[0].description}
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<button className="p-2 rounded-lg bg-zinc-800 text-zinc-400 hover:text-white">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
|
||||
</svg>
|
||||
</button>
|
||||
<button className="p-2 rounded-lg bg-zinc-800 text-zinc-400 hover:text-white">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg font-medium"
|
||||
style={{
|
||||
backgroundColor: branding.colors.primary,
|
||||
color: branding.colors.primaryText
|
||||
}}
|
||||
>
|
||||
<span>✦</span>
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Testimonials/Quotes */}
|
||||
{quotes.length > 0 && (
|
||||
<div className="quote-card">
|
||||
<blockquote className="text-white mb-4">
|
||||
<span className="text-2xl text-zinc-600">"</span>
|
||||
{quotes[currentQuote].text}
|
||||
<span className="text-2xl text-zinc-600">"</span>
|
||||
</blockquote>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{quotes[currentQuote].avatar ? (
|
||||
<img
|
||||
src={quotes[currentQuote].avatar}
|
||||
alt={quotes[currentQuote].author}
|
||||
className="w-10 h-10 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center text-white font-medium"
|
||||
style={{ backgroundColor: branding.colors.primary }}
|
||||
>
|
||||
{quotes[currentQuote].author.split(' ').map(n => n[0]).join('').slice(0, 2)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="font-medium text-white">
|
||||
{quotes[currentQuote].author}
|
||||
</div>
|
||||
{(quotes[currentQuote].role || quotes[currentQuote].company) && (
|
||||
<div className="text-sm text-zinc-500">
|
||||
{[quotes[currentQuote].role, quotes[currentQuote].company].filter(Boolean).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quote indicators */}
|
||||
{quotes.length > 1 && (
|
||||
<div className="flex justify-center gap-2 mt-4">
|
||||
{quotes.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setCurrentQuote(i)}
|
||||
className={`w-2 h-2 rounded-full transition-colors ${
|
||||
i === currentQuote ? 'bg-white' : 'bg-zinc-600'
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: i === currentQuote ? branding.colors.primary : undefined
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import type { BrandingConfig } from '@/lib/branding'
|
||||
import { getIamUrl, getOrg, getDefaultClientId } from '@/lib/iam'
|
||||
|
||||
interface SignUpFormProps {
|
||||
branding: BrandingConfig
|
||||
}
|
||||
|
||||
export default function SignUpForm({ branding }: SignUpFormProps) {
|
||||
const searchParams = useSearchParams()
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const host = typeof window !== 'undefined' ? window.location.hostname : 'hanzo.id'
|
||||
const iamUrl = getIamUrl(host)
|
||||
const org = getOrg(host)
|
||||
const clientId = searchParams.get('client_id') ?? searchParams.get('clientId') ?? getDefaultClientId(host)
|
||||
|
||||
// Resolve the IAM application name from clientId.
|
||||
// IAM's /api/signup expects the application NAME (e.g. "app-hanzobot"),
|
||||
// not the OAuth client_id (e.g. "hanzobot-client-id").
|
||||
const [appName, setAppName] = useState<string | null>(null)
|
||||
|
||||
// Capture referral code from URL and persist through redirects
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
const ref = searchParams.get('ref')
|
||||
if (ref) {
|
||||
sessionStorage.setItem('hanzo_ref_code', ref)
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
useEffect(() => {
|
||||
if (!clientId) return
|
||||
|
||||
const params = new URLSearchParams({
|
||||
clientId,
|
||||
type: 'code',
|
||||
responseType: 'code',
|
||||
redirectUri: `${window.location.origin}/callback`,
|
||||
scope: 'openid profile email',
|
||||
state: '',
|
||||
})
|
||||
|
||||
fetch(`/api/get-app-login?${params}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
// IAM returns the app data even when status is "error"
|
||||
// (e.g. redirect URI validation fails but app info is still present)
|
||||
if (data?.data?.name) {
|
||||
setAppName(data.data.name)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [clientId])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
if (!email || !password) {
|
||||
throw new Error('Please fill in all required fields')
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
throw new Error('Password must be at least 8 characters')
|
||||
}
|
||||
|
||||
const username = email.split('@')[0]
|
||||
const res = await fetch(`${iamUrl}/api/signup`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
organization: org,
|
||||
application: appName || clientId,
|
||||
username,
|
||||
name: username,
|
||||
displayName: name || username,
|
||||
email,
|
||||
password,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (data.status !== 'ok') {
|
||||
throw new Error(data.msg || 'Sign up failed')
|
||||
}
|
||||
|
||||
// Redirect to login with success message
|
||||
const loginUrl = new URL('/login', window.location.origin)
|
||||
// Preserve OAuth params and referral code
|
||||
const params = ['client_id', 'clientId', 'redirect_uri', 'redirectUri', 'response_type', 'responseType', 'scope', 'state', 'ref']
|
||||
for (const p of params) {
|
||||
const v = searchParams.get(p)
|
||||
if (v) loginUrl.searchParams.set(p, v)
|
||||
}
|
||||
loginUrl.searchParams.set('registered', '1')
|
||||
window.location.href = loginUrl.toString()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Sign up failed')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Create account</h2>
|
||||
<p className="text-zinc-400 text-sm mb-6">
|
||||
Sign up for {branding.orgName}
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Name */}
|
||||
<div className="relative">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Full name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="input w-full pl-10 py-3 rounded-lg"
|
||||
autoComplete="name"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div className="relative">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email address"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="input w-full pl-10 py-3 rounded-lg"
|
||||
autoComplete="email"
|
||||
disabled={isLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div className="relative">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="Password (min 8 characters)"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="input w-full pl-10 pr-12 py-3 rounded-lg"
|
||||
autoComplete="new-password"
|
||||
disabled={isLoading}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-zinc-500 hover:text-zinc-300"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
{showPassword ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" />
|
||||
) : (
|
||||
<>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="btn-primary w-full py-3 rounded-lg font-medium disabled:opacity-50 transition-opacity"
|
||||
style={{ backgroundColor: branding.colors.primary }}
|
||||
>
|
||||
{isLoading ? 'Creating account...' : 'Create Account'}
|
||||
</button>
|
||||
|
||||
<p className="text-center text-sm text-zinc-500">
|
||||
Already have an account?{' '}
|
||||
<a
|
||||
href={`/login${typeof window !== 'undefined' ? window.location.search : ''}`}
|
||||
className="link"
|
||||
style={{ color: branding.colors.primary }}
|
||||
>
|
||||
Sign in
|
||||
</a>
|
||||
</p>
|
||||
|
||||
{/* Terms */}
|
||||
{(branding.links.terms || branding.links.privacy) && (
|
||||
<p className="text-center text-xs text-zinc-500 mt-4">
|
||||
By creating an account, you agree to our{' '}
|
||||
{branding.links.terms && (
|
||||
<a href={branding.links.terms} className="hover:text-zinc-300">Terms</a>
|
||||
)}
|
||||
{branding.links.terms && branding.links.privacy && ' and '}
|
||||
{branding.links.privacy && (
|
||||
<a href={branding.links.privacy} className="hover:text-zinc-300">Privacy Policy</a>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,736 +0,0 @@
|
||||
/**
|
||||
* Branding configuration fetched from IAM backend based on domain
|
||||
*
|
||||
* Each organization can customize:
|
||||
* - Logo (URL or base64)
|
||||
* - Colors (primary, secondary, background, text)
|
||||
* - Login page content (quotes, testimonials)
|
||||
* - Links (terms, privacy, support)
|
||||
* - Features (which auth methods to show)
|
||||
*/
|
||||
|
||||
export interface BrandingConfig {
|
||||
// Organization info
|
||||
orgId: string
|
||||
orgName: string
|
||||
domain: string
|
||||
|
||||
// Visual branding
|
||||
logo: string
|
||||
logoAlt?: string
|
||||
favicon?: string
|
||||
|
||||
// Color scheme
|
||||
colors: {
|
||||
primary: string // Button color, accents
|
||||
primaryText: string // Text on primary color
|
||||
background: string // Page background
|
||||
surface: string // Card/form background
|
||||
text: string // Primary text
|
||||
textMuted: string // Secondary text
|
||||
border: string // Borders
|
||||
error: string // Error states
|
||||
}
|
||||
|
||||
// Login page content
|
||||
content: {
|
||||
title?: string // Main heading
|
||||
subtitle?: string // Subheading
|
||||
tagline?: string // Marketing tagline
|
||||
quotes?: Quote[] // Testimonials/quotes
|
||||
features?: Feature[] // Feature highlights
|
||||
}
|
||||
|
||||
// Links
|
||||
links: {
|
||||
terms?: string
|
||||
privacy?: string
|
||||
support?: string
|
||||
docs?: string
|
||||
home?: string
|
||||
}
|
||||
|
||||
// Auth features
|
||||
auth: {
|
||||
passwordEnabled: boolean
|
||||
codeEnabled: boolean // Email/SMS code
|
||||
webauthnEnabled: boolean // Passkeys
|
||||
faceIdEnabled: boolean
|
||||
socialProviders: string[] // google, github, etc
|
||||
}
|
||||
}
|
||||
|
||||
export interface Quote {
|
||||
text: string
|
||||
author: string
|
||||
role?: string
|
||||
company?: string
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
export interface Feature {
|
||||
title: string
|
||||
description: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
// Default Hanzo branding (fallback)
|
||||
export const defaultBranding: BrandingConfig = {
|
||||
orgId: 'hanzo',
|
||||
orgName: 'Hanzo',
|
||||
domain: 'hanzo.id',
|
||||
|
||||
logo: '/logos/hanzo.svg',
|
||||
|
||||
colors: {
|
||||
primary: '#e4e4e7', // Zinc-200 (monochrome white)
|
||||
primaryText: '#09090b', // Zinc-950 (dark text on light buttons)
|
||||
background: '#000000', // Pure black
|
||||
surface: '#0a0a0a', // Near black
|
||||
text: '#ffffff',
|
||||
textMuted: '#a1a1aa',
|
||||
border: '#27272a',
|
||||
error: '#dc2626',
|
||||
},
|
||||
|
||||
content: {
|
||||
title: 'Start building in seconds',
|
||||
subtitle: 'Describe your idea and watch AI bring it to life instantly',
|
||||
tagline: 'AI-powered development',
|
||||
quotes: [
|
||||
{
|
||||
text: 'Hanzo is amazing! It\'s revolutionizing how we build and deploy applications.',
|
||||
author: 'Developer',
|
||||
role: 'Software Engineer',
|
||||
}
|
||||
],
|
||||
},
|
||||
|
||||
links: {
|
||||
terms: 'https://hanzo.ai/terms',
|
||||
privacy: 'https://hanzo.ai/privacy',
|
||||
support: 'https://hanzo.ai/support',
|
||||
docs: 'https://docs.hanzo.ai',
|
||||
home: 'https://hanzo.ai',
|
||||
},
|
||||
|
||||
auth: {
|
||||
passwordEnabled: true,
|
||||
codeEnabled: true,
|
||||
webauthnEnabled: true,
|
||||
faceIdEnabled: true,
|
||||
socialProviders: ['metamask', 'google', 'github'],
|
||||
},
|
||||
}
|
||||
|
||||
// Fetch branding from IAM backend based on domain
|
||||
export async function getBranding(domain: string): Promise<BrandingConfig> {
|
||||
const iamUrl = process.env.HANZO_IAM_URL || 'https://api.hanzo.id'
|
||||
|
||||
try {
|
||||
const res = await fetch(`${iamUrl}/api/branding?domain=${encodeURIComponent(domain)}`, {
|
||||
next: { revalidate: 300 }, // Cache for 5 minutes
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
console.warn(`Failed to fetch branding for ${domain}, using defaults`)
|
||||
return defaultBranding
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
return { ...defaultBranding, ...data }
|
||||
} catch (error) {
|
||||
console.error(`Error fetching branding for ${domain}:`, error)
|
||||
return defaultBranding
|
||||
}
|
||||
}
|
||||
|
||||
// Static branding configs for known domains (can be overridden by IAM)
|
||||
// Supports both {org}.id format (e.g. lux.id) and id.{domain} format (e.g. id.ad.nexus)
|
||||
export const staticBranding: Record<string, Partial<BrandingConfig>> = {
|
||||
'hanzo.id': {
|
||||
orgId: 'hanzo',
|
||||
orgName: 'Hanzo',
|
||||
logo: '/logos/hanzo.svg',
|
||||
colors: {
|
||||
primary: '#e4e4e7', // Zinc-200 (monochrome white)
|
||||
primaryText: '#09090b', // Zinc-950 (dark text on light buttons)
|
||||
background: '#000000',
|
||||
surface: '#0a0a0a',
|
||||
text: '#ffffff',
|
||||
textMuted: '#a1a1aa',
|
||||
border: '#27272a',
|
||||
error: '#dc2626',
|
||||
},
|
||||
},
|
||||
'id.hanzo.ai': {
|
||||
orgId: 'hanzo',
|
||||
orgName: 'Hanzo',
|
||||
logo: '/logos/hanzo.svg',
|
||||
colors: {
|
||||
primary: '#e4e4e7', // Zinc-200 (monochrome white)
|
||||
primaryText: '#09090b', // Zinc-950 (dark text on light buttons)
|
||||
background: '#000000',
|
||||
surface: '#0a0a0a',
|
||||
text: '#ffffff',
|
||||
textMuted: '#a1a1aa',
|
||||
border: '#27272a',
|
||||
error: '#dc2626',
|
||||
},
|
||||
},
|
||||
'pars.id': {
|
||||
orgId: 'pars',
|
||||
orgName: 'Pars Network',
|
||||
logo: '/logos/pars.svg',
|
||||
colors: {
|
||||
primary: '#3b82f6', // Blue
|
||||
primaryText: '#ffffff',
|
||||
background: '#000000',
|
||||
surface: '#0a0a0a',
|
||||
text: '#ffffff',
|
||||
textMuted: '#a1a1aa',
|
||||
border: '#27272a',
|
||||
error: '#dc2626',
|
||||
},
|
||||
content: {
|
||||
title: 'Welcome to Pars',
|
||||
subtitle: 'The decentralized network for the next generation',
|
||||
},
|
||||
links: {
|
||||
terms: 'https://pars.network/terms',
|
||||
privacy: 'https://pars.network/privacy',
|
||||
support: 'https://pars.network/support',
|
||||
docs: 'https://pars.network/docs',
|
||||
home: 'https://pars.network',
|
||||
},
|
||||
},
|
||||
'id.pars.network': {
|
||||
orgId: 'pars',
|
||||
orgName: 'Pars Network',
|
||||
logo: '/logos/pars.svg',
|
||||
colors: {
|
||||
primary: '#3b82f6', // Blue
|
||||
primaryText: '#ffffff',
|
||||
background: '#000000',
|
||||
surface: '#0a0a0a',
|
||||
text: '#ffffff',
|
||||
textMuted: '#a1a1aa',
|
||||
border: '#27272a',
|
||||
error: '#dc2626',
|
||||
},
|
||||
content: {
|
||||
title: 'Welcome to Pars',
|
||||
subtitle: 'The decentralized network for the next generation',
|
||||
},
|
||||
links: {
|
||||
terms: 'https://pars.network/terms',
|
||||
privacy: 'https://pars.network/privacy',
|
||||
support: 'https://pars.network/support',
|
||||
docs: 'https://pars.network/docs',
|
||||
home: 'https://pars.network',
|
||||
},
|
||||
},
|
||||
'lux.id': {
|
||||
orgId: 'lux',
|
||||
orgName: 'Lux Network',
|
||||
logo: '/logos/lux.svg',
|
||||
colors: {
|
||||
primary: '#e4e4e7', // Zinc-200 (clean white)
|
||||
primaryText: '#09090b', // Zinc-950 (dark text on light buttons)
|
||||
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',
|
||||
logo: '/logos/lux.svg',
|
||||
colors: {
|
||||
primary: '#e4e4e7', // Zinc-200 (clean white)
|
||||
primaryText: '#09090b', // Zinc-950 (dark text on light buttons)
|
||||
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-dev.network': {
|
||||
orgId: 'lux',
|
||||
orgName: 'Lux Network (Devnet)',
|
||||
logo: '/logos/lux.svg',
|
||||
colors: {
|
||||
primary: '#e4e4e7',
|
||||
primaryText: '#09090b',
|
||||
background: '#000000',
|
||||
surface: '#0a0a0a',
|
||||
text: '#ffffff',
|
||||
textMuted: '#a1a1aa',
|
||||
border: '#27272a',
|
||||
error: '#dc2626',
|
||||
},
|
||||
content: {
|
||||
title: 'Lux Devnet',
|
||||
subtitle: 'Development network — unstable, reset frequently',
|
||||
tagline: 'Lux devnet infrastructure',
|
||||
quotes: [
|
||||
{
|
||||
text: "Break things fast. Devnet resets nightly.",
|
||||
author: 'Engineer',
|
||||
role: 'Infra',
|
||||
}
|
||||
],
|
||||
},
|
||||
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-dev.network',
|
||||
},
|
||||
},
|
||||
'id.lux-test.network': {
|
||||
orgId: 'lux',
|
||||
orgName: 'Lux Network (Testnet)',
|
||||
logo: '/logos/lux.svg',
|
||||
colors: {
|
||||
primary: '#e4e4e7',
|
||||
primaryText: '#09090b',
|
||||
background: '#000000',
|
||||
surface: '#0a0a0a',
|
||||
text: '#ffffff',
|
||||
textMuted: '#a1a1aa',
|
||||
border: '#27272a',
|
||||
error: '#dc2626',
|
||||
},
|
||||
content: {
|
||||
title: 'Lux Testnet',
|
||||
subtitle: 'Test the full stack before mainnet deploy',
|
||||
tagline: 'Lux testnet infrastructure',
|
||||
quotes: [
|
||||
{
|
||||
text: "Validator stability tested here for 48h before mainnet promotion.",
|
||||
author: 'Validator',
|
||||
role: '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-test.network',
|
||||
},
|
||||
},
|
||||
'id.dev.hanzo.ai': {
|
||||
orgId: 'hanzo',
|
||||
orgName: 'Hanzo (Dev)',
|
||||
logo: '/logos/hanzo.svg',
|
||||
colors: {
|
||||
primary: '#e4e4e7',
|
||||
primaryText: '#09090b',
|
||||
background: '#000000',
|
||||
surface: '#0a0a0a',
|
||||
text: '#ffffff',
|
||||
textMuted: '#a1a1aa',
|
||||
border: '#27272a',
|
||||
error: '#dc2626',
|
||||
},
|
||||
content: {
|
||||
title: 'Hanzo Dev',
|
||||
subtitle: 'Development environment',
|
||||
tagline: 'AI-powered development',
|
||||
},
|
||||
auth: {
|
||||
passwordEnabled: true,
|
||||
codeEnabled: true,
|
||||
webauthnEnabled: true,
|
||||
faceIdEnabled: true,
|
||||
socialProviders: ['metamask', 'google', 'github'],
|
||||
},
|
||||
},
|
||||
'id.test.hanzo.ai': {
|
||||
orgId: 'hanzo',
|
||||
orgName: 'Hanzo (Test)',
|
||||
logo: '/logos/hanzo.svg',
|
||||
colors: {
|
||||
primary: '#e4e4e7',
|
||||
primaryText: '#09090b',
|
||||
background: '#000000',
|
||||
surface: '#0a0a0a',
|
||||
text: '#ffffff',
|
||||
textMuted: '#a1a1aa',
|
||||
border: '#27272a',
|
||||
error: '#dc2626',
|
||||
},
|
||||
content: {
|
||||
title: 'Hanzo Test',
|
||||
subtitle: 'Test environment',
|
||||
tagline: 'AI-powered development',
|
||||
},
|
||||
auth: {
|
||||
passwordEnabled: true,
|
||||
codeEnabled: true,
|
||||
webauthnEnabled: true,
|
||||
faceIdEnabled: true,
|
||||
socialProviders: ['metamask', 'google', 'github'],
|
||||
},
|
||||
}, 'id.zoo.network': {
|
||||
orgId: 'zoo',
|
||||
orgName: 'Zoo Labs',
|
||||
logo: '/logos/zoo.svg',
|
||||
colors: {
|
||||
primary: '#22c55e', // Green
|
||||
primaryText: '#ffffff',
|
||||
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.hanzo.network': {
|
||||
orgId: 'hanzo',
|
||||
orgName: 'Hanzo (Network)',
|
||||
logo: '/logos/hanzo.svg',
|
||||
colors: {
|
||||
primary: '#e4e4e7',
|
||||
primaryText: '#09090b',
|
||||
background: '#000000',
|
||||
surface: '#0a0a0a',
|
||||
text: '#ffffff',
|
||||
textMuted: '#a1a1aa',
|
||||
border: '#27272a',
|
||||
error: '#dc2626',
|
||||
},
|
||||
content: {
|
||||
title: 'Welcome to Hanzo Network',
|
||||
subtitle: 'Hanzo network identity',
|
||||
tagline: 'AI-powered infrastructure',
|
||||
},
|
||||
auth: {
|
||||
passwordEnabled: true,
|
||||
codeEnabled: true,
|
||||
webauthnEnabled: true,
|
||||
faceIdEnabled: true,
|
||||
socialProviders: ['metamask', 'google', 'github'],
|
||||
},
|
||||
links: {
|
||||
home: 'https://hanzo.network',
|
||||
},
|
||||
},
|
||||
'id.hanzo-dev.network': {
|
||||
orgId: 'hanzo',
|
||||
orgName: 'Hanzo (Devnet)',
|
||||
logo: '/logos/hanzo.svg',
|
||||
colors: {
|
||||
primary: '#e4e4e7',
|
||||
primaryText: '#09090b',
|
||||
background: '#000000',
|
||||
surface: '#0a0a0a',
|
||||
text: '#ffffff',
|
||||
textMuted: '#a1a1aa',
|
||||
border: '#27272a',
|
||||
error: '#dc2626',
|
||||
},
|
||||
content: {
|
||||
title: 'Hanzo Devnet',
|
||||
subtitle: 'Hanzo development network — resets nightly',
|
||||
tagline: 'Development environment',
|
||||
},
|
||||
auth: {
|
||||
passwordEnabled: true,
|
||||
codeEnabled: true,
|
||||
webauthnEnabled: true,
|
||||
faceIdEnabled: true,
|
||||
socialProviders: ['metamask', 'google', 'github'],
|
||||
},
|
||||
links: {
|
||||
home: 'https://hanzo-dev.network',
|
||||
},
|
||||
},
|
||||
'id.hanzo-test.network': {
|
||||
orgId: 'hanzo',
|
||||
orgName: 'Hanzo (Testnet)',
|
||||
logo: '/logos/hanzo.svg',
|
||||
colors: {
|
||||
primary: '#e4e4e7',
|
||||
primaryText: '#09090b',
|
||||
background: '#000000',
|
||||
surface: '#0a0a0a',
|
||||
text: '#ffffff',
|
||||
textMuted: '#a1a1aa',
|
||||
border: '#27272a',
|
||||
error: '#dc2626',
|
||||
},
|
||||
content: {
|
||||
title: 'Hanzo Testnet',
|
||||
subtitle: 'Hanzo test network — staging before mainnet',
|
||||
tagline: 'Test environment',
|
||||
},
|
||||
auth: {
|
||||
passwordEnabled: true,
|
||||
codeEnabled: true,
|
||||
webauthnEnabled: true,
|
||||
faceIdEnabled: true,
|
||||
socialProviders: ['metamask', 'google', 'github'],
|
||||
},
|
||||
links: {
|
||||
home: 'https://hanzo-test.network',
|
||||
},
|
||||
},
|
||||
'id.zoo-dev.network': {
|
||||
orgId: 'zoo',
|
||||
orgName: 'Zoo Labs (Devnet)',
|
||||
logo: '/logos/zoo.svg',
|
||||
colors: {
|
||||
primary: '#e4e4e7',
|
||||
primaryText: '#09090b',
|
||||
background: '#000000',
|
||||
surface: '#0a0a0a',
|
||||
text: '#ffffff',
|
||||
textMuted: '#a1a1aa',
|
||||
border: '#27272a',
|
||||
error: '#dc2626',
|
||||
},
|
||||
content: {
|
||||
title: 'Zoo Devnet',
|
||||
subtitle: 'Zoo development network',
|
||||
tagline: 'Zoo devnet',
|
||||
},
|
||||
auth: {
|
||||
passwordEnabled: true,
|
||||
codeEnabled: true,
|
||||
webauthnEnabled: true,
|
||||
faceIdEnabled: true,
|
||||
socialProviders: ['metamask', 'google', 'github'],
|
||||
},
|
||||
links: {
|
||||
home: 'https://zoo-dev.network',
|
||||
},
|
||||
},
|
||||
'id.zoo-test.network': {
|
||||
orgId: 'zoo',
|
||||
orgName: 'Zoo Labs (Testnet)',
|
||||
logo: '/logos/zoo.svg',
|
||||
colors: {
|
||||
primary: '#e4e4e7',
|
||||
primaryText: '#09090b',
|
||||
background: '#000000',
|
||||
surface: '#0a0a0a',
|
||||
text: '#ffffff',
|
||||
textMuted: '#a1a1aa',
|
||||
border: '#27272a',
|
||||
error: '#dc2626',
|
||||
},
|
||||
content: {
|
||||
title: 'Zoo Testnet',
|
||||
subtitle: 'Zoo test network',
|
||||
tagline: 'Zoo testnet',
|
||||
},
|
||||
auth: {
|
||||
passwordEnabled: true,
|
||||
codeEnabled: true,
|
||||
webauthnEnabled: true,
|
||||
faceIdEnabled: true,
|
||||
socialProviders: ['metamask', 'google', 'github'],
|
||||
},
|
||||
links: {
|
||||
home: 'https://zoo-test.network',
|
||||
},
|
||||
},
|
||||
'zen.id': {
|
||||
orgId: 'zen',
|
||||
orgName: 'Zen LM',
|
||||
logo: '/logos/zen.svg',
|
||||
colors: {
|
||||
primary: '#a855f7', // Purple (Zen violet)
|
||||
primaryText: '#ffffff',
|
||||
background: '#000000',
|
||||
surface: '#0a0a0a',
|
||||
text: '#ffffff',
|
||||
textMuted: '#a1a1aa',
|
||||
border: '#27272a',
|
||||
error: '#dc2626',
|
||||
},
|
||||
content: {
|
||||
title: 'Welcome to Zen',
|
||||
subtitle: 'Frontier AI models for everyone',
|
||||
},
|
||||
links: {
|
||||
terms: 'https://zenlm.org/terms',
|
||||
privacy: 'https://zenlm.org/privacy',
|
||||
support: 'https://zenlm.org/support',
|
||||
docs: 'https://zenlm.org/docs',
|
||||
home: 'https://zenlm.org',
|
||||
},
|
||||
},
|
||||
'id.ad.nexus': {
|
||||
orgId: 'adnexus',
|
||||
orgName: 'Ad Nexus',
|
||||
logo: '/logos/adnexus.svg',
|
||||
logoAlt: 'Ad Nexus',
|
||||
colors: {
|
||||
primary: '#8b5cf6', // Purple
|
||||
primaryText: '#ffffff',
|
||||
background: '#000000',
|
||||
surface: '#0a0a0a',
|
||||
text: '#ffffff',
|
||||
textMuted: '#a1a1aa',
|
||||
border: '#27272a',
|
||||
error: '#dc2626',
|
||||
},
|
||||
content: {
|
||||
title: 'Welcome to Ad Nexus',
|
||||
subtitle: 'Programmatic advertising platform',
|
||||
},
|
||||
links: {
|
||||
terms: 'https://ad.nexus/terms',
|
||||
privacy: 'https://ad.nexus/privacy',
|
||||
support: 'https://ad.nexus/support',
|
||||
home: 'https://ad.nexus',
|
||||
},
|
||||
},
|
||||
'ad.nexus': {
|
||||
orgId: 'adnexus',
|
||||
orgName: 'Ad Nexus',
|
||||
logo: '/logos/adnexus.svg',
|
||||
logoAlt: 'Ad Nexus',
|
||||
colors: {
|
||||
primary: '#8b5cf6', // Purple
|
||||
primaryText: '#ffffff',
|
||||
background: '#000000',
|
||||
surface: '#0a0a0a',
|
||||
text: '#ffffff',
|
||||
textMuted: '#a1a1aa',
|
||||
border: '#27272a',
|
||||
error: '#dc2626',
|
||||
},
|
||||
content: {
|
||||
title: 'Welcome to Ad Nexus',
|
||||
subtitle: 'Programmatic advertising platform',
|
||||
},
|
||||
links: {
|
||||
terms: 'https://ad.nexus/terms',
|
||||
privacy: 'https://ad.nexus/privacy',
|
||||
support: 'https://ad.nexus/support',
|
||||
home: 'https://ad.nexus',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Resolve domain to branding key
|
||||
// Handles: exact match, id.{domain} → {domain}, {sub}.{domain} patterns
|
||||
export function resolveBrandingDomain(host: string): string {
|
||||
const domain = host.split(':')[0]
|
||||
|
||||
// Exact match first
|
||||
if (staticBranding[domain]) return domain
|
||||
|
||||
// Try stripping 'id.' prefix: id.ad.nexus → ad.nexus
|
||||
if (domain.startsWith('id.')) {
|
||||
const stripped = domain.slice(3)
|
||||
if (staticBranding[stripped]) return stripped
|
||||
}
|
||||
|
||||
return domain
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* Client ID → application/organization map.
|
||||
*
|
||||
* Used for resolving the correct IAM application from a client_id,
|
||||
* e.g. during social login callbacks where we need to know which
|
||||
* app and org the login belongs to.
|
||||
*/
|
||||
|
||||
export interface ClientInfo {
|
||||
application: string
|
||||
organization: string
|
||||
}
|
||||
|
||||
export const CLIENT_APP_MAP: Record<string, ClientInfo> = {
|
||||
// Hanzo org
|
||||
'hanzo-platform-client-id': { application: 'app-platform', organization: 'hanzo' },
|
||||
'hanzo-app-client-id': { application: 'hanzo-id', organization: 'hanzo' },
|
||||
'hanzo-id': { application: 'hanzo-id', organization: 'hanzo' },
|
||||
'hanzo-console-client-id': { application: 'app-console', organization: 'hanzo' },
|
||||
'hanzo-cloud-client-id': { application: 'app-cloud', organization: 'hanzo' },
|
||||
'kms-client': { application: 'app-kms', organization: 'hanzo' },
|
||||
'hanzo-kms-client-id': { application: 'app-kms', organization: 'hanzo' },
|
||||
'hanzo-commerce-client-id': { application: 'app-commerce', organization: 'hanzo' },
|
||||
'hanzo-team-client-id': { application: 'app-team', organization: 'hanzo' },
|
||||
'hanzobot-client-id': { application: 'app-hanzobot', organization: 'hanzo' },
|
||||
'chat-app': { application: 'app-chat', organization: 'hanzo' },
|
||||
'hanzo-chat-client-id': { application: 'app-hanzo-chat', organization: 'hanzo' },
|
||||
'hanzo-web3': { application: 'app-hanzo-web3', organization: 'hanzo' },
|
||||
'app-analytics': { application: 'app-analytics', organization: 'hanzo' },
|
||||
'app-insights': { application: 'app-insights', organization: 'hanzo' },
|
||||
'bootnode-web': { application: 'app-bootnode', organization: 'hanzo' },
|
||||
'zt-console': { application: 'app-zt-console', organization: 'hanzo' },
|
||||
'hanzo-storage-client-id': { application: 'app-storage', organization: 'hanzo' },
|
||||
'hanzo-auto-client-id': { application: 'app-auto', organization: 'hanzo' },
|
||||
'hanzo-flow-client-id': { application: 'app-flow', organization: 'hanzo' },
|
||||
// Adnexus org
|
||||
'adnexus-app-client-id': { application: 'app-adnexus', organization: 'adnexus' },
|
||||
// Lux org
|
||||
'lux-app-client-id': { application: 'app-lux', organization: 'lux' },
|
||||
'lux-chat-client-id': { application: 'app-lux-chat', organization: 'lux' },
|
||||
'lux-kms-client': { application: 'app-lux-kms', organization: 'lux' },
|
||||
'lux-web3': { application: 'app-lux-web3', organization: 'lux' },
|
||||
'lux-mpc': { application: 'app-lux-mpc', organization: 'lux' },
|
||||
// Zoo org
|
||||
'zoo-app-client-id': { application: 'app-zoo', organization: 'zoo' },
|
||||
'zoo-web3': { application: 'app-zoo-web3', organization: 'zoo' },
|
||||
'zoo-mpc': { application: 'app-zoo-mpc', organization: 'zoo' },
|
||||
// Pars org
|
||||
'pars-app-client-id': { application: 'app-pars', organization: 'pars' },
|
||||
'pars-mpc': { application: 'app-pars-mpc', organization: 'pars' },
|
||||
// Zen org
|
||||
'zen-app-client-id': { application: 'app-zen', organization: 'zen' },
|
||||
}
|
||||
|
||||
export function resolveClient(clientId: string): ClientInfo | undefined {
|
||||
return CLIENT_APP_MAP[clientId]
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* IAM backend URL resolution.
|
||||
*
|
||||
* Maps the login portal domain to the correct IAM backend.
|
||||
* Configurable via env vars for self-hosted deployments.
|
||||
*/
|
||||
|
||||
// Default domain → IAM URL mapping
|
||||
const IAM_URLS: Record<string, string> = {
|
||||
'hanzo.id': 'https://iam.hanzo.ai',
|
||||
'id.hanzo.ai': 'https://iam.hanzo.ai',
|
||||
'lux.id': 'https://iam.lux.network',
|
||||
'id.lux.network': 'https://iam.lux.network',
|
||||
'zoo.id': 'https://iam.zoo.network',
|
||||
'id.zoo.network': 'https://iam.zoo.network',
|
||||
'pars.id': 'https://iam.pars.network',
|
||||
'id.pars.network': 'https://iam.pars.network',
|
||||
'zen.id': 'https://iam.hanzo.ai',
|
||||
'id.ad.nexus': 'https://iam.hanzo.ai',
|
||||
}
|
||||
|
||||
// Default domain → org mapping
|
||||
const ORG_MAP: Record<string, string> = {
|
||||
'hanzo.id': 'hanzo',
|
||||
'id.hanzo.ai': 'hanzo',
|
||||
'lux.id': 'lux',
|
||||
'id.lux.network': 'lux',
|
||||
'zoo.id': 'zoo',
|
||||
'id.zoo.network': 'zoo',
|
||||
'pars.id': 'pars',
|
||||
'id.pars.network': 'pars',
|
||||
'zen.id': 'zen',
|
||||
'id.ad.nexus': 'adnexus',
|
||||
}
|
||||
|
||||
// Default domain → default app clientId
|
||||
const APP_MAP: Record<string, string> = {
|
||||
'hanzo.id': 'hanzo-id',
|
||||
'id.hanzo.ai': 'hanzo-id',
|
||||
'lux.id': 'app-lux',
|
||||
'id.lux.network': 'app-lux',
|
||||
'zoo.id': 'app-zoo',
|
||||
'id.zoo.network': 'app-zoo',
|
||||
'pars.id': 'app-pars',
|
||||
'id.pars.network': 'app-pars',
|
||||
'zen.id': 'app-zen',
|
||||
'id.ad.nexus': 'app-adnexus',
|
||||
}
|
||||
|
||||
export function getIamUrl(host: string): string {
|
||||
const domain = host.split(':')[0]
|
||||
|
||||
// 1. Check env override (for self-hosted / K8s)
|
||||
if (typeof process !== 'undefined') {
|
||||
const envUrl = process.env.NEXT_PUBLIC_IAM_URL || process.env.HANZO_IAM_URL
|
||||
if (envUrl) return envUrl
|
||||
}
|
||||
|
||||
// 2. Static map
|
||||
return IAM_URLS[domain] ?? 'https://iam.hanzo.ai'
|
||||
}
|
||||
|
||||
export function getOrg(host: string): string {
|
||||
const domain = host.split(':')[0]
|
||||
if (typeof process !== 'undefined' && process.env.NEXT_PUBLIC_ORG) {
|
||||
return process.env.NEXT_PUBLIC_ORG
|
||||
}
|
||||
return ORG_MAP[domain] ?? 'hanzo'
|
||||
}
|
||||
|
||||
export function getDefaultClientId(host: string): string {
|
||||
const domain = host.split(':')[0]
|
||||
if (typeof process !== 'undefined' && process.env.NEXT_PUBLIC_CLIENT_ID) {
|
||||
return process.env.NEXT_PUBLIC_CLIENT_ID
|
||||
}
|
||||
return APP_MAP[domain] ?? 'hanzo-id'
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
/**
|
||||
* OAuth2 / OIDC client with PKCE (RFC 7636)
|
||||
*
|
||||
* Works against Hanzo IAM backend via the tenant's iamOrigin.
|
||||
*/
|
||||
|
||||
// --- PKCE helpers ---
|
||||
|
||||
function generateRandomString(length: number): string {
|
||||
const array = new Uint8Array(length)
|
||||
crypto.getRandomValues(array)
|
||||
return Array.from(array, (b) => b.toString(16).padStart(2, '0')).join('').slice(0, length)
|
||||
}
|
||||
|
||||
async function sha256(plain: string): Promise<ArrayBuffer> {
|
||||
const encoder = new TextEncoder()
|
||||
return crypto.subtle.digest('SHA-256', encoder.encode(plain))
|
||||
}
|
||||
|
||||
function base64urlEncode(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer)
|
||||
let str = ''
|
||||
for (const b of bytes) str += String.fromCharCode(b)
|
||||
return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
export async function generatePKCE() {
|
||||
const verifier = generateRandomString(64)
|
||||
const hashed = await sha256(verifier)
|
||||
const challenge = base64urlEncode(hashed)
|
||||
return { verifier, challenge }
|
||||
}
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface TokenResponse {
|
||||
access_token: string
|
||||
token_type: string
|
||||
expires_in: number
|
||||
refresh_token?: string
|
||||
id_token?: string
|
||||
scope?: string
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
sub: string
|
||||
name?: string
|
||||
displayName?: string
|
||||
preferred_username?: string
|
||||
email?: string
|
||||
avatar?: string
|
||||
permanentAvatar?: string
|
||||
picture?: string
|
||||
owner?: string
|
||||
}
|
||||
|
||||
// --- Token storage (sessionStorage for PKCE, localStorage for session) ---
|
||||
|
||||
const PREFIX = 'hanzo_auth_'
|
||||
|
||||
export function storeSession(key: string, value: string) {
|
||||
sessionStorage.setItem(PREFIX + key, value)
|
||||
}
|
||||
|
||||
export function retrieveSession(key: string): string | null {
|
||||
const val = sessionStorage.getItem(PREFIX + key)
|
||||
sessionStorage.removeItem(PREFIX + key)
|
||||
return val
|
||||
}
|
||||
|
||||
// --- Core flows ---
|
||||
|
||||
/**
|
||||
* Password login against IAM /api/login.
|
||||
* Returns JWT token directly.
|
||||
*/
|
||||
export async function passwordLogin(params: {
|
||||
iamUrl: string
|
||||
org: string
|
||||
username: string
|
||||
password: string
|
||||
application: string
|
||||
clientId?: string
|
||||
redirectUri?: string
|
||||
}): Promise<{ token: string; code?: string }> {
|
||||
const url = new URL('/api/login', params.iamUrl)
|
||||
|
||||
// If OAuth params provided, pass as query params (camelCase — IAM convention)
|
||||
if (params.clientId && params.redirectUri) {
|
||||
url.searchParams.set('clientId', params.clientId)
|
||||
url.searchParams.set('responseType', 'code')
|
||||
url.searchParams.set('redirectUri', params.redirectUri)
|
||||
url.searchParams.set('scope', 'openid profile email')
|
||||
}
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'token',
|
||||
organization: params.org,
|
||||
username: params.username,
|
||||
password: params.password,
|
||||
application: params.application,
|
||||
...(params.clientId ? { clientId: params.clientId } : {}),
|
||||
...(params.redirectUri ? { redirectUri: params.redirectUri } : {}),
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (data.status !== 'ok') {
|
||||
throw new Error(data.msg || 'Login failed')
|
||||
}
|
||||
|
||||
return { token: data.data }
|
||||
}
|
||||
|
||||
/**
|
||||
* Start OAuth authorize redirect with PKCE.
|
||||
*/
|
||||
export async function startAuthorize(params: {
|
||||
iamUrl: string
|
||||
clientId: string
|
||||
redirectUri: string
|
||||
scope?: string
|
||||
}) {
|
||||
const { verifier, challenge } = await generatePKCE()
|
||||
const state = generateRandomString(32)
|
||||
|
||||
storeSession('pkce_verifier', verifier)
|
||||
storeSession('oauth_state', state)
|
||||
|
||||
const url = new URL('/oauth/authorize', params.iamUrl)
|
||||
url.searchParams.set('client_id', params.clientId)
|
||||
url.searchParams.set('response_type', 'code')
|
||||
url.searchParams.set('redirect_uri', params.redirectUri)
|
||||
url.searchParams.set('scope', params.scope ?? 'openid profile email')
|
||||
url.searchParams.set('state', state)
|
||||
url.searchParams.set('code_challenge', challenge)
|
||||
url.searchParams.set('code_challenge_method', 'S256')
|
||||
|
||||
window.location.href = url.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for tokens.
|
||||
*/
|
||||
export async function exchangeCode(params: {
|
||||
iamUrl: string
|
||||
code: string
|
||||
state: string
|
||||
clientId: string
|
||||
redirectUri: string
|
||||
}): Promise<TokenResponse> {
|
||||
const savedState = retrieveSession('oauth_state')
|
||||
if (savedState !== params.state) {
|
||||
throw new Error('OAuth state mismatch')
|
||||
}
|
||||
|
||||
const verifier = retrieveSession('pkce_verifier')
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: params.code,
|
||||
redirect_uri: params.redirectUri,
|
||||
client_id: params.clientId,
|
||||
...(verifier ? { code_verifier: verifier } : {}),
|
||||
})
|
||||
|
||||
const res = await fetch(`${params.iamUrl}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Token exchange failed: ${res.status}`)
|
||||
}
|
||||
|
||||
return res.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user info.
|
||||
*/
|
||||
export async function fetchUserInfo(iamUrl: string, accessToken: string): Promise<UserInfo> {
|
||||
const res = await fetch(`${iamUrl}/oauth/userinfo`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
if (!res.ok) throw new Error(`Userinfo failed: ${res.status}`)
|
||||
return res.json()
|
||||
}
|
||||
@@ -1,466 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* Next.js middleware — the core proxy layer for Hanzo ID.
|
||||
*
|
||||
* Handles:
|
||||
* 1. Multi-tenant hostname → org/IAM resolution
|
||||
* 2. RFC 6749/OIDC path normalization (standard → IAM backend paths)
|
||||
* 3. Social provider redirect with _oauth_ctx cookie
|
||||
* 4. OIDC discovery body rewriting
|
||||
* 5. Location header rewriting
|
||||
*
|
||||
* This is a white-label login portal. Any domain pointing here gets a
|
||||
* working OIDC/OAuth2 provider experience. Configure via env vars for
|
||||
* self-hosted deployments, or use the built-in tenant map.
|
||||
*/
|
||||
|
||||
// --- Tenant configuration ---
|
||||
|
||||
interface TenantConfig {
|
||||
org: string
|
||||
iamOrigin: string
|
||||
publicOrigin: string
|
||||
}
|
||||
|
||||
const TENANTS: Record<string, TenantConfig> = {
|
||||
'hanzo.id': {
|
||||
org: 'hanzo',
|
||||
iamOrigin: 'https://iam.hanzo.ai',
|
||||
publicOrigin: 'https://hanzo.id',
|
||||
},
|
||||
'id.hanzo.ai': {
|
||||
org: 'hanzo',
|
||||
iamOrigin: 'https://iam.hanzo.ai',
|
||||
publicOrigin: 'https://id.hanzo.ai',
|
||||
},
|
||||
'lux.id': {
|
||||
org: 'lux',
|
||||
iamOrigin: 'https://iam.hanzo.ai',
|
||||
publicOrigin: 'https://lux.id',
|
||||
},
|
||||
'iam.lux.network': {
|
||||
org: 'lux',
|
||||
iamOrigin: 'https://iam.hanzo.ai',
|
||||
publicOrigin: 'https://iam.lux.network',
|
||||
},
|
||||
'id.lux.network': {
|
||||
org: 'lux',
|
||||
iamOrigin: 'https://iam.hanzo.ai',
|
||||
publicOrigin: 'https://id.lux.network',
|
||||
},
|
||||
'id.lux-dev.network': {
|
||||
org: 'lux',
|
||||
iamOrigin: 'https://iam.hanzo.ai',
|
||||
publicOrigin: 'https://id.lux-dev.network',
|
||||
},
|
||||
'id.lux-test.network': {
|
||||
org: 'lux',
|
||||
iamOrigin: 'https://iam.hanzo.ai',
|
||||
publicOrigin: 'https://id.lux-test.network',
|
||||
},
|
||||
'id.dev.hanzo.ai': {
|
||||
org: 'hanzo',
|
||||
iamOrigin: 'https://iam.hanzo.ai',
|
||||
publicOrigin: 'https://id.dev.hanzo.ai',
|
||||
},
|
||||
'id.test.hanzo.ai': {
|
||||
org: 'hanzo',
|
||||
iamOrigin: 'https://iam.hanzo.ai',
|
||||
publicOrigin: 'https://id.test.hanzo.ai',
|
||||
},
|
||||
'id.zoo.network': {
|
||||
org: 'zoo',
|
||||
iamOrigin: 'https://iam.hanzo.ai',
|
||||
publicOrigin: 'https://id.zoo.network',
|
||||
},
|
||||
'id.zoo-dev.network': {
|
||||
org: 'zoo',
|
||||
iamOrigin: 'https://iam.hanzo.ai',
|
||||
publicOrigin: 'https://id.zoo-dev.network',
|
||||
},
|
||||
'id.zoo-test.network': {
|
||||
org: 'zoo',
|
||||
iamOrigin: 'https://iam.hanzo.ai',
|
||||
publicOrigin: 'https://id.zoo-test.network',
|
||||
},
|
||||
'id.hanzo.network': {
|
||||
org: 'hanzo',
|
||||
iamOrigin: 'https://iam.hanzo.ai',
|
||||
publicOrigin: 'https://id.hanzo.network',
|
||||
},
|
||||
'id.hanzo-dev.network': {
|
||||
org: 'hanzo',
|
||||
iamOrigin: 'https://iam.hanzo.ai',
|
||||
publicOrigin: 'https://id.hanzo-dev.network',
|
||||
},
|
||||
'id.hanzo-test.network': {
|
||||
org: 'hanzo',
|
||||
iamOrigin: 'https://iam.hanzo.ai',
|
||||
publicOrigin: 'https://id.hanzo-test.network',
|
||||
},
|
||||
'pars.id': {
|
||||
org: 'pars',
|
||||
iamOrigin: 'https://iam.hanzo.ai',
|
||||
publicOrigin: 'https://pars.id',
|
||||
},
|
||||
'id.pars.network': {
|
||||
org: 'pars',
|
||||
iamOrigin: 'https://iam.hanzo.ai',
|
||||
publicOrigin: 'https://id.pars.network',
|
||||
},
|
||||
'zen.id': {
|
||||
org: 'zen',
|
||||
iamOrigin: 'https://iam.hanzo.ai',
|
||||
publicOrigin: 'https://zen.id',
|
||||
},
|
||||
'id.ad.nexus': {
|
||||
org: 'adnexus',
|
||||
iamOrigin: 'https://iam.hanzo.ai',
|
||||
publicOrigin: 'https://id.ad.nexus',
|
||||
},
|
||||
'auth.hanzo.ai': {
|
||||
org: 'hanzo',
|
||||
iamOrigin: 'https://iam.hanzo.ai',
|
||||
publicOrigin: 'https://auth.hanzo.ai',
|
||||
},
|
||||
}
|
||||
|
||||
function getTenant(hostname: string): TenantConfig {
|
||||
const host = hostname.split(':')[0]
|
||||
const tenant = TENANTS[host]
|
||||
if (tenant) return tenant
|
||||
|
||||
// Env override for self-hosted / fork deployments
|
||||
const envIamOrigin = process.env.IAM_ORIGIN || process.env.NEXT_PUBLIC_IAM_URL
|
||||
return {
|
||||
org: process.env.NEXT_PUBLIC_ORG || 'hanzo',
|
||||
iamOrigin: envIamOrigin || 'https://iam.hanzo.ai',
|
||||
publicOrigin: `https://${host}`,
|
||||
}
|
||||
}
|
||||
|
||||
// --- RFC path normalization ---
|
||||
|
||||
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',
|
||||
// RFC 7662 — Token Introspection
|
||||
'/oauth/introspect': '/api/login/oauth/introspect',
|
||||
// RFC 7009 — Token Revocation
|
||||
'/oauth/revoke': '/api/login/oauth/revoke',
|
||||
// OIDC Core — UserInfo
|
||||
'/oauth/userinfo': '/api/userinfo',
|
||||
// OIDC — Logout
|
||||
'/oauth/logout': '/login/oauth/logout',
|
||||
// RFC 8628 — Device Authorization
|
||||
'/oauth/device': '/api/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)
|
||||
const IAM_PATH_PREFIXES = [
|
||||
'/api/',
|
||||
'/oauth/',
|
||||
'/login/oauth/',
|
||||
'/.well-known/',
|
||||
'/cas/',
|
||||
'/scim/',
|
||||
]
|
||||
|
||||
// Paths handled by the Next.js app (login UI)
|
||||
const APP_PATHS = [
|
||||
'/login',
|
||||
'/signup',
|
||||
'/callback',
|
||||
'/account',
|
||||
'/forgot-password',
|
||||
'/logout',
|
||||
]
|
||||
|
||||
function shouldProxyToIAM(pathname: string): boolean {
|
||||
// Don't proxy Next.js internal paths or our own API routes
|
||||
if (pathname.startsWith('/_next/')) return false
|
||||
if (pathname.startsWith('/api/auth/')) return false
|
||||
if (pathname.startsWith('/api/logout')) return false
|
||||
|
||||
// Don't proxy paths handled by the Next.js app
|
||||
// But DO proxy /login/oauth/* (IAM backend paths rewritten from /oauth/*)
|
||||
if (pathname.startsWith('/login/oauth/')) return true
|
||||
for (const p of APP_PATHS) {
|
||||
if (pathname === p || pathname.startsWith(p + '/')) return false
|
||||
}
|
||||
|
||||
return IAM_PATH_PREFIXES.some(p => pathname.startsWith(p))
|
||||
}
|
||||
|
||||
// --- Social provider redirect handling ---
|
||||
|
||||
/**
|
||||
* When /login/oauth/authorize or /oauth/authorize is called with a ?provider=
|
||||
* param, we need to:
|
||||
* 1. Resolve the app/org from the client_id
|
||||
* 2. Set an _oauth_ctx cookie so the callback handler knows context
|
||||
* 3. Proxy to IAM which redirects to the social provider
|
||||
*/
|
||||
async function handleSocialProviderRedirect(
|
||||
request: NextRequest,
|
||||
url: URL,
|
||||
pathname: string,
|
||||
tenant: TenantConfig,
|
||||
): Promise<NextResponse | null> {
|
||||
if (!url.searchParams.has('provider')) return null
|
||||
|
||||
const provider = url.searchParams.get('provider')!
|
||||
const clientId = url.searchParams.get('client_id') || ''
|
||||
const iamHost = new URL(tenant.iamOrigin).host
|
||||
|
||||
// Resolve app/org via IAM API, fall back to client map
|
||||
let appName = ''
|
||||
let appOwner = ''
|
||||
|
||||
if (clientId) {
|
||||
// Dynamic import to keep middleware lean
|
||||
const { resolveClient } = await import('@/lib/clients')
|
||||
|
||||
try {
|
||||
const loginParams = new URLSearchParams({
|
||||
clientId,
|
||||
type: 'code',
|
||||
responseType: url.searchParams.get('response_type') || 'code',
|
||||
redirectUri: url.searchParams.get('redirect_uri') || `${url.origin}/callback`,
|
||||
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 appLoginData = await appLoginRes.json()
|
||||
if (appLoginData?.status === 'ok' && appLoginData.data) {
|
||||
appName = appLoginData.data.name || ''
|
||||
appOwner = appLoginData.data.owner || appLoginData.data.organization || ''
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (!appName) {
|
||||
const client = resolveClient(clientId)
|
||||
if (client) {
|
||||
appName = client.application
|
||||
appOwner = client.organization
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store OAuth context in cookie for the callback handler
|
||||
const oauthContext = JSON.stringify({
|
||||
application: appName,
|
||||
organization: appOwner,
|
||||
provider,
|
||||
redirectUri: url.searchParams.get('redirect_uri') || `${url.origin}/callback`,
|
||||
clientId,
|
||||
})
|
||||
const oauthContextCookie = `_oauth_ctx=${encodeURIComponent(btoa(oauthContext))}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=600`
|
||||
|
||||
// Proxy to IAM — it manages the full social OAuth flow
|
||||
const iamUrl = new URL('/login/oauth/authorize' + url.search, tenant.iamOrigin)
|
||||
const headers = new Headers(request.headers)
|
||||
headers.set('Host', iamHost)
|
||||
headers.delete('connection')
|
||||
|
||||
const iamResponse = await fetch(iamUrl.toString(), {
|
||||
method: request.method,
|
||||
headers,
|
||||
body: request.body,
|
||||
redirect: 'manual',
|
||||
})
|
||||
|
||||
const responseHeaders = new Headers(iamResponse.headers)
|
||||
responseHeaders.append('Set-Cookie', oauthContextCookie)
|
||||
|
||||
// Rewrite IAM redirects to our domain
|
||||
const location = responseHeaders.get('location')
|
||||
if (location) {
|
||||
responseHeaders.set('location', location.replaceAll(tenant.iamOrigin, tenant.publicOrigin))
|
||||
}
|
||||
|
||||
return new NextResponse(iamResponse.body, {
|
||||
status: iamResponse.status,
|
||||
statusText: iamResponse.statusText,
|
||||
headers: responseHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
// --- Callback routing ---
|
||||
|
||||
/**
|
||||
* Route /callback to the right handler:
|
||||
* - If ?code=&state= present and state looks like a social callback → server-side handler
|
||||
* - Otherwise → let the Next.js callback page handle it (PKCE flow)
|
||||
*/
|
||||
function isSocialCallback(url: URL): boolean {
|
||||
const code = url.searchParams.get('code')
|
||||
const state = url.searchParams.get('state')
|
||||
if (!code || !state) return false
|
||||
|
||||
// IAM social callbacks have base64-encoded state starting with "?"
|
||||
try {
|
||||
const decoded = atob(state)
|
||||
if (decoded.startsWith('?') || decoded.includes('application=')) return true
|
||||
} catch {}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// --- Main middleware ---
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const url = new URL(request.url)
|
||||
let pathname = url.pathname
|
||||
const hostname = url.hostname
|
||||
|
||||
const tenant = getTenant(hostname)
|
||||
|
||||
// Route social callbacks to server-side handler
|
||||
if (pathname === '/callback' && isSocialCallback(url)) {
|
||||
const socialUrl = new URL('/api/auth/social-callback' + url.search, url.origin)
|
||||
return NextResponse.rewrite(socialUrl)
|
||||
}
|
||||
|
||||
// Handle /oauth/authorize and /login/oauth/authorize
|
||||
if (pathname === '/oauth/authorize' || pathname === '/login/oauth/authorize') {
|
||||
// Social login: proxy to IAM with ?provider= param
|
||||
if (url.searchParams.has('provider')) {
|
||||
const socialResponse = await handleSocialProviderRedirect(request, url, pathname, tenant)
|
||||
if (socialResponse) return socialResponse
|
||||
}
|
||||
|
||||
// If user already has an IAM session, try to proxy to IAM to complete OAuth authorize.
|
||||
// IAM will auto-authorize and redirect (3xx) if the session is valid for this app.
|
||||
// If IAM returns 200 (its built-in login page), fall through to our own login UI.
|
||||
const sessionCookie = request.cookies.get('iam_session_id')?.value
|
||||
if (sessionCookie) {
|
||||
const iamUrl = new URL('/login/oauth/authorize' + url.search, tenant.iamOrigin)
|
||||
const iamHost = new URL(tenant.iamOrigin).host
|
||||
|
||||
const headers = new Headers(request.headers)
|
||||
headers.set('Host', iamHost)
|
||||
headers.set('Cookie', `iam_session_id=${sessionCookie}`)
|
||||
headers.delete('connection')
|
||||
|
||||
const iamResponse = await fetch(iamUrl.toString(), {
|
||||
method: 'GET',
|
||||
headers,
|
||||
redirect: 'manual',
|
||||
})
|
||||
|
||||
// Only use IAM's response if it's a redirect (auto-authorize succeeded).
|
||||
// If IAM returns 200 (its login page), fall through to our own login UI.
|
||||
if (iamResponse.status >= 300 && iamResponse.status < 400) {
|
||||
const response = new NextResponse(iamResponse.body, {
|
||||
status: iamResponse.status,
|
||||
statusText: iamResponse.statusText,
|
||||
headers: iamResponse.headers,
|
||||
})
|
||||
|
||||
const location = response.headers.get('location')
|
||||
if (location) {
|
||||
response.headers.set(
|
||||
'location',
|
||||
location.replaceAll(tenant.iamOrigin, tenant.publicOrigin)
|
||||
)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
// IAM didn't auto-authorize — show our own login UI below
|
||||
}
|
||||
|
||||
// Show our own login UI with OAuth context
|
||||
// (Don't proxy to IAM's built-in SPA — hanzo.id IS the login UI)
|
||||
const loginUrl = new URL('/login' + url.search, url.origin)
|
||||
return NextResponse.redirect(loginUrl)
|
||||
}
|
||||
|
||||
// Apply RFC path normalization
|
||||
const rewrittenPath = PATH_REWRITES[pathname]
|
||||
if (rewrittenPath) {
|
||||
pathname = rewrittenPath
|
||||
}
|
||||
|
||||
// Proxy IAM paths to backend
|
||||
if (shouldProxyToIAM(pathname)) {
|
||||
const iamUrl = new URL(pathname + url.search, tenant.iamOrigin)
|
||||
const iamHost = new URL(tenant.iamOrigin).host
|
||||
|
||||
const headers = new Headers(request.headers)
|
||||
headers.set('Host', iamHost)
|
||||
headers.delete('connection')
|
||||
|
||||
const iamResponse = await fetch(iamUrl.toString(), {
|
||||
method: request.method,
|
||||
headers,
|
||||
body: request.body,
|
||||
redirect: 'manual',
|
||||
})
|
||||
|
||||
// Rewrite OIDC discovery documents
|
||||
const isDiscovery = pathname === '/.well-known/openid-configuration'
|
||||
|| pathname === '/.well-known/oauth-authorization-server'
|
||||
if (isDiscovery && iamResponse.ok) {
|
||||
const contentType = iamResponse.headers.get('content-type') || ''
|
||||
if (contentType.includes('json')) {
|
||||
try {
|
||||
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
|
||||
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,
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// Clone response and rewrite redirect Location headers
|
||||
const response = new NextResponse(iamResponse.body, {
|
||||
status: iamResponse.status,
|
||||
statusText: iamResponse.statusText,
|
||||
headers: iamResponse.headers,
|
||||
})
|
||||
|
||||
const location = response.headers.get('location')
|
||||
if (location) {
|
||||
response.headers.set(
|
||||
'location',
|
||||
location.replaceAll(tenant.iamOrigin, tenant.publicOrigin)
|
||||
)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/api/:path*',
|
||||
'/oauth/:path*',
|
||||
'/login/oauth/:path*',
|
||||
'/.well-known/:path*',
|
||||
'/callback',
|
||||
'/cas/:path*',
|
||||
'/scim/:path*',
|
||||
],
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { NextConfig } from 'next'
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '**',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
@@ -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.29",
|
||||
"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,35 @@
|
||||
{
|
||||
"name": "@hanzo/id-auth",
|
||||
"version": "0.1.1",
|
||||
"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",
|
||||
"test": "node --test --experimental-strip-types 'src/**/*.test.ts'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/id-shared": "workspace:*",
|
||||
"@hanzo/iam": "^0.11.0",
|
||||
"@paulmillr/qr": "^0.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=19",
|
||||
"react-dom": ">=19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* MFA wiring tests — pure, no network (fetch is mocked via `fetchImpl`).
|
||||
* Run with: pnpm --filter @hanzo/id-auth test
|
||||
*
|
||||
* Locks the wire contract verified live against iam.hanzo.ai:
|
||||
* - login answers a forced-MFA org with `data:"RequiredMfa"` (enroll) or
|
||||
* `data:"NextMfa"` + `data2` (challenge) — STRINGS, never a boolean.
|
||||
* - the `/v1/iam/mfa/setup/*` calls carry EVERY param on the query string with
|
||||
* an EMPTY body (the one shape IAM's authz self-match + controller accept).
|
||||
* - the challenge re-POSTs `/v1/iam/login` with `{mfaType,passcode}` and NO
|
||||
* username, riding the MFA session cookie.
|
||||
*/
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import type { TenantConfig } from '@hanzo/id-shared'
|
||||
import { createAuthClient, mfaChannelOf, MFA_TOTP } from './client.ts'
|
||||
|
||||
const TENANT: TenantConfig = {
|
||||
orgId: 'hanzo',
|
||||
iamUrl: 'https://hanzo.id',
|
||||
iamIssuer: 'https://hanzo.id',
|
||||
clientId: 'hanzo-id',
|
||||
appName: 'hanzo-id',
|
||||
publicOrigin: 'https://hanzo.id',
|
||||
brandPackage: '@hanzo/brand',
|
||||
}
|
||||
|
||||
type Call = { url: string; init: RequestInit }
|
||||
|
||||
function mockFetch(body: unknown, calls: Call[]): typeof fetch {
|
||||
return (async (input: string | URL, init?: RequestInit) => {
|
||||
calls.push({ url: String(input), init: init ?? {} })
|
||||
return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } })
|
||||
}) as unknown as typeof fetch
|
||||
}
|
||||
|
||||
test('login → RequiredMfa maps to an enroll signal (not a redirect)', async () => {
|
||||
const calls: Call[] = []
|
||||
const client = createAuthClient({ tenant: TENANT, fetchImpl: mockFetch({ status: 'ok', data: 'RequiredMfa' }, calls) })
|
||||
const res = await client.login({
|
||||
identifier: 'davelorenzini@gmail.com',
|
||||
password: 'x',
|
||||
clientId: 'hanzo-id',
|
||||
application: 'hanzo-id',
|
||||
organization: 'hanzo',
|
||||
})
|
||||
assert.equal(res.mfaRequired, true)
|
||||
assert.equal(res.mfaStage, 'enroll')
|
||||
assert.equal(res.redirectUrl, undefined, 'must NOT short-circuit to /onboarding')
|
||||
})
|
||||
|
||||
test('login → NextMfa maps to a challenge signal and carries the allowed types', async () => {
|
||||
const calls: Call[] = []
|
||||
const body = {
|
||||
status: 'ok',
|
||||
data: 'NextMfa',
|
||||
data2: [{ mfaType: 'app', enabled: true }, { mfaType: 'sms', enabled: true }],
|
||||
}
|
||||
const client = createAuthClient({ tenant: TENANT, fetchImpl: mockFetch(body, calls) })
|
||||
const res = await client.login({
|
||||
identifier: 'davelorenzini@gmail.com',
|
||||
password: 'x',
|
||||
clientId: 'hanzo-id',
|
||||
application: 'hanzo-id',
|
||||
organization: 'hanzo',
|
||||
})
|
||||
assert.equal(res.mfaStage, 'challenge')
|
||||
assert.deepEqual(res.mfaTypes, ['app', 'sms'])
|
||||
})
|
||||
|
||||
test('mfaInitiate puts owner/name/mfaType on the query string with an empty body', async () => {
|
||||
const calls: Call[] = []
|
||||
const data = { secret: 'BOUYRUSHJCEDDB33', url: 'otpauth://totp/Hanzo:x?secret=BOUYRUSHJCEDDB33', recoveryCodes: ['rc-1'] }
|
||||
const client = createAuthClient({ tenant: TENANT, fetchImpl: mockFetch({ status: 'ok', data }, calls) })
|
||||
const setup = await client.mfaInitiate({ owner: 'hanzo', name: 'davelorenzini@gmail.com' })
|
||||
|
||||
assert.equal(setup.secret, 'BOUYRUSHJCEDDB33')
|
||||
assert.equal(setup.mfaType, MFA_TOTP)
|
||||
assert.deepEqual(setup.recoveryCodes, ['rc-1'])
|
||||
|
||||
const u = new URL(calls[0].url)
|
||||
assert.equal(u.pathname, '/v1/iam/mfa/setup/initiate')
|
||||
assert.equal(u.searchParams.get('owner'), 'hanzo')
|
||||
assert.equal(u.searchParams.get('name'), 'davelorenzini@gmail.com')
|
||||
assert.equal(u.searchParams.get('mfaType'), 'app')
|
||||
assert.equal(calls[0].init.method, 'POST')
|
||||
assert.equal(calls[0].init.body, undefined, 'body must be empty for authz self-match')
|
||||
assert.equal(calls[0].init.credentials, 'include')
|
||||
})
|
||||
|
||||
test('mfaVerify carries owner/name (for authz) + secret + passcode on the query', async () => {
|
||||
const calls: Call[] = []
|
||||
const client = createAuthClient({ tenant: TENANT, fetchImpl: mockFetch({ status: 'ok', data: 'OK' }, calls) })
|
||||
const r = await client.mfaVerify({ owner: 'hanzo', name: 'dave@x', secret: 'SEC', passcode: '123456' })
|
||||
assert.equal(r.ok, true)
|
||||
const u = new URL(calls[0].url)
|
||||
assert.equal(u.pathname, '/v1/iam/mfa/setup/verify')
|
||||
assert.equal(u.searchParams.get('owner'), 'hanzo')
|
||||
assert.equal(u.searchParams.get('secret'), 'SEC')
|
||||
assert.equal(u.searchParams.get('passcode'), '123456')
|
||||
assert.equal(u.searchParams.get('mfaType'), 'app')
|
||||
})
|
||||
|
||||
test('mfaVerify surfaces an IAM error instead of throwing', async () => {
|
||||
const calls: Call[] = []
|
||||
const client = createAuthClient({ tenant: TENANT, fetchImpl: mockFetch({ status: 'error', msg: 'wrong passcode' }, calls) })
|
||||
const r = await client.mfaVerify({ owner: 'hanzo', name: 'dave@x', secret: 'SEC', passcode: '000000' })
|
||||
assert.equal(r.ok, false)
|
||||
assert.equal(r.error, 'wrong passcode')
|
||||
})
|
||||
|
||||
test('mfaEnable echoes the recovery code back on the query', async () => {
|
||||
const calls: Call[] = []
|
||||
const client = createAuthClient({ tenant: TENANT, fetchImpl: mockFetch({ status: 'ok', data: 'OK' }, calls) })
|
||||
const r = await client.mfaEnable({ owner: 'hanzo', name: 'dave@x', secret: 'SEC', recoveryCode: 'rc-1' })
|
||||
assert.equal(r.ok, true)
|
||||
const u = new URL(calls[0].url)
|
||||
assert.equal(u.pathname, '/v1/iam/mfa/setup/enable')
|
||||
assert.equal(u.searchParams.get('recoveryCodes'), 'rc-1')
|
||||
assert.equal(u.searchParams.get('secret'), 'SEC')
|
||||
})
|
||||
|
||||
test('mfaChallenge re-POSTs /v1/iam/login with mfaType/passcode and NO username', async () => {
|
||||
const calls: Call[] = []
|
||||
// code flow: data is the freshly minted auth code
|
||||
const client = createAuthClient({ tenant: TENANT, fetchImpl: mockFetch({ status: 'ok', data: 'AUTHCODE' }, calls) })
|
||||
const res = await client.mfaChallenge({
|
||||
mfaType: 'app',
|
||||
passcode: '654321',
|
||||
clientId: 'hanzo-id',
|
||||
application: 'hanzo-id',
|
||||
organization: 'hanzo',
|
||||
redirectUri: 'https://app.example/cb',
|
||||
state: 'st',
|
||||
})
|
||||
const sent = JSON.parse(String(calls[0].init.body)) as Record<string, unknown>
|
||||
assert.equal(new URL(calls[0].url).pathname, '/v1/iam/login')
|
||||
assert.equal(sent.mfaType, 'app')
|
||||
assert.equal(sent.passcode, '654321')
|
||||
assert.equal(sent.username, undefined, 'challenge must not send a username')
|
||||
assert.equal(calls[0].init.credentials, 'include')
|
||||
assert.equal(res.redirectUrl, 'https://app.example/cb?code=AUTHCODE&state=st')
|
||||
})
|
||||
|
||||
test('mfaChannelOf maps IAM types to UI channels', () => {
|
||||
assert.equal(mfaChannelOf('app'), 'totp')
|
||||
assert.equal(mfaChannelOf('sms'), 'sms')
|
||||
assert.equal(mfaChannelOf('email'), 'email')
|
||||
assert.equal(mfaChannelOf('anything-else'), 'totp')
|
||||
})
|
||||
@@ -0,0 +1,556 @@
|
||||
import type { TenantConfig } from '@hanzo/id-shared'
|
||||
import type {
|
||||
AppLogin,
|
||||
AppProvider,
|
||||
ForgotRequest,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
MfaChallengeRequest,
|
||||
MfaChannel,
|
||||
MfaIdentity,
|
||||
MfaSetup,
|
||||
OAuthAuthorizeRequest,
|
||||
SignupRequest,
|
||||
TokenResponse,
|
||||
} from './types'
|
||||
|
||||
/** IAM's TOTP MFA type constant (`object.TotpType`). */
|
||||
export const MFA_TOTP = 'app'
|
||||
|
||||
/** Map an IAM MFA type to the {@link MfaChannel} the OTP UI renders a label for. */
|
||||
export function mfaChannelOf(iamType: string): MfaChannel {
|
||||
return iamType === 'sms' ? 'sms' : iamType === 'email' ? 'email' : 'totp'
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable IAM client.
|
||||
*
|
||||
* Stateless wrapper around the canonical IAM REST surface (Casdoor-compat
|
||||
* paths under `/v1/iam/*` and the OIDC paths under `/v1/iam/oauth/*`). One
|
||||
* client instance per tenant. The portal creates one in `createRoot()`;
|
||||
* downstream pages call `.login()`, `.signup()`, `.forgot()`, `.authorize()`
|
||||
* directly.
|
||||
*
|
||||
* Wire contract (verified against live IAM): the auth fields — `type`,
|
||||
* `application`, `organization` — are read from the request BODY; the OAuth
|
||||
* params — `clientId`, `responseType`, `redirectUri`, `scope`, `state` — ride
|
||||
* on the query string. `type=code` (a client `redirectUri` is present) returns
|
||||
* an authorization code in `data`; `type=login` (bare portal sign-in)
|
||||
* establishes the session cookie.
|
||||
*
|
||||
* Token storage is intentionally NOT part of this client — the portal is a
|
||||
* white-label OIDC provider, so tokens are minted then immediately redirected
|
||||
* back to the requesting app via `redirectUri`. The browser never holds them
|
||||
* past the redirect.
|
||||
*/
|
||||
export interface AuthClient {
|
||||
readonly tenant: TenantConfig
|
||||
login(req: LoginRequest): Promise<LoginResponse>
|
||||
signup(req: SignupRequest): Promise<LoginResponse>
|
||||
forgot(req: ForgotRequest): Promise<{ ok: boolean; error?: string }>
|
||||
authorize(req: OAuthAuthorizeRequest): string
|
||||
exchange(code: string, codeVerifier?: string): Promise<TokenResponse>
|
||||
logout(idTokenHint?: string, postLogoutRedirectUri?: string): string
|
||||
/**
|
||||
* Read the live enabled-auth-methods view for an application from
|
||||
* `/v1/iam/get-app-login` — the canonical source of truth for which
|
||||
* sign-in buttons (password / GitHub / Google / Web3) to render.
|
||||
* Resolves to null when the endpoint is unreachable so callers can fall
|
||||
* back to the tenant's declared default method set.
|
||||
*/
|
||||
getAppLogin(clientId?: string): Promise<AppLogin | null>
|
||||
/**
|
||||
* Complete a social provider login when the provider redirects back to
|
||||
* `/callback` with a `code` + base64 `state` (see `social.ts`). Exchanges the
|
||||
* provider code at the IAM backend (the Casdoor `AuthBackend.login` contract)
|
||||
* and resolves the URL to redirect to — the original OIDC `redirect_uri` with
|
||||
* an authorization code, which the portal's normal PKCE callback then
|
||||
* completes. NOTE: pending live verification — runs only once real OAuth
|
||||
* provider creds are seeded (the buttons are hidden until then).
|
||||
*/
|
||||
providerLogin(req: ProviderExchangeRequest): Promise<{ redirectUrl?: string; error?: string }>
|
||||
/**
|
||||
* Resolve the signed-in user's `{owner, name}` from the IAM session
|
||||
* (`/v1/iam/get-account`). After a `RequiredMfa` login the IAM session cookie
|
||||
* already authenticates the user (IAM calls `SetSessionUsername` before
|
||||
* answering `RequiredMfa`), so this is how the portal learns the identity to
|
||||
* key the forced-enrollment calls on. Resolves null when unauthenticated.
|
||||
*/
|
||||
getAccount(): Promise<MfaIdentity | null>
|
||||
/**
|
||||
* Begin TOTP enrollment: `POST /v1/iam/mfa/setup/initiate`. Returns the secret
|
||||
* + `otpauth://` URI + recovery codes. Does NOT persist anything — only
|
||||
* {@link mfaEnable} does.
|
||||
*/
|
||||
mfaInitiate(id: MfaIdentity): Promise<MfaSetup>
|
||||
/** Verify a TOTP code against a pending secret: `POST /v1/iam/mfa/setup/verify`. */
|
||||
mfaVerify(req: MfaIdentity & { secret: string; passcode: string }): Promise<{ ok: boolean; error?: string }>
|
||||
/** Persist a verified TOTP enrollment: `POST /v1/iam/mfa/setup/enable`. */
|
||||
mfaEnable(req: MfaIdentity & { secret: string; recoveryCode: string }): Promise<{ ok: boolean; error?: string }>
|
||||
/**
|
||||
* Answer a `NextMfa` challenge: `POST /v1/iam/login` with `{mfaType, passcode}`
|
||||
* and NO username, riding the MFA session cookie IAM set with `NextMfa`.
|
||||
* Returns the same shape as {@link login} (a redirect with an auth code for the
|
||||
* code flow, or a bare-session signal for portal sign-in).
|
||||
*/
|
||||
mfaChallenge(req: MfaChallengeRequest): Promise<LoginResponse>
|
||||
}
|
||||
|
||||
/** Inputs to {@link AuthClient.providerLogin}, recovered from the /callback return. */
|
||||
export interface ProviderExchangeRequest {
|
||||
/** IAM application name (from the decoded state). */
|
||||
readonly application: string
|
||||
/** IAM provider record name, e.g. `provider-github`. */
|
||||
readonly provider: string
|
||||
/** The provider's authorization code (the `?code=` on the /callback return). */
|
||||
readonly code: string
|
||||
/** The ORIGINAL OIDC authorize query string (decoded from the base64 state). */
|
||||
readonly oidcQuery: string
|
||||
/** "signin" | "signup". */
|
||||
readonly method: string
|
||||
}
|
||||
|
||||
export interface AuthClientOptions {
|
||||
readonly tenant: TenantConfig
|
||||
/** Override fetch impl (testing). Defaults to global fetch. */
|
||||
readonly fetchImpl?: typeof fetch
|
||||
}
|
||||
|
||||
export function createAuthClient(opts: AuthClientOptions): AuthClient {
|
||||
const tenant = opts.tenant
|
||||
const f = opts.fetchImpl ?? fetch
|
||||
|
||||
async function login(req: LoginRequest): Promise<LoginResponse> {
|
||||
const type = req.redirectUri ? 'code' : 'login'
|
||||
const url = new URL('/v1/iam/login', tenant.iamUrl)
|
||||
url.searchParams.set('clientId', req.clientId)
|
||||
url.searchParams.set('responseType', 'code')
|
||||
if (req.redirectUri) url.searchParams.set('redirectUri', req.redirectUri)
|
||||
url.searchParams.set('scope', 'openid profile email')
|
||||
if (req.state) url.searchParams.set('state', req.state)
|
||||
if (req.codeChallenge) {
|
||||
url.searchParams.set('code_challenge', req.codeChallenge)
|
||||
url.searchParams.set('code_challenge_method', req.codeChallengeMethod ?? 'S256')
|
||||
}
|
||||
url.searchParams.set('type', type)
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
type,
|
||||
username: req.identifier,
|
||||
password: req.password,
|
||||
application: req.application,
|
||||
organization: req.organization,
|
||||
signinMethod: 'Password',
|
||||
autoSignin: true,
|
||||
}),
|
||||
})
|
||||
return parseLoginResponse(res, req)
|
||||
}
|
||||
|
||||
async function signup(req: SignupRequest): Promise<LoginResponse> {
|
||||
const url = new URL('/v1/iam/signup', tenant.iamUrl)
|
||||
url.searchParams.set('clientId', req.clientId)
|
||||
const username = req.email.split('@')[0]
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
application: req.application,
|
||||
organization: req.organization,
|
||||
username,
|
||||
name: username,
|
||||
email: req.email,
|
||||
password: req.password,
|
||||
confirm: req.password,
|
||||
autoSignin: true,
|
||||
...(req.inviteCode ? { invitationCode: req.inviteCode } : {}),
|
||||
}),
|
||||
})
|
||||
return parseLoginResponse(res)
|
||||
}
|
||||
|
||||
async function forgot(req: ForgotRequest): Promise<{ ok: boolean; error?: string }> {
|
||||
const url = new URL('/v1/iam/send-verification-code', tenant.iamUrl)
|
||||
url.searchParams.set('clientId', req.clientId)
|
||||
url.searchParams.set('organization', req.organization)
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
applicationId: `admin/${tenant.appName}`,
|
||||
organization: req.organization,
|
||||
dest: req.identifier,
|
||||
type: req.identifier.includes('@') ? 'email' : 'phone',
|
||||
method: 'forget',
|
||||
checkUser: req.identifier,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }
|
||||
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>
|
||||
if (body.status === 'error') return { ok: false, error: typeof body.msg === 'string' ? body.msg : 'failed' }
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
function authorize(req: OAuthAuthorizeRequest): string {
|
||||
const url = new URL('/v1/iam/oauth/authorize', tenant.iamUrl)
|
||||
url.searchParams.set('client_id', req.clientId)
|
||||
url.searchParams.set('redirect_uri', req.redirectUri)
|
||||
url.searchParams.set('response_type', req.responseType ?? 'code')
|
||||
url.searchParams.set('scope', req.scope ?? 'openid profile email')
|
||||
url.searchParams.set('state', req.state)
|
||||
if (req.nonce) url.searchParams.set('nonce', req.nonce)
|
||||
if (req.codeChallenge) {
|
||||
url.searchParams.set('code_challenge', req.codeChallenge)
|
||||
url.searchParams.set('code_challenge_method', req.codeChallengeMethod ?? 'S256')
|
||||
}
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
async function exchange(code: string, codeVerifier?: string): Promise<TokenResponse> {
|
||||
const url = new URL('/v1/iam/oauth/token', tenant.iamUrl)
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: tenant.clientId,
|
||||
redirect_uri: `${tenant.publicOrigin}/callback`,
|
||||
})
|
||||
if (codeVerifier) body.set('code_verifier', codeVerifier)
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
})
|
||||
if (!res.ok) throw new Error(`token exchange failed: ${res.status}`)
|
||||
const data = (await res.json()) as Record<string, unknown>
|
||||
return {
|
||||
accessToken: String(data.access_token ?? ''),
|
||||
refreshToken: typeof data.refresh_token === 'string' ? data.refresh_token : undefined,
|
||||
idToken: typeof data.id_token === 'string' ? data.id_token : undefined,
|
||||
tokenType: String(data.token_type ?? 'Bearer'),
|
||||
expiresIn: typeof data.expires_in === 'number' ? data.expires_in : undefined,
|
||||
scope: typeof data.scope === 'string' ? data.scope : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function logout(idTokenHint?: string, postLogoutRedirectUri?: string): string {
|
||||
const url = new URL('/v1/iam/oauth/logout', tenant.iamUrl)
|
||||
if (idTokenHint) url.searchParams.set('id_token_hint', idTokenHint)
|
||||
url.searchParams.set(
|
||||
'post_logout_redirect_uri',
|
||||
postLogoutRedirectUri ?? `${tenant.publicOrigin}/login`,
|
||||
)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
async function getAppLogin(clientId?: string): Promise<AppLogin | null> {
|
||||
const id = clientId ?? tenant.clientId
|
||||
const url = new URL('/v1/iam/get-app-login', tenant.iamUrl)
|
||||
url.searchParams.set('clientId', id)
|
||||
url.searchParams.set('responseType', 'code')
|
||||
url.searchParams.set('redirectUri', `${tenant.publicOrigin}/callback`)
|
||||
url.searchParams.set('scope', 'openid profile email')
|
||||
url.searchParams.set('state', 'app-login')
|
||||
let body: Record<string, unknown>
|
||||
try {
|
||||
const res = await f(url.toString(), { headers: { Accept: 'application/json' } })
|
||||
if (!res.ok) return null
|
||||
body = (await res.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (body.status !== 'ok' || typeof body.data !== 'object' || body.data === null) return null
|
||||
return parseAppLogin(body.data as Record<string, unknown>, tenant.appName, tenant.orgId)
|
||||
}
|
||||
|
||||
async function providerLogin(
|
||||
req: ProviderExchangeRequest,
|
||||
): Promise<{ redirectUrl?: string; error?: string }> {
|
||||
// POST the provider code to the IAM backend with the original OIDC params
|
||||
// as the query string (Casdoor `AuthBackend.login(body, oAuthParams)`). The
|
||||
// backend exchanges the code, signs the user in, and returns the URL to
|
||||
// continue the original authorize request.
|
||||
const url = new URL('/v1/iam/login', tenant.iamUrl)
|
||||
const oidc = new URLSearchParams(req.oidcQuery.replace(/^\?/, ''))
|
||||
for (const [k, v] of oidc) {
|
||||
if (['client_id', 'redirect_uri', 'response_type', 'scope', 'state', 'nonce', 'code_challenge', 'code_challenge_method'].includes(k)) {
|
||||
url.searchParams.set(k, v)
|
||||
}
|
||||
}
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
type: 'code',
|
||||
application: req.application,
|
||||
provider: req.provider,
|
||||
code: req.code,
|
||||
state: req.application,
|
||||
redirectUri: `${tenant.publicOrigin}/callback`,
|
||||
method: req.method,
|
||||
}),
|
||||
})
|
||||
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}` }
|
||||
}
|
||||
// On success the backend returns the continue-URL in `data`.
|
||||
const data = typeof body.data === 'string' ? body.data : ''
|
||||
return data ? { redirectUrl: data } : { error: 'provider login returned no redirect' }
|
||||
}
|
||||
|
||||
async function getAccount(): Promise<MfaIdentity | null> {
|
||||
const url = new URL('/v1/iam/get-account', tenant.iamUrl)
|
||||
let body: Record<string, unknown>
|
||||
try {
|
||||
const res = await f(url.toString(), { headers: { Accept: 'application/json' }, credentials: 'include' })
|
||||
if (!res.ok) return null
|
||||
body = (await res.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const d = (typeof body.data === 'object' && body.data ? body.data : {}) as Record<string, unknown>
|
||||
if (typeof d.owner !== 'string' || typeof d.name !== 'string' || !d.owner || !d.name) return null
|
||||
return { owner: d.owner, name: d.name }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `/v1/iam/mfa/setup/*` POST URL with EVERY param on the query string
|
||||
* and send an EMPTY body. This is the one wire shape IAM's authz filter and
|
||||
* the MFA controller both accept: the controller reads `owner`/`name`/… from
|
||||
* the merged form (query + body), while the authz filter only extracts the
|
||||
* `{owner,name}` object from the query when the body is empty (a non-empty
|
||||
* body is JSON-unmarshalled, and a urlencoded body fails that parse → empty
|
||||
* object → the self-access match `sub==obj` fails → "Unauthorized operation").
|
||||
* `owner`/`name` ride the query on EVERY call — including `verify`, which
|
||||
* otherwise carries no identity — purely so that self-access check passes.
|
||||
*/
|
||||
async function mfaSetupPost(path: string, params: Record<string, string>): Promise<Record<string, unknown>> {
|
||||
const url = new URL(`/v1/iam/mfa/setup/${path}`, tenant.iamUrl)
|
||||
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v)
|
||||
const res = await f(url.toString(), { method: 'POST', credentials: 'include' })
|
||||
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>
|
||||
if (typeof body.status === 'string' && body.status === 'error') {
|
||||
throw new Error(typeof body.msg === 'string' && body.msg ? body.msg : `HTTP ${res.status}`)
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return body
|
||||
}
|
||||
|
||||
async function mfaInitiate(id: MfaIdentity): Promise<MfaSetup> {
|
||||
const body = await mfaSetupPost('initiate', { owner: id.owner, name: id.name, mfaType: MFA_TOTP })
|
||||
const d = (typeof body.data === 'object' && body.data ? body.data : {}) as Record<string, unknown>
|
||||
const secret = typeof d.secret === 'string' ? d.secret : ''
|
||||
const url = typeof d.url === 'string' ? d.url : ''
|
||||
if (!secret || !url) throw new Error('IAM returned no TOTP secret')
|
||||
return {
|
||||
mfaType: MFA_TOTP,
|
||||
secret,
|
||||
url,
|
||||
recoveryCodes: Array.isArray(d.recoveryCodes) ? d.recoveryCodes.filter((c): c is string => typeof c === 'string') : [],
|
||||
}
|
||||
}
|
||||
|
||||
async function mfaVerify(req: MfaIdentity & { secret: string; passcode: string }): Promise<{ ok: boolean; error?: string }> {
|
||||
try {
|
||||
await mfaSetupPost('verify', { owner: req.owner, name: req.name, mfaType: MFA_TOTP, secret: req.secret, passcode: req.passcode })
|
||||
return { ok: true }
|
||||
} catch (e) {
|
||||
return { ok: false, error: e instanceof Error ? e.message : String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async function mfaEnable(req: MfaIdentity & { secret: string; recoveryCode: string }): Promise<{ ok: boolean; error?: string }> {
|
||||
try {
|
||||
await mfaSetupPost('enable', {
|
||||
owner: req.owner,
|
||||
name: req.name,
|
||||
mfaType: MFA_TOTP,
|
||||
secret: req.secret,
|
||||
recoveryCodes: req.recoveryCode,
|
||||
})
|
||||
return { ok: true }
|
||||
} catch (e) {
|
||||
return { ok: false, error: e instanceof Error ? e.message : String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async function mfaChallenge(req: MfaChallengeRequest): Promise<LoginResponse> {
|
||||
const type = req.redirectUri ? 'code' : 'login'
|
||||
const url = new URL('/v1/iam/login', tenant.iamUrl)
|
||||
url.searchParams.set('clientId', req.clientId)
|
||||
url.searchParams.set('responseType', 'code')
|
||||
if (req.redirectUri) url.searchParams.set('redirectUri', req.redirectUri)
|
||||
url.searchParams.set('scope', 'openid profile email')
|
||||
if (req.state) url.searchParams.set('state', req.state)
|
||||
if (req.codeChallenge) {
|
||||
url.searchParams.set('code_challenge', req.codeChallenge)
|
||||
url.searchParams.set('code_challenge_method', req.codeChallengeMethod ?? 'S256')
|
||||
}
|
||||
url.searchParams.set('type', type)
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
type,
|
||||
// No username: IAM resolves the user from the MFA session cookie it set
|
||||
// when it answered NextMfa.
|
||||
mfaType: req.mfaType,
|
||||
passcode: req.passcode,
|
||||
application: req.application,
|
||||
organization: req.organization,
|
||||
enableMfaRemember: req.rememberDevice ?? false,
|
||||
}),
|
||||
})
|
||||
return parseLoginResponse(res, req)
|
||||
}
|
||||
|
||||
return {
|
||||
tenant,
|
||||
login,
|
||||
signup,
|
||||
forgot,
|
||||
authorize,
|
||||
exchange,
|
||||
logout,
|
||||
getAppLogin,
|
||||
providerLogin,
|
||||
getAccount,
|
||||
mfaInitiate,
|
||||
mfaVerify,
|
||||
mfaEnable,
|
||||
mfaChallenge,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an IAM provider record to its canonical authorize-endpoint `provider`
|
||||
* key. IAM names providers `provider-<key>` (e.g. `provider-github`); the
|
||||
* `/v1/iam/oauth/authorize?provider=<key>` param wants the bare key. The
|
||||
* Web3Onboard wallet provider maps to `web3`.
|
||||
*/
|
||||
function providerKey(name: string): string {
|
||||
return name.replace(/^provider-/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider is renderable only when IAM holds a real OAuth clientId for it.
|
||||
* The seed ships obvious placeholders (`GITHUB_CLIENT_ID_PLACEHOLDER`,
|
||||
* `placeholder`); an empty or placeholder id means the provider isn't
|
||||
* provisioned, so its button is hidden rather than dead-ending the user. Real
|
||||
* OAuth client ids never contain "placeholder".
|
||||
*/
|
||||
function isConfiguredClientId(clientId: string): boolean {
|
||||
return clientId.length > 0 && !/placeholder/i.test(clientId)
|
||||
}
|
||||
|
||||
/** Shape the `/v1/iam/get-app-login` `data` payload into the {@link AppLogin} view. */
|
||||
function parseAppLogin(
|
||||
data: Record<string, unknown>,
|
||||
fallbackApp: string,
|
||||
fallbackOrg: string,
|
||||
): AppLogin {
|
||||
const rawProviders = Array.isArray(data.providers) ? data.providers : []
|
||||
const providers: AppProvider[] = rawProviders
|
||||
.map((p): AppProvider | null => {
|
||||
if (typeof p !== 'object' || p === null) return null
|
||||
const rec = p as Record<string, unknown>
|
||||
const name = typeof rec.name === 'string' ? rec.name : ''
|
||||
if (!name) return null
|
||||
// The clientId lives on the nested provider record (`rec.provider`), not
|
||||
// the outer link object.
|
||||
const inner =
|
||||
typeof rec.provider === 'object' && rec.provider !== null
|
||||
? (rec.provider as Record<string, unknown>)
|
||||
: {}
|
||||
const clientId = typeof inner.clientId === 'string' ? inner.clientId : ''
|
||||
return {
|
||||
name,
|
||||
key: providerKey(name),
|
||||
canSignIn: rec.canSignIn !== false,
|
||||
canSignUp: rec.canSignUp !== false,
|
||||
configured: isConfiguredClientId(clientId),
|
||||
type: typeof inner.type === 'string' ? inner.type : '',
|
||||
clientId,
|
||||
scopes: typeof inner.scopes === 'string' ? inner.scopes : '',
|
||||
}
|
||||
})
|
||||
.filter((p): p is AppProvider => p !== null)
|
||||
return {
|
||||
application: typeof data.name === 'string' ? data.name : fallbackApp,
|
||||
organization: typeof data.organization === 'string' ? data.organization : fallbackOrg,
|
||||
enablePassword: data.enablePassword !== false,
|
||||
enableSignUp: data.enableSignUp !== false,
|
||||
enableCodeSignin: data.enableCodeSignin === true,
|
||||
providers,
|
||||
}
|
||||
}
|
||||
|
||||
async function parseLoginResponse(
|
||||
res: Response,
|
||||
req?: { redirectUri?: string; state?: string },
|
||||
): Promise<LoginResponse> {
|
||||
let body: Record<string, unknown> = {}
|
||||
try {
|
||||
body = (await res.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
return { error: `HTTP ${res.status} non-JSON response` }
|
||||
}
|
||||
if (!res.ok || body.status === 'error') {
|
||||
return { error: typeof body.msg === 'string' ? body.msg : `HTTP ${res.status}` }
|
||||
}
|
||||
const data = body.data
|
||||
|
||||
// Multi-factor signal — IAM answers a successful credential check with a
|
||||
// STRING in `data` (NOT a `mfa_required` boolean): `"RequiredMfa"` when org
|
||||
// policy forces MFA the user has not enrolled, `"NextMfa"` when the user has
|
||||
// MFA and must answer a challenge. Branch BEFORE any session/redirect return:
|
||||
// the password session is not yet usable, so the portal must render the
|
||||
// enrollment/challenge step rather than navigate on.
|
||||
if (data === 'RequiredMfa') {
|
||||
return { mfaRequired: true, mfaStage: 'enroll' }
|
||||
}
|
||||
if (data === 'NextMfa') {
|
||||
const allow = Array.isArray(body.data2) ? body.data2 : []
|
||||
const mfaTypes = allow
|
||||
.map((p) => (typeof p === 'object' && p !== null ? (p as Record<string, unknown>).mfaType : undefined))
|
||||
.filter((t): t is string => typeof t === 'string' && t.length > 0)
|
||||
return { mfaRequired: true, mfaStage: 'challenge', mfaTypes }
|
||||
}
|
||||
|
||||
// Authorization-code flow: a client redirectUri is present and `data` is the
|
||||
// freshly minted code — hand the SPA a fully-formed redirect back to the app.
|
||||
if (req?.redirectUri && typeof data === 'string' && data.length > 0) {
|
||||
const sep = req.redirectUri.includes('?') ? '&' : '?'
|
||||
return {
|
||||
redirectUrl: `${req.redirectUri}${sep}code=${encodeURIComponent(data)}&state=${encodeURIComponent(req.state ?? '')}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Bare portal sign-in: the IAM session cookie is now set; land on the
|
||||
// post-login onboarding flow. Onboarding's IAM writes ride the same
|
||||
// session cookie (`credentials: include`), so no bearer token is needed
|
||||
// for the password path.
|
||||
if (!req?.redirectUri) {
|
||||
return { redirectUrl: '/onboarding' }
|
||||
}
|
||||
|
||||
// Fallback: a nested token payload (future direct-token IAM responses).
|
||||
const d = (typeof data === 'object' && data ? data : body) as Record<string, unknown>
|
||||
return {
|
||||
accessToken: typeof d.access_token === 'string' ? d.access_token : undefined,
|
||||
refreshToken: typeof d.refresh_token === 'string' ? d.refresh_token : undefined,
|
||||
idToken: typeof d.id_token === 'string' ? d.id_token : undefined,
|
||||
expiresAt: typeof d.expires_at === 'number' ? d.expires_at : undefined,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { TenantConfig } from '@hanzo/id-shared'
|
||||
import { IAM } from '@hanzo/iam/browser'
|
||||
|
||||
/**
|
||||
* One IAM browser-SDK instance per tenant, wired to the portal's own
|
||||
* `/callback` route. This is the single place that constructs the PKCE
|
||||
* client — social/web3 sign-in (here) and the callback handler
|
||||
* (`Callback.tsx`) share it so the PKCE verifier/state the SDK stores on
|
||||
* `signinRedirect` is the same one it reads on `handleCallback`. One way.
|
||||
*
|
||||
* The portal is its own OIDC client (`clientId` = the brand `-id` app), so
|
||||
* every flow it initiates lands back at `${publicOrigin}/callback`.
|
||||
*/
|
||||
export function createIam(tenant: TenantConfig, clientId?: string): IAM {
|
||||
return new IAM({
|
||||
serverUrl: tenant.iamUrl,
|
||||
clientId: clientId ?? tenant.clientId,
|
||||
redirectUri: `${tenant.publicOrigin}/callback`,
|
||||
scope: 'openid profile email',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export {
|
||||
createAuthClient,
|
||||
mfaChannelOf,
|
||||
MFA_TOTP,
|
||||
type AuthClient,
|
||||
type AuthClientOptions,
|
||||
} from './client'
|
||||
export { createIam } from './iam'
|
||||
export {
|
||||
startProviderLogin,
|
||||
buildProviderAuthUrl,
|
||||
isHoppableProvider,
|
||||
type ProviderLoginParams,
|
||||
} from './social'
|
||||
export type {
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
MfaChannel,
|
||||
MfaChallengeRequest,
|
||||
MfaIdentity,
|
||||
MfaSetup,
|
||||
SignupRequest,
|
||||
ForgotRequest,
|
||||
OAuthAuthorizeRequest,
|
||||
TokenResponse,
|
||||
AppLogin,
|
||||
AppProvider,
|
||||
} from './types'
|
||||
export * from './ui'
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Provider-hop URL builder tests — pure, no network. Run with:
|
||||
* pnpm --filter @hanzo/id-auth test
|
||||
*
|
||||
* Verifies the URL + base64 state match the Hanzo-IAM (Casdoor) `getAuthUrl`
|
||||
* contract so the backend `/callback` exchange accepts the return. The
|
||||
* end-to-end OAuth round-trip still needs live verification once real provider
|
||||
* creds are seeded — but the URL/state construction is locked down here.
|
||||
*/
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { buildProviderAuthUrl, isHoppableProvider } from './social.ts'
|
||||
|
||||
const ORIGIN = 'https://hanzo.id'
|
||||
// The original OIDC authorize query the portal was bounced here with.
|
||||
const SEARCH = '?client_id=hanzo-id&redirect_uri=https%3A%2F%2Fhanzo.id%2Fcallback&response_type=code&scope=openid&state=rp123'
|
||||
|
||||
test('GitHub hop builds the correct endpoint, client_id, redirect_uri, and scope', () => {
|
||||
const url = buildProviderAuthUrl(
|
||||
{ application: 'hanzo-id', providerName: 'provider-github', type: 'GitHub', clientId: 'gh_real_123' },
|
||||
ORIGIN,
|
||||
SEARCH,
|
||||
)!
|
||||
assert.ok(url.startsWith('https://github.com/login/oauth/authorize?'))
|
||||
assert.ok(url.includes('client_id=gh_real_123'))
|
||||
// No callbackOrigin → defaults to the browser origin.
|
||||
assert.ok(url.includes('redirect_uri=https://hanzo.id/callback'))
|
||||
assert.ok(url.includes('scope=user:email+read:user')) // GitHub default
|
||||
assert.ok(url.includes('response_type=code'))
|
||||
})
|
||||
|
||||
test('the registered callback origin overrides the browser origin in redirect_uri', () => {
|
||||
// The shared OAuth client is registered against iam.hanzo.ai/callback, so the
|
||||
// hop must return there even though the SPA runs on hanzo.id — otherwise the
|
||||
// provider rejects the redirect_uri (verified live: Google accepts ONLY
|
||||
// https://iam.hanzo.ai/callback for this client).
|
||||
const url = buildProviderAuthUrl(
|
||||
{ application: 'hanzo-id', providerName: 'provider-google', type: 'Google', clientId: 'goog_1' },
|
||||
ORIGIN,
|
||||
SEARCH,
|
||||
'https://iam.hanzo.ai',
|
||||
)!
|
||||
assert.ok(url.includes('redirect_uri=https://iam.hanzo.ai/callback'))
|
||||
assert.ok(!url.includes('redirect_uri=https://hanzo.id/callback'))
|
||||
})
|
||||
|
||||
test('state base64-encodes the original OIDC query + application/provider/method (round-trips)', () => {
|
||||
const url = buildProviderAuthUrl(
|
||||
{ application: 'hanzo-id', providerName: 'provider-github', type: 'GitHub', clientId: 'gh_real_123', method: 'signup' },
|
||||
ORIGIN,
|
||||
SEARCH,
|
||||
)!
|
||||
const state = new URL(url).searchParams.get('state')!
|
||||
const decoded = Buffer.from(state, 'base64').toString('utf8')
|
||||
// The RP's original request survives so the backend can complete it.
|
||||
assert.ok(decoded.includes('client_id=hanzo-id'))
|
||||
assert.ok(decoded.includes('state=rp123'))
|
||||
assert.ok(decoded.includes('application=hanzo-id'))
|
||||
assert.ok(decoded.includes('provider=provider-github'))
|
||||
assert.ok(decoded.includes('method=signup'))
|
||||
})
|
||||
|
||||
test('Google uses its own endpoint + scope; a custom provider scope overrides', () => {
|
||||
const g = buildProviderAuthUrl(
|
||||
{ application: 'hanzo-id', providerName: 'provider-google', type: 'Google', clientId: 'goog_1' },
|
||||
ORIGIN,
|
||||
SEARCH,
|
||||
)!
|
||||
assert.ok(g.startsWith('https://accounts.google.com/o/oauth2/v2/auth?'))
|
||||
assert.ok(g.includes('scope=profile+email'))
|
||||
|
||||
const custom = buildProviderAuthUrl(
|
||||
{ application: 'hanzo-id', providerName: 'provider-github', type: 'GitHub', clientId: 'gh_1', scopes: 'repo+user' },
|
||||
ORIGIN,
|
||||
SEARCH,
|
||||
)!
|
||||
assert.ok(custom.includes('scope=repo+user'))
|
||||
})
|
||||
|
||||
test('an unconfigured (empty clientId) or unknown provider type yields no URL', () => {
|
||||
assert.equal(buildProviderAuthUrl({ application: 'a', providerName: 'p', type: 'GitHub', clientId: '' }, ORIGIN, SEARCH), null)
|
||||
assert.equal(buildProviderAuthUrl({ application: 'a', providerName: 'p', type: 'Mystery', clientId: 'x' }, ORIGIN, SEARCH), null)
|
||||
})
|
||||
|
||||
test('isHoppableProvider knows the OAuth set, not wallet', () => {
|
||||
assert.equal(isHoppableProvider('GitHub'), true)
|
||||
assert.equal(isHoppableProvider('Google'), true)
|
||||
assert.equal(isHoppableProvider('Web3Onboard'), false)
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Social provider redirect — the "hop" that sends the browser to GitHub /
|
||||
* Google / … to start an OAuth login, replicating the Hanzo-IAM (Casdoor)
|
||||
* front-end `Provider.getAuthUrl` contract so the IAM backend's `/callback`
|
||||
* exchange accepts the return.
|
||||
*
|
||||
* Why this exists: the IAM backend's OIDC authorize endpoint, for an
|
||||
* unauthenticated request, 302s to its OWN login-page route (`/login/oauth/
|
||||
* authorize`) and expects the FRONT END to read `?provider=` and bounce to the
|
||||
* provider. We replaced that front end with this portal, so the portal must do
|
||||
* the bounce. `iam.signinRedirect({provider})` does NOT — it just re-enters the
|
||||
* authorize endpoint and loops.
|
||||
*
|
||||
* Contract (from `web/src/auth/Provider.tsx::getAuthUrl` + `Util.tsx::
|
||||
* getStateFromQueryParams` in the IAM fork):
|
||||
* url = `${endpoint}?client_id=${clientId}&redirect_uri=${origin}/callback`
|
||||
* `&scope=${scope}&response_type=code&state=${state}`
|
||||
* state = btoa(`${window.location.search}&application=${app}&provider=`
|
||||
* `${providerName}&method=${method}`) // base64 of the ORIGINAL
|
||||
* // OIDC query + app/provider/method, so the backend recovers the
|
||||
* // original request when the provider returns to /callback.
|
||||
*
|
||||
* Only the standard OAuth2 set is wired here (the providers a `-id` app
|
||||
* actually enables: github, google, +web3 handled elsewhere). Apple uses the
|
||||
* backend callback and is added when needed.
|
||||
*
|
||||
* NOTE: live-verify this end-to-end once real OAuth credentials are seeded —
|
||||
* it cannot be exercised while every provider carries placeholder creds (the
|
||||
* buttons are hidden until then; see SocialButtons + AppProvider.configured).
|
||||
*/
|
||||
|
||||
/** Provider `type` → OAuth2 authorize endpoint + default scope (IAM `authInfo`). */
|
||||
const AUTH_INFO: Record<string, { endpoint: string; scope: string }> = {
|
||||
GitHub: { endpoint: 'https://github.com/login/oauth/authorize', scope: 'user:email+read:user' },
|
||||
// Canonical Google OAuth2 authorize endpoint. (Google aliases the legacy
|
||||
// `/signin/oauth` path, but `/o/oauth2/v2/auth` is the documented, stable one.)
|
||||
Google: { endpoint: 'https://accounts.google.com/o/oauth2/v2/auth', scope: 'profile+email' },
|
||||
}
|
||||
|
||||
export interface ProviderLoginParams {
|
||||
/** IAM application name the portal authenticates as (e.g. `hanzo-id`). */
|
||||
readonly application: string
|
||||
/** IAM provider record name, e.g. `provider-github`. */
|
||||
readonly providerName: string
|
||||
/** IAM provider `type`, e.g. `GitHub` / `Google` (selects the endpoint). */
|
||||
readonly type: string
|
||||
/** The provider's real OAuth client id (from `get-app-login`). */
|
||||
readonly clientId: string
|
||||
/** Override scope; falls back to the type default. */
|
||||
readonly scopes?: string
|
||||
/** "signin" (default) or "signup" — passed through to the backend. */
|
||||
readonly method?: 'signin' | 'signup'
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the provider authorize URL (pure; testable without navigating).
|
||||
*
|
||||
* `callbackOrigin` is the origin of the `/callback` that MUST be registered as
|
||||
* the provider's authorized redirect URI. The OAuth client (one per provider)
|
||||
* is registered against a SINGLE callback host — the IAM backend host
|
||||
* (`iam.hanzo.ai`) — shared across every brand portal, so the provider only
|
||||
* accepts that exact `redirect_uri`. Sending the browser's own origin (e.g.
|
||||
* `hanzo.id`) yields `redirect_uri_mismatch`. Callers pass the registered
|
||||
* origin; it defaults to `origin` for the single-host / local-dev case.
|
||||
*
|
||||
* `iam.hanzo.ai/callback` serves the SAME `@hanzo/id` SPA (the headless
|
||||
* `Callback` page — no login UI), which decodes the base64 `state` to recover
|
||||
* the original app's `redirect_uri`, exchanges the provider `code` at the IAM
|
||||
* backend, and forwards the browser back to the originating app.
|
||||
*/
|
||||
export function buildProviderAuthUrl(
|
||||
p: ProviderLoginParams,
|
||||
origin: string,
|
||||
search: string,
|
||||
callbackOrigin: string = origin,
|
||||
): string | null {
|
||||
const info = AUTH_INFO[p.type]
|
||||
if (!info || !p.clientId) return null
|
||||
const scope = p.scopes && p.scopes.trim() !== '' ? p.scopes : info.scope
|
||||
const redirectUri = `${callbackOrigin}/callback`
|
||||
const method = p.method ?? 'signin'
|
||||
// Base64 of the original OIDC query + routing — the backend decodes this on
|
||||
// the /callback return to complete the original authorize request.
|
||||
const state = btoa(`${search}&application=${encodeURIComponent(p.application)}&provider=${encodeURIComponent(p.providerName)}&method=${method}`)
|
||||
return `${info.endpoint}?client_id=${p.clientId}&redirect_uri=${redirectUri}&scope=${scope}&response_type=code&state=${state}`
|
||||
}
|
||||
|
||||
/** True when this portal knows how to start an OAuth hop for the given type. */
|
||||
export function isHoppableProvider(type: string): boolean {
|
||||
return type in AUTH_INFO
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect the browser to the provider to begin login. No-op return on bad input.
|
||||
*
|
||||
* `callbackOrigin` (the provider's registered redirect host, e.g.
|
||||
* `https://iam.hanzo.ai`) defaults to the current origin when omitted.
|
||||
*/
|
||||
export function startProviderLogin(p: ProviderLoginParams, callbackOrigin?: string): void {
|
||||
if (typeof window === 'undefined') return
|
||||
const url = buildProviderAuthUrl(p, window.location.origin, window.location.search, callbackOrigin ?? window.location.origin)
|
||||
if (url) window.location.assign(url)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
export interface LoginRequest {
|
||||
readonly identifier: string
|
||||
readonly password: string
|
||||
readonly clientId: string
|
||||
readonly application: string
|
||||
readonly organization: string
|
||||
readonly redirectUri?: string
|
||||
readonly state?: string
|
||||
readonly codeChallenge?: string
|
||||
readonly codeChallengeMethod?: 'S256' | 'plain'
|
||||
}
|
||||
|
||||
/** A multi-factor channel the portal can render a code entry for. */
|
||||
export type MfaChannel = 'totp' | 'sms' | 'email'
|
||||
|
||||
export interface LoginResponse {
|
||||
readonly accessToken?: string
|
||||
readonly refreshToken?: string
|
||||
readonly idToken?: string
|
||||
readonly expiresAt?: number
|
||||
readonly redirectUrl?: string
|
||||
/**
|
||||
* Set when IAM answered the login with a multi-factor signal instead of a
|
||||
* session/code. `mfaStage` discriminates the two IAM states:
|
||||
* - `'enroll'` — IAM returned `data:"RequiredMfa"`: org policy forces MFA
|
||||
* and the user has none yet → render forced TOTP enrollment.
|
||||
* - `'challenge'` — IAM returned `data:"NextMfa"`: the user has MFA enabled
|
||||
* → render a code challenge for one of `mfaTypes`.
|
||||
* The password session is NOT established until the enrollment/challenge
|
||||
* completes, so the portal must not navigate past this signal.
|
||||
*/
|
||||
readonly mfaRequired?: boolean
|
||||
readonly mfaStage?: 'enroll' | 'challenge'
|
||||
/**
|
||||
* The IAM MFA types available for a `'challenge'` (from the login response's
|
||||
* `data2`), in IAM's own vocabulary: `app` (TOTP), `sms`, `email`. Empty for
|
||||
* enrollment.
|
||||
*/
|
||||
readonly mfaTypes?: readonly string[]
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The TOTP enrollment material minted by `/v1/iam/mfa/setup/initiate`. The
|
||||
* secret + `url` (an `otpauth://` URI) are rendered locally as a QR code — the
|
||||
* secret never leaves the browser to a third party. `recoveryCodes[0]` must be
|
||||
* echoed back to `/v1/iam/mfa/setup/enable`.
|
||||
*/
|
||||
export interface MfaSetup {
|
||||
/** IAM MFA type — `app` for TOTP. */
|
||||
readonly mfaType: string
|
||||
/** Base32 TOTP secret. */
|
||||
readonly secret: string
|
||||
/** `otpauth://totp/...` provisioning URI for the authenticator app. */
|
||||
readonly url: string
|
||||
/** One-time recovery codes issued alongside the secret. */
|
||||
readonly recoveryCodes: readonly string[]
|
||||
}
|
||||
|
||||
/** The signed-in user's identity, resolved from the IAM session for MFA setup. */
|
||||
export interface MfaIdentity {
|
||||
readonly owner: string
|
||||
readonly name: string
|
||||
}
|
||||
|
||||
/** A TOTP challenge submission for a user who already enrolled (`NextMfa`). */
|
||||
export interface MfaChallengeRequest {
|
||||
/** IAM MFA type, e.g. `app` (TOTP), `sms`, `email`. */
|
||||
readonly mfaType: string
|
||||
readonly passcode: string
|
||||
readonly clientId: string
|
||||
readonly application: string
|
||||
readonly organization: string
|
||||
readonly redirectUri?: string
|
||||
readonly state?: string
|
||||
readonly codeChallenge?: string
|
||||
readonly codeChallengeMethod?: 'S256' | 'plain'
|
||||
/** Honor the org's "remember this device" window after a successful code. */
|
||||
readonly rememberDevice?: boolean
|
||||
}
|
||||
|
||||
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'
|
||||
/** Social provider name (e.g. "provider-github"); IAM initiates that provider's OAuth. */
|
||||
readonly provider?: string
|
||||
}
|
||||
|
||||
/** A third-party / wallet login provider attached to the application. */
|
||||
export interface ProviderInfo {
|
||||
readonly name: string
|
||||
readonly displayName?: string
|
||||
/** Casdoor provider type, e.g. GitHub, Google, Apple, Web3Onboard. */
|
||||
readonly type?: string
|
||||
/** Casdoor category, e.g. OAuth, Web3, SAML. */
|
||||
readonly category?: string
|
||||
readonly canSignIn?: boolean
|
||||
readonly canSignUp?: boolean
|
||||
}
|
||||
|
||||
/** A sign-in method offered by the application (Password, Verification code, WebAuthn, …). */
|
||||
export interface SigninMethod {
|
||||
readonly name: string
|
||||
readonly rule?: string
|
||||
}
|
||||
|
||||
/** The subset of the application's login config the portal renders from. */
|
||||
export interface AppLoginInfo {
|
||||
readonly name: string
|
||||
readonly displayName?: string
|
||||
readonly providers: ProviderInfo[]
|
||||
readonly signinMethods: SigninMethod[]
|
||||
readonly enablePassword: boolean
|
||||
readonly enableCodeSignin: boolean
|
||||
readonly enableSignUp: boolean
|
||||
}
|
||||
|
||||
/** Passwordless login with an email/SMS verification code. */
|
||||
export interface CodeLoginRequest {
|
||||
/** Destination already sent a code: an email address or E.164 phone number. */
|
||||
readonly dest: string
|
||||
readonly code: string
|
||||
readonly clientId: string
|
||||
readonly application: string
|
||||
readonly organization: string
|
||||
readonly redirectUri?: string
|
||||
readonly state?: string
|
||||
}
|
||||
|
||||
export interface TokenResponse {
|
||||
readonly accessToken: string
|
||||
readonly refreshToken?: string
|
||||
readonly idToken?: string
|
||||
readonly tokenType: string
|
||||
readonly expiresIn?: number
|
||||
readonly scope?: string
|
||||
}
|
||||
|
||||
/** A social/web3 provider enabled on an IAM application. */
|
||||
export interface AppProvider {
|
||||
/** IAM provider record name, e.g. `provider-github`. */
|
||||
readonly name: string
|
||||
/** Normalized provider key passed to the authorize endpoint, e.g. `github`, `google`, `web3`. */
|
||||
readonly key: string
|
||||
/** Whether the provider may be used to sign in. */
|
||||
readonly canSignIn: boolean
|
||||
/** Whether the provider may be used to sign up. */
|
||||
readonly canSignUp: boolean
|
||||
/**
|
||||
* Whether IAM holds a real OAuth credential for this provider (a non-empty,
|
||||
* non-placeholder clientId). The login UI renders ONLY configured providers,
|
||||
* so an unprovisioned button never dead-ends the user — it appears
|
||||
* automatically once real credentials are seeded into IAM. The seed ships
|
||||
* obvious placeholders (`GITHUB_CLIENT_ID_PLACEHOLDER`, `placeholder`), which
|
||||
* read as not-configured.
|
||||
*/
|
||||
readonly configured: boolean
|
||||
/** IAM provider `type`, e.g. `GitHub` / `Google` / `Web3Onboard` (selects the OAuth endpoint). */
|
||||
readonly type: string
|
||||
/** The provider's OAuth client id (used to build the provider redirect; empty when unconfigured). */
|
||||
readonly clientId: string
|
||||
/** Override OAuth scopes, if the provider record sets them. */
|
||||
readonly scopes: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The enabled-auth-methods view of an IAM application, read live from
|
||||
* `/v1/iam/get-app-login`. This is the canonical source of truth for which
|
||||
* buttons to render — it reflects the per-app config in `init_data.json`
|
||||
* (password + GitHub + Google + Web3). The portal renders exactly what IAM
|
||||
* reports enabled, so there is no client/server method drift.
|
||||
*/
|
||||
export interface AppLogin {
|
||||
/** IAM application name (e.g. `hanzo-id`). */
|
||||
readonly application: string
|
||||
/** Owning organization slug. */
|
||||
readonly organization: string
|
||||
/** Email/username + password sign-in is enabled. */
|
||||
readonly enablePassword: boolean
|
||||
/** Self-service signup is enabled. */
|
||||
readonly enableSignUp: boolean
|
||||
/** Email/SMS verification-code sign-in is enabled. */
|
||||
readonly enableCodeSignin: boolean
|
||||
/** Social + Web3 providers enabled on the app, in display order. */
|
||||
readonly providers: readonly AppProvider[]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** Labeled horizontal rule, e.g. "or", separating social from email sign-in. */
|
||||
export function Divider({ label = 'or' }: { label?: string }) {
|
||||
return (
|
||||
<div className="hanzo-id-divider" role="separator" aria-label={label}>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import type { AuthClient } from '../client'
|
||||
|
||||
export interface ForgotFormProps {
|
||||
readonly client: AuthClient
|
||||
readonly onSent?: () => void
|
||||
}
|
||||
|
||||
export function ForgotForm(props: ForgotFormProps) {
|
||||
const { client } = props
|
||||
const [identifier, setIdentifier] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [sent, setSent] = useState(false)
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await client.forgot({
|
||||
identifier,
|
||||
clientId: client.tenant.clientId,
|
||||
organization: client.tenant.orgId,
|
||||
})
|
||||
if (!res.ok) setError(res.error ?? 'send failed')
|
||||
else {
|
||||
setSent(true)
|
||||
props.onSent?.()
|
||||
}
|
||||
} catch (err) {
|
||||
setError(String(err))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (sent) {
|
||||
return <p className="hanzo-id-info">Check your inbox for a reset link.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="hanzo-id-forgot-form" aria-busy={busy}>
|
||||
<label>
|
||||
<span>Email</span>
|
||||
<input type="email" autoComplete="email" value={identifier} onChange={(e) => setIdentifier(e.target.value)} required />
|
||||
</label>
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
<button type="submit" disabled={busy}>{busy ? 'Sending…' : 'Send reset link'}</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
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 codeChallenge?: string
|
||||
readonly codeChallengeMethod?: 'S256' | 'plain'
|
||||
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,
|
||||
codeChallenge: props.codeChallenge,
|
||||
codeChallengeMethod: props.codeChallengeMethod,
|
||||
})
|
||||
if (res.error) {
|
||||
setError(res.error)
|
||||
} else if (res.mfaRequired) {
|
||||
props.onMfaRequired?.(res)
|
||||
} else if (res.redirectUrl) {
|
||||
window.location.href = res.redirectUrl
|
||||
} else {
|
||||
props.onSuccess?.(res)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(String(err))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="hanzo-id-login-form" aria-busy={busy}>
|
||||
<label>
|
||||
<span>Email or username</span>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Password</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
<button type="submit" disabled={busy}>{busy ? 'Signing in…' : 'Sign in'}</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import encodeQR from '@paulmillr/qr'
|
||||
import type { AuthClient } from '../client'
|
||||
import type { MfaIdentity, MfaSetup } from '../types'
|
||||
import { OTPForm } from './OTPForm'
|
||||
|
||||
export interface MfaEnrollFormProps {
|
||||
readonly client: AuthClient
|
||||
/**
|
||||
* Called once the user has verified a TOTP code AND the enrollment is
|
||||
* persisted. The caller continues the session (onboarding or the OIDC
|
||||
* code redirect).
|
||||
*/
|
||||
readonly onComplete: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Forced TOTP enrollment, shown when IAM answers a login with `RequiredMfa`
|
||||
* (org policy requires MFA and the user has none). There is intentionally NO
|
||||
* skip / dismiss control — the only way past this screen is to enroll an
|
||||
* authenticator. The QR is rendered locally from the `otpauth://` URI, so the
|
||||
* TOTP secret never leaves the browser.
|
||||
*
|
||||
* Flow: `getAccount` (resolve identity from the session IAM set with
|
||||
* `RequiredMfa`) → `mfaInitiate` (secret + QR) → user scans → `mfaVerify`
|
||||
* (prove the code) → `mfaEnable` (persist) → `onComplete`.
|
||||
*/
|
||||
export function MfaEnrollForm({ client, onComplete }: MfaEnrollFormProps) {
|
||||
const [identity, setIdentity] = useState<MfaIdentity | null>(null)
|
||||
const [setup, setSetup] = useState<MfaSetup | null>(null)
|
||||
const [fatal, setFatal] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function begin() {
|
||||
try {
|
||||
const id = await client.getAccount()
|
||||
if (!id) throw new Error('Your session could not be resolved. Please sign in again.')
|
||||
const s = await client.mfaInitiate(id)
|
||||
if (cancelled) return
|
||||
setIdentity(id)
|
||||
setSetup(s)
|
||||
} catch (e) {
|
||||
if (!cancelled) setFatal(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
void begin()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [client])
|
||||
|
||||
const qrSvg = useMemo(() => (setup ? encodeQR(setup.url, 'svg') : ''), [setup])
|
||||
|
||||
async function onCode(code: string) {
|
||||
if (!identity || !setup || busy) return
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const verified = await client.mfaVerify({ owner: identity.owner, name: identity.name, secret: setup.secret, passcode: code })
|
||||
if (!verified.ok) {
|
||||
setError(verified.error ?? 'That code did not match. Try the current code from your app.')
|
||||
return
|
||||
}
|
||||
const enabled = await client.mfaEnable({
|
||||
owner: identity.owner,
|
||||
name: identity.name,
|
||||
secret: setup.secret,
|
||||
recoveryCode: setup.recoveryCodes[0] ?? '',
|
||||
})
|
||||
if (!enabled.ok) {
|
||||
setError(enabled.error ?? 'Could not enable two-factor authentication.')
|
||||
return
|
||||
}
|
||||
onComplete()
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (fatal) {
|
||||
return (
|
||||
<div className="hanzo-id-mfa-enroll">
|
||||
<h2>Two-factor setup</h2>
|
||||
<p role="alert" className="hanzo-id-error">{fatal}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!setup) {
|
||||
return (
|
||||
<div className="hanzo-id-mfa-enroll">
|
||||
<h2>Two-factor setup</h2>
|
||||
<p className="lede">Preparing your authenticator…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const recoveryCode = setup.recoveryCodes[0]
|
||||
return (
|
||||
<div className="hanzo-id-mfa-enroll">
|
||||
<h2>Set up two-factor authentication</h2>
|
||||
<p className="lede">
|
||||
Your organization requires two-factor authentication. Scan this QR code with an
|
||||
authenticator app (Google Authenticator, 1Password, Authy), then enter the 6-digit code it
|
||||
shows.
|
||||
</p>
|
||||
<div
|
||||
className="hanzo-id-mfa-qr"
|
||||
role="img"
|
||||
aria-label="TOTP enrollment QR code"
|
||||
// Local SVG from @paulmillr/qr — the otpauth secret never leaves the browser.
|
||||
dangerouslySetInnerHTML={{ __html: qrSvg }}
|
||||
/>
|
||||
<details className="hanzo-id-mfa-manual">
|
||||
<summary>Can't scan? Enter this key manually</summary>
|
||||
<code className="hanzo-id-mfa-secret">{setup.secret}</code>
|
||||
</details>
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
<OTPForm channel="totp" onSubmit={onCode} />
|
||||
{recoveryCode ? (
|
||||
<p className="hanzo-id-mfa-recovery">
|
||||
Save this recovery code somewhere safe — it lets you sign in if you lose your device:
|
||||
<br />
|
||||
<code>{recoveryCode}</code>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { SmsConsentNotice } from './SmsConsent'
|
||||
|
||||
export interface OTPFormProps {
|
||||
readonly onSubmit: (code: string) => void | Promise<void>
|
||||
readonly length?: number
|
||||
readonly channel?: 'totp' | 'sms' | 'email'
|
||||
}
|
||||
|
||||
export function OTPForm(props: OTPFormProps) {
|
||||
const { length = 6, channel = 'totp' } = props
|
||||
const [code, setCode] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (code.length !== length) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await props.onSubmit(code)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const label = channel === 'sms' ? 'SMS code' : channel === 'email' ? 'Email code' : 'Authenticator code'
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="hanzo-id-otp-form" aria-busy={busy}>
|
||||
<label>
|
||||
<span>{label}</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern={`\\d{${length}}`}
|
||||
maxLength={length}
|
||||
autoComplete="one-time-code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, length))}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{channel === 'sms' ? <SmsConsentNotice /> : null}
|
||||
<button type="submit" disabled={busy || code.length !== length}>{busy ? 'Verifying…' : 'Verify'}</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { AuthClient } from '../client'
|
||||
import type { ProviderInfo } from '../types'
|
||||
|
||||
interface Meta {
|
||||
readonly label: string
|
||||
/** stable brand token used for the CSS class + data attribute (for icon styling) */
|
||||
readonly brand: string
|
||||
}
|
||||
|
||||
// Keyed by a normalized provider token (type or name, lowercased, alnum-only,
|
||||
// "provider" prefix stripped). Falls back to a generic label for anything new.
|
||||
const META: Record<string, Meta> = {
|
||||
google: { label: 'Continue with Google', brand: 'google' },
|
||||
github: { label: 'Continue with GitHub', brand: 'github' },
|
||||
apple: { label: 'Continue with Apple', brand: 'apple' },
|
||||
facebook: { label: 'Continue with Facebook', brand: 'facebook' },
|
||||
web3: { label: 'Connect wallet', brand: 'web3' },
|
||||
web3onboard: { label: 'Connect wallet', brand: 'web3' },
|
||||
metamask: { label: 'Connect wallet', brand: 'web3' },
|
||||
}
|
||||
|
||||
function metaFor(p: ProviderInfo): Meta {
|
||||
const key = (p.type || p.name || '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '')
|
||||
.replace(/^provider/, '')
|
||||
return META[key] ?? { label: `Continue with ${p.displayName || p.name}`, brand: 'generic' }
|
||||
}
|
||||
|
||||
export interface ProviderButtonsProps {
|
||||
readonly client: AuthClient
|
||||
readonly providers: ProviderInfo[]
|
||||
readonly mode: 'login' | 'signup'
|
||||
readonly redirectUri?: string
|
||||
readonly state?: string
|
||||
readonly clientIdOverride?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one button per social/wallet provider attached to the application.
|
||||
* Each links to the IAM authorize endpoint with `provider=<name>`, which
|
||||
* initiates that provider's OAuth and returns to `${publicOrigin}/callback`.
|
||||
* The set is driven by the live app config (AuthClient.appLogin) — no
|
||||
* hardcoded provider list — so enabling a provider in IAM surfaces it here.
|
||||
*/
|
||||
export function ProviderButtons(props: ProviderButtonsProps) {
|
||||
const { client, providers, mode } = props
|
||||
const usable = providers.filter((p) =>
|
||||
mode === 'signup' ? p.canSignUp !== false : p.canSignIn !== false,
|
||||
)
|
||||
if (usable.length === 0) return null
|
||||
|
||||
const redirectUri = props.redirectUri ?? `${client.tenant.publicOrigin}/callback`
|
||||
return (
|
||||
<div className="hanzo-id-providers">
|
||||
{usable.map((p) => {
|
||||
const m = metaFor(p)
|
||||
const href = client.authorize({
|
||||
clientId: props.clientIdOverride ?? client.tenant.clientId,
|
||||
redirectUri,
|
||||
state: props.state ?? mode,
|
||||
provider: p.name,
|
||||
})
|
||||
return (
|
||||
<a
|
||||
key={p.name}
|
||||
className={`hanzo-id-provider-btn hanzo-id-provider-${m.brand}`}
|
||||
href={href}
|
||||
data-provider={m.brand}
|
||||
>
|
||||
{m.label}
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
<div className="hanzo-id-or">
|
||||
<span>or</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import type { AuthClient } from '../client'
|
||||
|
||||
export interface SignupFormProps {
|
||||
readonly client: AuthClient
|
||||
readonly inviteCode?: string
|
||||
readonly onSuccess?: () => void
|
||||
}
|
||||
|
||||
export function SignupForm(props: SignupFormProps) {
|
||||
const { client } = props
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await client.signup({
|
||||
email,
|
||||
password,
|
||||
clientId: client.tenant.clientId,
|
||||
application: client.tenant.appName,
|
||||
organization: client.tenant.orgId,
|
||||
inviteCode: props.inviteCode,
|
||||
})
|
||||
if (res.error) setError(res.error)
|
||||
else if (res.redirectUrl) window.location.href = res.redirectUrl
|
||||
else props.onSuccess?.()
|
||||
} catch (err) {
|
||||
setError(String(err))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="hanzo-id-signup-form" aria-busy={busy}>
|
||||
<label>
|
||||
<span>Email</span>
|
||||
<input type="email" autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||
</label>
|
||||
<label>
|
||||
<span>Password</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
minLength={12}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
<button type="submit" disabled={busy}>{busy ? 'Creating account…' : 'Create account'}</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Canonical A2P 10DLC consent copy. This EXACT disclosure is reused at every
|
||||
// point where Hanzo collects or uses a phone number for messaging. It MUST stay
|
||||
// verbatim-identical to the public opt-in page (hanzo.ai/sms-opt-in,
|
||||
// `SMS_CONSENT_TEXT`) and to the IAM phone-login UI — Twilio / carrier campaign
|
||||
// review compares the wording across surfaces. One string, reused everywhere.
|
||||
export const SMS_CONSENT_TEXT =
|
||||
'I agree to receive text messages (SMS) from Hanzo AI at the number provided, ' +
|
||||
'including one-time passcodes and two-factor authentication, account and security ' +
|
||||
'alerts, and transactional notifications. Message frequency varies. Message and data ' +
|
||||
'rates may apply. Reply STOP to opt out at any time, or HELP for help. Consent is not ' +
|
||||
'a condition of any purchase.'
|
||||
|
||||
const TERMS_URL = 'https://hanzo.ai/terms'
|
||||
const PRIVACY_URL = 'https://hanzo.ai/privacy'
|
||||
|
||||
/**
|
||||
* SMS consent disclosure shown beneath any phone/SMS surface (disclosure-only,
|
||||
* no checkbox — the portal's SMS step is reached only after the user already
|
||||
* provided/opted-in their number in IAM, and after a code was sent).
|
||||
*
|
||||
* For a phone-number COLLECTION surface that requires affirmative opt-in (A2P),
|
||||
* gate the submit on a checkbox and reuse {@link SMS_CONSENT_TEXT} — see the IAM
|
||||
* SignupPage `SmsConsentCheckbox`. The portal does not yet render its own phone
|
||||
* field (collection happens in the IAM-hosted UI), so only the notice is used
|
||||
* here today.
|
||||
*/
|
||||
export function SmsConsentNotice() {
|
||||
return (
|
||||
<div className="hanzo-id-sms-consent" role="note">
|
||||
<p>{SMS_CONSENT_TEXT}</p>
|
||||
<p className="hanzo-id-sms-consent-links">
|
||||
By continuing, you agree to our{' '}
|
||||
<a href={TERMS_URL} target="_blank" rel="noreferrer">Terms of Service</a> and{' '}
|
||||
<a href={PRIVACY_URL} target="_blank" rel="noreferrer">Privacy Policy</a>.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
import type { AuthClient } from '../client'
|
||||
import type { AppProvider } from '../types'
|
||||
import { createIam } from '../iam'
|
||||
import { startProviderLogin, isHoppableProvider } from '../social'
|
||||
import { GitHubIcon, GoogleIcon, WalletIcon } from './icons'
|
||||
import { Divider } from './Divider'
|
||||
|
||||
/**
|
||||
* Social + Web3 sign-in buttons.
|
||||
*
|
||||
* The enabled set is read live from `/v1/iam/get-app-login` (via
|
||||
* `client.getAppLogin()`) — the canonical source of truth that mirrors the
|
||||
* per-app provider config in `init_data.json`. We render ONLY providers IAM
|
||||
* holds real credentials for (`AppProvider.configured`); a provider seeded with
|
||||
* placeholder creds is hidden so its button never dead-ends, and reappears once
|
||||
* real creds land. When the config is unreadable we render none.
|
||||
*
|
||||
* Each OAuth button drives the provider "hop" (`startProviderLogin`) — it
|
||||
* redirects straight to GitHub/Google with a Casdoor-compatible state that
|
||||
* round-trips the original authorize request, so the IAM backend's `/callback`
|
||||
* exchange completes it. (Web3/wallet falls back to the `@hanzo/iam` redirect.)
|
||||
*/
|
||||
export interface SocialButtonsProps {
|
||||
readonly client: AuthClient
|
||||
/** Override the OAuth client_id (e.g. a downstream app's id). */
|
||||
readonly clientIdOverride?: string
|
||||
/** "signin" (default) or "signup" — only changes button copy. */
|
||||
readonly intent?: 'signin' | 'signup'
|
||||
/**
|
||||
* Downstream app's `redirect_uri`, if this portal is mid-flow for another
|
||||
* app. Social/Web3 sign-in always returns to the portal's own `/callback`
|
||||
* (the SDK's fixed redirectUri), so we stash this target before the
|
||||
* redirect; `Callback` reads it back and forwards the tokens there. Absent
|
||||
* → a bare portal sign-in that lands on onboarding.
|
||||
*/
|
||||
readonly postLoginRedirect?: string
|
||||
}
|
||||
|
||||
interface ProviderMeta {
|
||||
readonly key: string
|
||||
readonly label: string
|
||||
readonly Icon: ComponentType<SVGProps<SVGSVGElement>>
|
||||
}
|
||||
|
||||
/** Display metadata for the providers the portal knows how to render. */
|
||||
const PROVIDER_META: Record<string, ProviderMeta> = {
|
||||
github: { key: 'github', label: 'GitHub', Icon: GitHubIcon },
|
||||
google: { key: 'google', label: 'Google', Icon: GoogleIcon },
|
||||
web3: { key: 'web3', label: 'Wallet', Icon: WalletIcon },
|
||||
}
|
||||
|
||||
/** Canonical render order. */
|
||||
const ORDER = ['github', 'google', 'web3']
|
||||
|
||||
interface Resolved {
|
||||
/** IAM application name (for the provider-hop state). */
|
||||
readonly application: string
|
||||
/** Configured + renderable providers, keyed by their normalized key. */
|
||||
readonly providers: Record<string, AppProvider>
|
||||
}
|
||||
|
||||
export function SocialButtons({
|
||||
client,
|
||||
clientIdOverride,
|
||||
intent = 'signin',
|
||||
postLoginRedirect,
|
||||
}: SocialButtonsProps) {
|
||||
const [resolved, setResolved] = useState<Resolved | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
client
|
||||
.getAppLogin(clientIdOverride)
|
||||
.then((app) => {
|
||||
if (cancelled) return
|
||||
if (!app) {
|
||||
// Can't read the app config → render no social rather than risk a
|
||||
// dead-end button. Password / email-code still render.
|
||||
setResolved({ application: '', providers: {} })
|
||||
return
|
||||
}
|
||||
const want = intent === 'signup' ? (p: AppProvider) => p.canSignUp : (p: AppProvider) => p.canSignIn
|
||||
// Render ONLY providers IAM actually holds credentials for. A provider
|
||||
// with placeholder/empty creds would dead-end the OAuth redirect, so we
|
||||
// hide it; it reappears automatically once real creds are seeded.
|
||||
const providers: Record<string, AppProvider> = {}
|
||||
for (const p of app.providers) {
|
||||
if (want(p) && p.configured && p.key in PROVIDER_META) providers[p.key] = p
|
||||
}
|
||||
setResolved({ application: app.application, providers })
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setResolved({ application: '', providers: {} })
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [client, clientIdOverride, intent])
|
||||
|
||||
if (resolved === null) return null // resolving — render nothing rather than flicker
|
||||
const ordered = ORDER.filter((k) => k in resolved.providers)
|
||||
if (ordered.length === 0) return null
|
||||
|
||||
const verb = intent === 'signup' ? 'Sign up' : 'Continue'
|
||||
|
||||
function start(provider: AppProvider) {
|
||||
setError(null)
|
||||
// Persist the downstream target across the IAM round-trip; `Callback`
|
||||
// reads it back and forwards tokens there (else lands on onboarding).
|
||||
if (postLoginRedirect) sessionStorage.setItem('post_login_redirect', postLoginRedirect)
|
||||
else sessionStorage.removeItem('post_login_redirect')
|
||||
const method = intent === 'signup' ? 'signup' : 'signin'
|
||||
// OAuth providers (github/google) hop straight to the provider; wallet/web3
|
||||
// falls back to the @hanzo/iam redirect.
|
||||
if (isHoppableProvider(provider.type)) {
|
||||
startProviderLogin(
|
||||
{
|
||||
application: resolved!.application,
|
||||
providerName: provider.name,
|
||||
type: provider.type,
|
||||
clientId: provider.clientId,
|
||||
scopes: provider.scopes,
|
||||
method,
|
||||
},
|
||||
// The shared OAuth client is registered against the IAM backend's
|
||||
// /callback (not this brand host), so the hop must return there or the
|
||||
// provider rejects the redirect_uri. Catalog-driven; defaults to host.
|
||||
client.tenant.oauthCallbackOrigin,
|
||||
)
|
||||
return
|
||||
}
|
||||
const iam = createIam(client.tenant, clientIdOverride)
|
||||
iam.signinRedirect({ additionalParams: { provider: provider.key } }).catch((e) => {
|
||||
setError(String(e))
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="hanzo-id-social">
|
||||
{ordered.map((k) => {
|
||||
const meta = PROVIDER_META[k]
|
||||
const { Icon } = meta
|
||||
const provider = resolved.providers[k]!
|
||||
return (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
className="hanzo-id-social-btn"
|
||||
data-provider={k}
|
||||
onClick={() => start(provider)}
|
||||
>
|
||||
<Icon />
|
||||
<span>{verb} with {meta.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
</div>
|
||||
{/* The "or" separator belongs WITH the social block — render it only when
|
||||
there are buttons, so it never dangles above the password form when
|
||||
no providers are configured. */}
|
||||
<Divider />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Minimal inline provider marks. Brand-neutral, currentColor-driven, no
|
||||
* external icon dependency. One 18px glyph per supported sign-in provider.
|
||||
*/
|
||||
import type { SVGProps } from 'react'
|
||||
|
||||
const base = (props: SVGProps<SVGSVGElement>) => ({
|
||||
width: 18,
|
||||
height: 18,
|
||||
viewBox: '0 0 24 24',
|
||||
'aria-hidden': true,
|
||||
focusable: false as const,
|
||||
...props,
|
||||
})
|
||||
|
||||
export function GitHubIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...base(props)} fill="currentColor">
|
||||
<path d="M12 .5C5.73.5.5 5.73.5 12a11.5 11.5 0 0 0 7.86 10.92c.58.1.79-.25.79-.56v-2c-3.2.7-3.88-1.37-3.88-1.37-.53-1.34-1.3-1.7-1.3-1.7-1.05-.72.08-.7.08-.7 1.17.08 1.78 1.2 1.78 1.2 1.04 1.78 2.73 1.27 3.4.97.1-.75.4-1.27.73-1.56-2.56-.29-5.26-1.28-5.26-5.7 0-1.26.45-2.29 1.2-3.1-.12-.3-.52-1.48.11-3.08 0 0 .97-.31 3.18 1.18a11 11 0 0 1 5.8 0c2.2-1.5 3.17-1.18 3.17-1.18.63 1.6.23 2.78.11 3.08.75.81 1.2 1.84 1.2 3.1 0 4.43-2.7 5.4-5.28 5.69.42.36.79 1.07.79 2.16v3.2c0 .31.21.67.8.56A11.5 11.5 0 0 0 23.5 12C23.5 5.73 18.27.5 12 .5Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function GoogleIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...base(props)}>
|
||||
<path fill="#4285F4" d="M23.52 12.27c0-.82-.07-1.6-.21-2.36H12v4.46h6.46a5.52 5.52 0 0 1-2.4 3.62v3h3.88c2.27-2.09 3.58-5.17 3.58-8.72Z" />
|
||||
<path fill="#34A853" d="M12 24c3.24 0 5.96-1.08 7.94-2.91l-3.88-3c-1.08.72-2.45 1.15-4.06 1.15-3.12 0-5.77-2.11-6.71-4.95H1.28v3.1A12 12 0 0 0 12 24Z" />
|
||||
<path fill="#FBBC05" d="M5.29 14.29A7.2 7.2 0 0 1 4.91 12c0-.8.14-1.57.38-2.29v-3.1H1.28A12 12 0 0 0 0 12c0 1.94.46 3.77 1.28 5.39l4.01-3.1Z" />
|
||||
<path fill="#EA4335" d="M12 4.76c1.76 0 3.34.61 4.58 1.8l3.43-3.43A11.99 11.99 0 0 0 12 0 12 12 0 0 0 1.28 6.61l4.01 3.1C6.23 6.87 8.88 4.76 12 4.76Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function WalletIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...base(props)} fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 7a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v1H5a2 2 0 0 0-2 2V7Z" />
|
||||
<path d="M3 9a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V9Z" />
|
||||
<circle cx="16.5" cy="13" r="1.25" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { LoginForm } from './LoginForm'
|
||||
export { SignupForm } from './SignupForm'
|
||||
export { ForgotForm } from './ForgotForm'
|
||||
export { OTPForm } from './OTPForm'
|
||||
export { MfaEnrollForm, type MfaEnrollFormProps } from './MfaEnrollForm'
|
||||
export { SmsConsentNotice, SMS_CONSENT_TEXT } from './SmsConsent'
|
||||
export { SocialButtons, type SocialButtonsProps } from './SocialButtons'
|
||||
export { Divider } from './Divider'
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@hanzo/id-idv",
|
||||
"version": "0.1.0",
|
||||
"description": "Pluggable identity verification (KYC / KYB / liveness). Provider-agnostic — wires Persona, Onfido, Veriff, Sumsub, or any custom backend behind a single React surface.",
|
||||
"license": "BSD-3-Clause",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./providers/persona": "./src/providers/persona.ts",
|
||||
"./providers/onfido": "./src/providers/onfido.ts",
|
||||
"./providers/veriff": "./src/providers/veriff.ts",
|
||||
"./providers/stub": "./src/providers/stub.ts",
|
||||
"./flow": "./src/ui/IDVFlow.tsx",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": ["src"],
|
||||
"scripts": {
|
||||
"tc": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/id-shared": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=19",
|
||||
"react-dom": ">=19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './provider'
|
||||
export * from './session'
|
||||
export { IDVFlow } from './ui/IDVFlow'
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* IDV (identity verification) provider contract.
|
||||
*
|
||||
* A provider knows how to:
|
||||
* 1. mint a verification session for a given subject + flow,
|
||||
* 2. resume an existing session by id,
|
||||
* 3. report the terminal status (passed / failed / needs_review / expired).
|
||||
*
|
||||
* The portal stays agnostic of the actual vendor (Persona, Onfido, Veriff,
|
||||
* Sumsub, Stripe Identity, a custom backend, or the in-process stub for
|
||||
* dev). Wire whichever in `apps/web/src/main.tsx` via `setProvider(...)`.
|
||||
*/
|
||||
|
||||
export type IDVFlowKind =
|
||||
| 'kyc-basic' // ID doc + selfie
|
||||
| 'kyc-enhanced' // + proof of address
|
||||
| 'kyb' // business onboarding
|
||||
| 'liveness' // selfie-only liveness check
|
||||
| 'address-proof'
|
||||
|
||||
export type IDVStatus =
|
||||
| 'pending'
|
||||
| 'in_progress'
|
||||
| 'awaiting_review'
|
||||
| 'passed'
|
||||
| 'failed'
|
||||
| 'expired'
|
||||
| 'cancelled'
|
||||
|
||||
export interface IDVSubject {
|
||||
/** Stable subject identifier (typically the IAM user id). */
|
||||
readonly subjectId: string
|
||||
/** Tenant org slug (for multi-tenant providers). */
|
||||
readonly orgId: string
|
||||
/** Email + display name carried through for vendor pre-fill. */
|
||||
readonly email?: string
|
||||
readonly displayName?: string
|
||||
}
|
||||
|
||||
export interface IDVSessionInit {
|
||||
readonly subject: IDVSubject
|
||||
readonly flow: IDVFlowKind
|
||||
/** Where to send the user after the IDV widget completes. */
|
||||
readonly redirectUri: string
|
||||
/** Optional vendor-specific metadata pass-through. */
|
||||
readonly metadata?: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
export interface IDVSessionHandle {
|
||||
readonly id: string
|
||||
readonly provider: string
|
||||
/** URL the browser should redirect the user to (hosted vendor flow). */
|
||||
readonly hostedUrl?: string
|
||||
/**
|
||||
* Inline embed config (when the vendor supports an in-app web SDK). The
|
||||
* IDV UI mounts an iframe / web component using this token + config.
|
||||
*/
|
||||
readonly embed?: {
|
||||
readonly sdkUrl: string
|
||||
readonly token: string
|
||||
readonly env?: 'sandbox' | 'production'
|
||||
}
|
||||
}
|
||||
|
||||
export interface IDVStatusReport {
|
||||
readonly id: string
|
||||
readonly status: IDVStatus
|
||||
readonly reason?: string
|
||||
/** Provider-specific result blob (audit trail). */
|
||||
readonly raw?: Readonly<Record<string, unknown>>
|
||||
}
|
||||
|
||||
export interface IDVProvider {
|
||||
readonly id: string
|
||||
start(init: IDVSessionInit): Promise<IDVSessionHandle>
|
||||
status(sessionId: string): Promise<IDVStatusReport>
|
||||
cancel?(sessionId: string): Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { IDVProvider, IDVSessionHandle, IDVSessionInit, IDVStatusReport } from '../provider'
|
||||
|
||||
export interface OnfidoOptions {
|
||||
readonly apiToken: string
|
||||
readonly region?: 'eu' | 'us' | 'ca'
|
||||
readonly workflowId?: string
|
||||
readonly fetchImpl?: typeof fetch
|
||||
}
|
||||
|
||||
export function createOnfidoProvider(opts: OnfidoOptions): IDVProvider {
|
||||
const region = opts.region ?? 'eu'
|
||||
const base = `https://api.${region}.onfido.com/v3.6`
|
||||
const f = opts.fetchImpl ?? fetch
|
||||
return {
|
||||
id: 'onfido',
|
||||
async start(init: IDVSessionInit): Promise<IDVSessionHandle> {
|
||||
// Create an applicant
|
||||
const applicantRes = await f(`${base}/applicants`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Token token=${opts.apiToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
first_name: init.subject.displayName?.split(' ')[0] ?? 'Applicant',
|
||||
last_name: init.subject.displayName?.split(' ').slice(1).join(' ') || init.subject.subjectId,
|
||||
email: init.subject.email,
|
||||
external_id: init.subject.subjectId,
|
||||
}),
|
||||
})
|
||||
if (!applicantRes.ok) throw new Error(`onfido applicant failed: ${applicantRes.status}`)
|
||||
const applicant = (await applicantRes.json()) as { id: string }
|
||||
|
||||
// Generate SDK token for the web SDK
|
||||
const tokenRes = await f(`${base}/sdk_token`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Token token=${opts.apiToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
applicant_id: applicant.id,
|
||||
referrer: '*://*.hanzo.id/*',
|
||||
}),
|
||||
})
|
||||
if (!tokenRes.ok) throw new Error(`onfido sdk_token failed: ${tokenRes.status}`)
|
||||
const { token } = (await tokenRes.json()) as { token: string }
|
||||
|
||||
return {
|
||||
id: applicant.id,
|
||||
provider: 'onfido',
|
||||
embed: {
|
||||
sdkUrl: 'https://assets.onfido.com/web-sdk-releases/14.0.0/onfido.min.js',
|
||||
token,
|
||||
},
|
||||
}
|
||||
},
|
||||
async status(sessionId: string): Promise<IDVStatusReport> {
|
||||
const res = await f(`${base}/checks?applicant_id=${sessionId}`, {
|
||||
headers: { Authorization: `Token token=${opts.apiToken}` },
|
||||
})
|
||||
if (!res.ok) throw new Error(`onfido check status failed: ${res.status}`)
|
||||
const body = (await res.json()) as { checks?: Array<{ status: string; result?: string }> }
|
||||
const latest = body.checks?.[0]
|
||||
if (!latest) return { id: sessionId, status: 'pending' }
|
||||
const status: IDVStatusReport['status'] =
|
||||
latest.status === 'complete'
|
||||
? latest.result === 'clear'
|
||||
? 'passed'
|
||||
: 'failed'
|
||||
: 'in_progress'
|
||||
return { id: sessionId, status, raw: latest as unknown as Readonly<Record<string, unknown>> }
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { IDVProvider, IDVSessionHandle, IDVSessionInit, IDVStatusReport } from '../provider'
|
||||
|
||||
/**
|
||||
* Persona (https://withpersona.com) IDV adapter.
|
||||
*
|
||||
* Pre-creates an Inquiry via the Persona REST API, returns the hosted
|
||||
* URL for redirect. Status is read by polling the Inquiry resource.
|
||||
*/
|
||||
export interface PersonaOptions {
|
||||
readonly templateId: string
|
||||
readonly apiKey: string
|
||||
/** Sandbox or production environment. */
|
||||
readonly environment?: 'sandbox' | 'production'
|
||||
/** Override fetch impl (testing). */
|
||||
readonly fetchImpl?: typeof fetch
|
||||
}
|
||||
|
||||
export function createPersonaProvider(opts: PersonaOptions): IDVProvider {
|
||||
const base = 'https://withpersona.com/api/v1'
|
||||
const f = opts.fetchImpl ?? fetch
|
||||
return {
|
||||
id: 'persona',
|
||||
async start(init: IDVSessionInit): Promise<IDVSessionHandle> {
|
||||
const res = await f(`${base}/inquiries`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${opts.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Persona-Version': '2023-01-05',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
attributes: {
|
||||
'inquiry-template-id': opts.templateId,
|
||||
'reference-id': init.subject.subjectId,
|
||||
fields: { email: init.subject.email },
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
if (!res.ok) throw new Error(`persona start failed: ${res.status}`)
|
||||
const body = (await res.json()) as { data: { id: string; attributes: { 'session-token'?: string } } }
|
||||
const id = body.data.id
|
||||
const token = body.data.attributes['session-token']
|
||||
return {
|
||||
id,
|
||||
provider: 'persona',
|
||||
embed: token
|
||||
? {
|
||||
sdkUrl: 'https://cdn.withpersona.com/dist/persona-v5.1.0.js',
|
||||
token,
|
||||
env: opts.environment ?? 'sandbox',
|
||||
}
|
||||
: undefined,
|
||||
hostedUrl: `https://withpersona.com/verify?inquiry-id=${id}&redirect-uri=${encodeURIComponent(init.redirectUri)}`,
|
||||
}
|
||||
},
|
||||
async status(sessionId: string): Promise<IDVStatusReport> {
|
||||
const res = await f(`${base}/inquiries/${sessionId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${opts.apiKey}`,
|
||||
'Persona-Version': '2023-01-05',
|
||||
},
|
||||
})
|
||||
if (!res.ok) throw new Error(`persona status failed: ${res.status}`)
|
||||
const body = (await res.json()) as { data: { attributes: { status: string } } }
|
||||
return { id: sessionId, status: mapStatus(body.data.attributes.status), raw: body.data as unknown as Readonly<Record<string, unknown>> }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function mapStatus(s: string): IDVStatusReport['status'] {
|
||||
switch (s) {
|
||||
case 'completed':
|
||||
case 'approved':
|
||||
return 'passed'
|
||||
case 'failed':
|
||||
case 'declined':
|
||||
return 'failed'
|
||||
case 'needs_review':
|
||||
return 'awaiting_review'
|
||||
case 'expired':
|
||||
return 'expired'
|
||||
case 'created':
|
||||
case 'pending':
|
||||
return 'pending'
|
||||
default:
|
||||
return 'in_progress'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { IDVProvider, IDVSessionHandle, IDVSessionInit, IDVStatusReport } from '../provider'
|
||||
|
||||
/**
|
||||
* In-memory IDV stub. Always passes after a short delay. For local dev only
|
||||
* — never use in any environment that resolves user identity for real.
|
||||
*/
|
||||
export function createStubProvider(): IDVProvider {
|
||||
const sessions = new Map<string, { startedAt: number; init: IDVSessionInit }>()
|
||||
|
||||
return {
|
||||
id: 'stub',
|
||||
async start(init: IDVSessionInit): Promise<IDVSessionHandle> {
|
||||
const id = `stub-${crypto.randomUUID()}`
|
||||
sessions.set(id, { startedAt: Date.now(), init })
|
||||
return {
|
||||
id,
|
||||
provider: 'stub',
|
||||
hostedUrl: `${init.redirectUri}?session=${id}&status=passed`,
|
||||
}
|
||||
},
|
||||
async status(sessionId: string): Promise<IDVStatusReport> {
|
||||
const s = sessions.get(sessionId)
|
||||
if (!s) return { id: sessionId, status: 'expired' }
|
||||
const elapsed = Date.now() - s.startedAt
|
||||
return {
|
||||
id: sessionId,
|
||||
status: elapsed > 2000 ? 'passed' : 'in_progress',
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { IDVProvider, IDVSessionHandle, IDVSessionInit, IDVStatusReport } from '../provider'
|
||||
|
||||
export interface VeriffOptions {
|
||||
readonly apiKey: string
|
||||
readonly secret: string
|
||||
readonly baseUrl?: string
|
||||
readonly fetchImpl?: typeof fetch
|
||||
}
|
||||
|
||||
export function createVeriffProvider(opts: VeriffOptions): IDVProvider {
|
||||
const base = opts.baseUrl ?? 'https://stationapi.veriff.com/v1'
|
||||
const f = opts.fetchImpl ?? fetch
|
||||
return {
|
||||
id: 'veriff',
|
||||
async start(init: IDVSessionInit): Promise<IDVSessionHandle> {
|
||||
const res = await f(`${base}/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-AUTH-CLIENT': opts.apiKey, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
verification: {
|
||||
callback: init.redirectUri,
|
||||
person: { firstName: init.subject.displayName ?? '', lastName: init.subject.subjectId },
|
||||
vendorData: init.subject.subjectId,
|
||||
},
|
||||
}),
|
||||
})
|
||||
if (!res.ok) throw new Error(`veriff session failed: ${res.status}`)
|
||||
const body = (await res.json()) as { verification: { id: string; url: string } }
|
||||
return {
|
||||
id: body.verification.id,
|
||||
provider: 'veriff',
|
||||
hostedUrl: body.verification.url,
|
||||
}
|
||||
},
|
||||
async status(sessionId: string): Promise<IDVStatusReport> {
|
||||
const res = await f(`${base}/sessions/${sessionId}/decision`, {
|
||||
headers: { 'X-AUTH-CLIENT': opts.apiKey },
|
||||
})
|
||||
if (!res.ok) throw new Error(`veriff decision failed: ${res.status}`)
|
||||
const body = (await res.json()) as { verification?: { status?: string; code?: number } }
|
||||
const status: IDVStatusReport['status'] =
|
||||
body.verification?.status === 'approved'
|
||||
? 'passed'
|
||||
: body.verification?.status === 'declined'
|
||||
? 'failed'
|
||||
: body.verification?.status === 'resubmission_requested'
|
||||
? 'awaiting_review'
|
||||
: body.verification?.status === 'expired'
|
||||
? 'expired'
|
||||
: 'in_progress'
|
||||
return { id: sessionId, status, raw: body as unknown as Readonly<Record<string, unknown>> }
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { IDVProvider, IDVSessionHandle, IDVSessionInit, IDVStatusReport } from './provider'
|
||||
|
||||
/** Registry of installed IDV providers, keyed by provider id. */
|
||||
const registry = new Map<string, IDVProvider>()
|
||||
let active: IDVProvider | null = null
|
||||
|
||||
export function registerProvider(p: IDVProvider): void {
|
||||
registry.set(p.id, p)
|
||||
if (!active) active = p
|
||||
}
|
||||
|
||||
export function setActiveProvider(id: string): void {
|
||||
const p = registry.get(id)
|
||||
if (!p) throw new Error(`IDV provider not registered: ${id}`)
|
||||
active = p
|
||||
}
|
||||
|
||||
export function getActiveProvider(): IDVProvider {
|
||||
if (!active) throw new Error('No IDV provider registered. Call registerProvider() at startup.')
|
||||
return active
|
||||
}
|
||||
|
||||
export async function startSession(init: IDVSessionInit): Promise<IDVSessionHandle> {
|
||||
return getActiveProvider().start(init)
|
||||
}
|
||||
|
||||
export async function getStatus(sessionId: string): Promise<IDVStatusReport> {
|
||||
return getActiveProvider().status(sessionId)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getActiveProvider } from '../session'
|
||||
import type { IDVFlowKind, IDVSessionHandle, IDVStatusReport, IDVSubject } from '../provider'
|
||||
|
||||
export interface IDVFlowProps {
|
||||
readonly subject: IDVSubject
|
||||
readonly flow: IDVFlowKind
|
||||
/** Where to send the user after the flow completes. */
|
||||
readonly redirectUri: string
|
||||
/** Called as the status changes. */
|
||||
readonly onChange?: (s: IDVStatusReport) => void
|
||||
/** Called once the status is terminal. */
|
||||
readonly onTerminal?: (s: IDVStatusReport) => void
|
||||
}
|
||||
|
||||
const TERMINAL = new Set<IDVStatusReport['status']>(['passed', 'failed', 'expired', 'cancelled'])
|
||||
|
||||
export function IDVFlow(props: IDVFlowProps) {
|
||||
const [handle, setHandle] = useState<IDVSessionHandle | null>(null)
|
||||
const [status, setStatus] = useState<IDVStatusReport | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let stopped = false
|
||||
let poll: ReturnType<typeof setInterval> | null = null
|
||||
const provider = getActiveProvider()
|
||||
provider
|
||||
.start({ subject: props.subject, flow: props.flow, redirectUri: props.redirectUri })
|
||||
.then((h) => {
|
||||
if (stopped) return
|
||||
setHandle(h)
|
||||
// If hosted, redirect immediately
|
||||
if (h.hostedUrl && !h.embed) {
|
||||
window.location.href = h.hostedUrl
|
||||
return
|
||||
}
|
||||
// Otherwise poll for status while the embed is rendered
|
||||
poll = setInterval(async () => {
|
||||
try {
|
||||
const s = await provider.status(h.id)
|
||||
if (stopped) return
|
||||
setStatus(s)
|
||||
props.onChange?.(s)
|
||||
if (TERMINAL.has(s.status)) {
|
||||
if (poll) clearInterval(poll)
|
||||
props.onTerminal?.(s)
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
}
|
||||
}, 4000)
|
||||
})
|
||||
.catch((e) => setError(String(e)))
|
||||
return () => {
|
||||
stopped = true
|
||||
if (poll) clearInterval(poll)
|
||||
}
|
||||
// props.flow / props.subject changes trigger a new session; the deps list
|
||||
// intentionally tracks the meaningful identity bits.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [props.subject.subjectId, props.flow, props.redirectUri])
|
||||
|
||||
if (error) return <p role="alert" className="hanzo-id-error">{error}</p>
|
||||
if (!handle) return <p>Starting verification…</p>
|
||||
if (handle.embed) {
|
||||
return (
|
||||
<div className="hanzo-id-idv-embed" data-provider={handle.provider}>
|
||||
<iframe
|
||||
title="Identity verification"
|
||||
src={handle.embed.sdkUrl}
|
||||
// The web SDKs use postMessage to receive the token — apps that
|
||||
// need full SDK init should override IDVFlow with their own
|
||||
// provider-specific mount. This iframe is the safe default.
|
||||
style={{ width: '100%', minHeight: 600, border: 0 }}
|
||||
/>
|
||||
{status ? <p className="hanzo-id-idv-status">Status: {status.status}</p> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <p>Redirecting…</p>
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@hanzo/id-onboarding",
|
||||
"version": "0.1.0",
|
||||
"description": "Post-login onboarding for the Hanzo ID portal: choose/create org → optional project → optional wallet link. White-labeled by host. Domain / service / UI split.",
|
||||
"license": "BSD-3-Clause",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./service": "./src/service/onboarding.ts",
|
||||
"./flow": "./src/ui/OnboardingFlow.tsx",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": ["src", "!src/**/*.test.ts"],
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
"tc": "tsc --noEmit",
|
||||
"test": "node --test --experimental-strip-types 'src/**/*.test.ts'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/id-shared": "workspace:*",
|
||||
"@hanzo/iam": "^0.11.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=19",
|
||||
"react-dom": ">=19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Onboarding domain types — React-free, serializable.
|
||||
*
|
||||
* The post-login onboarding is a three-step linear flow:
|
||||
*
|
||||
* 1. org — choose an existing org the user already belongs to, or
|
||||
* create a new one. Required (every account needs a home org).
|
||||
* 2. project — create a first project inside the chosen org. Optional
|
||||
* (skippable; the org ships with a default project).
|
||||
* 3. wallet — link a Web3 wallet to the account. Optional (skippable).
|
||||
*
|
||||
* The flow is declared as data here so the UI layer can render it without
|
||||
* the domain importing React. `OnboardingService` (the service layer) does
|
||||
* the actual IAM writes; this module only describes the shape of the flow
|
||||
* and its accumulated state.
|
||||
*/
|
||||
|
||||
/** Identifier for each step in the onboarding flow. */
|
||||
export type StepId = 'org' | 'project' | 'wallet' | 'done'
|
||||
|
||||
/** A step's place in the linear flow. */
|
||||
export interface StepDesc {
|
||||
readonly id: StepId
|
||||
/** Heading shown at the top of the step. */
|
||||
readonly title: string
|
||||
/** One-line subhead under the title. */
|
||||
readonly byline: string
|
||||
/** Whether the user may skip this step (Continue without acting). */
|
||||
readonly skippable: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical step sequence. `done` is a terminal pseudo-step the flow
|
||||
* lands on after `wallet`; it renders the success state and hands control
|
||||
* back to the host via `onComplete`.
|
||||
*/
|
||||
export const STEPS: readonly StepDesc[] = [
|
||||
{
|
||||
id: 'org',
|
||||
title: 'Choose your organization',
|
||||
byline: 'Pick an organization you belong to, or create a new one.',
|
||||
skippable: false,
|
||||
},
|
||||
{
|
||||
id: 'project',
|
||||
title: 'Create your first project',
|
||||
byline: 'Projects group your apps, keys, and usage. You can add more later.',
|
||||
skippable: true,
|
||||
},
|
||||
{
|
||||
id: 'wallet',
|
||||
title: 'Link a wallet',
|
||||
byline: 'Connect a Web3 wallet to sign and pay onchain. Optional.',
|
||||
skippable: true,
|
||||
},
|
||||
] as const
|
||||
|
||||
/** A minimal org reference the UI lists in the "choose org" step. */
|
||||
export interface OrgRef {
|
||||
/** Casdoor org slug (the `<org>` in `<org>-<app>`). */
|
||||
readonly name: string
|
||||
/** Human-facing name; falls back to `name` when unset. */
|
||||
readonly displayName: string
|
||||
}
|
||||
|
||||
/** A minimal project reference returned after creation. */
|
||||
export interface ProjectRef {
|
||||
readonly owner: string
|
||||
readonly name: string
|
||||
readonly displayName: string
|
||||
readonly organization: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Accumulated flow state. Each step writes its result here; the success
|
||||
* screen and `onComplete` read it. Serializable so the host can persist a
|
||||
* resume point if it wants (this pkg does not persist on its own).
|
||||
*/
|
||||
export interface OnboardingState {
|
||||
/** Slug of the org the user landed in (chosen or created). */
|
||||
readonly orgName?: string
|
||||
/** Whether the org was freshly created in this flow (vs. pre-existing). */
|
||||
readonly orgCreated?: boolean
|
||||
/** Name of the project created in step 2, if any. */
|
||||
readonly projectName?: string
|
||||
/** Wallet address linked in step 3, if any. */
|
||||
readonly walletAddress?: string
|
||||
}
|
||||
|
||||
/** Resolve a step descriptor by id. */
|
||||
export function stepById(id: StepId): StepDesc | undefined {
|
||||
return STEPS.find((s) => s.id === id)
|
||||
}
|
||||
|
||||
/** The step that follows `id` in the linear flow (`done` is terminal). */
|
||||
export function nextStep(id: StepId): StepId {
|
||||
if (id === 'done') return 'done'
|
||||
const i = STEPS.findIndex((s) => s.id === id)
|
||||
if (i < 0 || i + 1 >= STEPS.length) return 'done'
|
||||
return STEPS[i + 1]!.id
|
||||
}
|
||||
|
||||
/** The step that precedes `id`, or undefined at the first step. */
|
||||
export function prevStep(id: StepId): StepId | undefined {
|
||||
const i = STEPS.findIndex((s) => s.id === id)
|
||||
if (i <= 0) return undefined
|
||||
return STEPS[i - 1]!.id
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// @hanzo/id-onboarding — post-login onboarding for the Hanzo ID portal.
|
||||
//
|
||||
// Three-step flow: choose/create org → optional project → optional wallet
|
||||
// link. White-labeled by the host's brand name. Domain (serializable types +
|
||||
// step machine) / service (IAM-backed writes) / UI (self-contained flow)
|
||||
// split. Auth lives in @hanzo/id-auth — import login/signup from there.
|
||||
|
||||
// ── Domain ──────────────────────────────────────────────────────
|
||||
export {
|
||||
STEPS,
|
||||
stepById,
|
||||
nextStep,
|
||||
prevStep,
|
||||
type StepId,
|
||||
type StepDesc,
|
||||
type OrgRef,
|
||||
type ProjectRef,
|
||||
type OnboardingState,
|
||||
} from './domain/types'
|
||||
|
||||
// ── Service ─────────────────────────────────────────────────────
|
||||
export {
|
||||
createOnboardingService,
|
||||
type OnboardingService,
|
||||
type OnboardingServiceOptions,
|
||||
type Result,
|
||||
} from './service/onboarding'
|
||||
|
||||
// ── UI ──────────────────────────────────────────────────────────
|
||||
export { OnboardingFlow, type OnboardingFlowProps } from './ui/OnboardingFlow'
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Onboarding unit tests — run with the Node built-in test runner and native
|
||||
* TypeScript stripping (no test-framework dependency):
|
||||
*
|
||||
* node --test --experimental-strip-types src/onboarding.test.ts
|
||||
*
|
||||
* Covers the React-free surface: the domain step machine and the service's
|
||||
* request shaping + IAM response translation (with an injected fake fetch).
|
||||
*/
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { STEPS, stepById, nextStep, prevStep } from './domain/types.ts'
|
||||
import { createOnboardingService } from './service/onboarding.ts'
|
||||
|
||||
// ── Domain: step machine ────────────────────────────────────────────
|
||||
|
||||
test('step machine walks org → project → wallet → done', () => {
|
||||
assert.equal(STEPS[0]!.id, 'org')
|
||||
assert.equal(nextStep('org'), 'project')
|
||||
assert.equal(nextStep('project'), 'wallet')
|
||||
assert.equal(nextStep('wallet'), 'done')
|
||||
assert.equal(nextStep('done'), 'done') // terminal is a fixpoint
|
||||
})
|
||||
|
||||
test('prevStep is the inverse within the flow, undefined at the head', () => {
|
||||
assert.equal(prevStep('org'), undefined)
|
||||
assert.equal(prevStep('project'), 'org')
|
||||
assert.equal(prevStep('wallet'), 'project')
|
||||
})
|
||||
|
||||
test('only org is required; project and wallet are skippable', () => {
|
||||
assert.equal(stepById('org')!.skippable, false)
|
||||
assert.equal(stepById('project')!.skippable, true)
|
||||
assert.equal(stepById('wallet')!.skippable, true)
|
||||
})
|
||||
|
||||
// ── Service: fake-fetch harness ─────────────────────────────────────
|
||||
|
||||
interface Recorded {
|
||||
url: string
|
||||
method: string
|
||||
body?: string
|
||||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
/** Build a service whose fetch records calls and returns scripted JSON. */
|
||||
function harness(script: (rec: Recorded) => { status?: number; json: unknown }) {
|
||||
const calls: Recorded[] = []
|
||||
const fetchImpl = (async (input: string | URL, init?: RequestInit) => {
|
||||
const headers: Record<string, string> = {}
|
||||
const h = init?.headers as Record<string, string> | undefined
|
||||
if (h) for (const k of Object.keys(h)) headers[k] = h[k]!
|
||||
const rec: Recorded = {
|
||||
url: String(input),
|
||||
method: init?.method ?? 'GET',
|
||||
body: typeof init?.body === 'string' ? init.body : undefined,
|
||||
headers,
|
||||
}
|
||||
calls.push(rec)
|
||||
const { status = 200, json } = script(rec)
|
||||
return new Response(JSON.stringify(json), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
const service = createOnboardingService({
|
||||
iamUrl: 'https://hanzo.id',
|
||||
orgId: 'hanzo',
|
||||
getAccessToken: () => 'tok-123',
|
||||
fetchImpl,
|
||||
})
|
||||
return { service, calls }
|
||||
}
|
||||
|
||||
test('listOrgs hits get-organizations with the bearer token and maps rows', async () => {
|
||||
const { service, calls } = harness(() => ({
|
||||
json: { status: 'ok', data: [{ name: 'hanzo', displayName: 'Hanzo' }, { name: 'acme' }] },
|
||||
}))
|
||||
const orgs = await service.listOrgs()
|
||||
assert.equal(calls[0]!.url, 'https://hanzo.id/v1/iam/get-organizations')
|
||||
assert.equal(calls[0]!.headers.Authorization, 'Bearer tok-123')
|
||||
assert.deepEqual(orgs, [
|
||||
{ name: 'hanzo', displayName: 'Hanzo' },
|
||||
{ name: 'acme', displayName: 'acme' }, // displayName falls back to name
|
||||
])
|
||||
})
|
||||
|
||||
test('listOrgs returns [] (not throw) on a server error', async () => {
|
||||
const { service } = harness(() => ({ status: 500, json: { status: 'error', msg: 'boom' } }))
|
||||
assert.deepEqual(await service.listOrgs(), [])
|
||||
})
|
||||
|
||||
test('createOrg posts the org and reports IAM error messages', async () => {
|
||||
const ok = harness(() => ({ json: { status: 'ok' } }))
|
||||
const res = await ok.service.createOrg({ name: 'acme', displayName: 'Acme Inc' })
|
||||
assert.equal(ok.calls[0]!.url, 'https://hanzo.id/v1/iam/add-organization')
|
||||
assert.equal(ok.calls[0]!.method, 'POST')
|
||||
const sent = JSON.parse(ok.calls[0]!.body!)
|
||||
assert.equal(sent.name, 'acme')
|
||||
assert.equal(sent.displayName, 'Acme Inc')
|
||||
assert.deepEqual(res, { ok: true, value: { name: 'acme', displayName: 'Acme Inc' } })
|
||||
|
||||
const denied = harness(() => ({ status: 403, json: { status: 'error', msg: 'permission denied' } }))
|
||||
const fail = await denied.service.createOrg({ name: 'x', displayName: 'X' })
|
||||
assert.deepEqual(fail, { ok: false, error: 'HTTP 403' })
|
||||
})
|
||||
|
||||
test('linkWallet rejects a malformed address before any network call', async () => {
|
||||
const { service, calls } = harness(() => ({ json: { status: 'ok' } }))
|
||||
const res = await service.linkWallet('not-an-address')
|
||||
assert.deepEqual(res, { ok: false, error: 'invalid wallet address' })
|
||||
assert.equal(calls.length, 0)
|
||||
})
|
||||
|
||||
test('linkWallet resolves the user via get-account then writes web3onboard', async () => {
|
||||
const addr = '0x' + 'a'.repeat(40)
|
||||
const { service, calls } = harness((rec) => {
|
||||
if (rec.url.includes('get-account')) return { json: { status: 'ok', data: { owner: 'hanzo', name: 'alice' } } }
|
||||
return { json: { status: 'ok' } }
|
||||
})
|
||||
const res = await service.linkWallet(addr)
|
||||
assert.deepEqual(res, { ok: true, value: addr })
|
||||
// 1) get-account, 2) update-user keyed by owner/name, column-scoped
|
||||
assert.match(calls[0]!.url, /get-account$/)
|
||||
const upd = calls[1]!
|
||||
assert.ok(upd.url.includes('/v1/iam/update-user'))
|
||||
assert.ok(upd.url.includes('id=hanzo%2Falice') || upd.url.includes('id=hanzo/alice'))
|
||||
assert.ok(upd.url.includes('columns=web3onboard'))
|
||||
const sent = JSON.parse(upd.body!)
|
||||
assert.equal(sent.web3onboard, addr)
|
||||
assert.equal(sent.owner, 'hanzo')
|
||||
assert.equal(sent.name, 'alice')
|
||||
})
|
||||
|
||||
test('linkWallet fails closed when there is no signed-in user', async () => {
|
||||
const addr = '0x' + 'b'.repeat(40)
|
||||
const { service } = harness((rec) => {
|
||||
if (rec.url.includes('get-account')) return { status: 401, json: { status: 'error', msg: 'not signed in' } }
|
||||
return { json: { status: 'ok' } }
|
||||
})
|
||||
assert.deepEqual(await service.linkWallet(addr), { ok: false, error: 'not signed in' })
|
||||
})
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Onboarding service — the IAM-backed implementation of the org/project/
|
||||
* wallet flow.
|
||||
*
|
||||
* One way: every write goes through the canonical IAM REST surface under
|
||||
* `/v1/iam/*` (the same Casdoor-compat paths the auth client uses), carrying
|
||||
* the user's bearer token. There is no separate onboarding backend — the org
|
||||
* and project records live in IAM, which is the identity registry.
|
||||
*
|
||||
* listOrgs() GET /v1/iam/get-organizations (user-scoped server-side)
|
||||
* createOrg() POST /v1/iam/add-organization
|
||||
* createProject POST /v1/iam/add-project
|
||||
* linkWallet() client-side wallet connect → IAM update-user (host-driven)
|
||||
*
|
||||
* Token is supplied by the host through `getAccessToken` (the portal already
|
||||
* holds the session after login). The service never stores it.
|
||||
*/
|
||||
import type { Organization, Project } from '@hanzo/iam'
|
||||
import type { OrgRef, ProjectRef } from '../domain/types'
|
||||
|
||||
/** Result of a write that can fail gracefully (no throw on expected errors). */
|
||||
export type Result<T> = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: string }
|
||||
|
||||
export interface OnboardingService {
|
||||
/**
|
||||
* List organizations the signed-in user can land in. IAM scopes
|
||||
* `get-organizations` to the caller's memberships server-side from the
|
||||
* bearer token. Returns [] (not an error) when the user belongs to none.
|
||||
*/
|
||||
listOrgs(): Promise<OrgRef[]>
|
||||
/** Create a new organization owned by the user. */
|
||||
createOrg(input: { name: string; displayName: string }): Promise<Result<OrgRef>>
|
||||
/** Create a project inside `organization`. */
|
||||
createProject(input: { organization: string; name: string; displayName: string }): Promise<Result<ProjectRef>>
|
||||
/**
|
||||
* Attach a wallet address to the signed-in user (IAM `update-user`,
|
||||
* `web3Onboard` address field). The actual wallet connect happens in the
|
||||
* browser via the host-supplied `connectWallet`; this only persists the
|
||||
* resulting address.
|
||||
*/
|
||||
linkWallet(address: string): Promise<Result<string>>
|
||||
}
|
||||
|
||||
export interface OnboardingServiceOptions {
|
||||
/** IAM origin, no trailing slash (the tenant's `iamUrl`, i.e. hanzo.id). */
|
||||
readonly iamUrl: string
|
||||
/** Owning org slug used as the default `owner` for new records. */
|
||||
readonly orgId: string
|
||||
/** Bearer-token provider; resolves null when no session is present. */
|
||||
readonly getAccessToken: () => Promise<string | null> | string | null
|
||||
/** Override fetch (testing). Defaults to global fetch. */
|
||||
readonly fetchImpl?: typeof fetch
|
||||
}
|
||||
|
||||
const trimSlash = (s: string): string => s.replace(/\/+$/, '')
|
||||
|
||||
export function createOnboardingService(opts: OnboardingServiceOptions): OnboardingService {
|
||||
const base = trimSlash(opts.iamUrl)
|
||||
const f = opts.fetchImpl ?? fetch
|
||||
|
||||
async function authHeaders(json = true): Promise<HeadersInit> {
|
||||
const token = await opts.getAccessToken()
|
||||
const h: Record<string, string> = { Accept: 'application/json' }
|
||||
if (json) h['Content-Type'] = 'application/json'
|
||||
if (token) h.Authorization = `Bearer ${token}`
|
||||
return h
|
||||
}
|
||||
|
||||
async function listOrgs(): Promise<OrgRef[]> {
|
||||
const url = new URL('/v1/iam/get-organizations', base)
|
||||
let body: Record<string, unknown>
|
||||
try {
|
||||
const res = await f(url.toString(), { headers: await authHeaders(false), credentials: 'include' })
|
||||
if (!res.ok) return []
|
||||
body = (await res.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
const rows = extractRows(body)
|
||||
return rows.map(toOrgRef).filter((o): o is OrgRef => o !== null)
|
||||
}
|
||||
|
||||
async function createOrg(input: { name: string; displayName: string }): Promise<Result<OrgRef>> {
|
||||
const url = new URL('/v1/iam/add-organization', base)
|
||||
const org: Partial<Organization> = {
|
||||
owner: 'admin',
|
||||
name: input.name,
|
||||
displayName: input.displayName,
|
||||
isPersonal: false,
|
||||
balanceCurrency: 'USD',
|
||||
}
|
||||
return writeRecord(url, org, () => ({ name: input.name, displayName: input.displayName }))
|
||||
}
|
||||
|
||||
async function createProject(input: {
|
||||
organization: string
|
||||
name: string
|
||||
displayName: string
|
||||
}): Promise<Result<ProjectRef>> {
|
||||
const url = new URL('/v1/iam/add-project', base)
|
||||
const project: Partial<Project> = {
|
||||
owner: input.organization,
|
||||
name: input.name,
|
||||
displayName: input.displayName,
|
||||
organization: input.organization,
|
||||
isDefault: false,
|
||||
}
|
||||
return writeRecord(url, project, () => ({
|
||||
owner: input.organization,
|
||||
name: input.name,
|
||||
displayName: input.displayName,
|
||||
organization: input.organization,
|
||||
}))
|
||||
}
|
||||
|
||||
async function linkWallet(address: string): Promise<Result<string>> {
|
||||
const trimmed = address.trim()
|
||||
if (!isHexAddress(trimmed)) return { ok: false, error: 'invalid wallet address' }
|
||||
// Resolve the signed-in user (owner/name) from the session — IAM's
|
||||
// update-user is keyed by `id=<owner>/<name>`, not a "self" alias.
|
||||
const account = await getAccount()
|
||||
if (!account) return { ok: false, error: 'not signed in' }
|
||||
const url = new URL('/v1/iam/update-user', base)
|
||||
url.searchParams.set('id', `${account.owner}/${account.name}`)
|
||||
// Scope the write to the single `web3onboard` column so the rest of the
|
||||
// user row is untouched (Casdoor replaces unscoped writes wholesale).
|
||||
url.searchParams.set('columns', 'web3onboard')
|
||||
try {
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: await authHeaders(),
|
||||
credentials: 'include',
|
||||
// Casdoor's User JSON tag is lowercase `web3onboard`; send the full
|
||||
// owner/name so the row identity is unambiguous on the server.
|
||||
body: JSON.stringify({ owner: account.owner, name: account.name, web3onboard: trimmed }),
|
||||
})
|
||||
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }
|
||||
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>
|
||||
if (body.status === 'error') return { ok: false, error: msgOf(body) }
|
||||
return { ok: true, value: trimmed }
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the signed-in user's `{owner, name}` from `/v1/iam/get-account`. */
|
||||
async function getAccount(): Promise<{ owner: string; name: string } | null> {
|
||||
const url = new URL('/v1/iam/get-account', base)
|
||||
try {
|
||||
const res = await f(url.toString(), { headers: await authHeaders(false), credentials: 'include' })
|
||||
if (!res.ok) return null
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
const data = (body.data ?? body) as Record<string, unknown>
|
||||
const owner = typeof data.owner === 'string' ? data.owner : ''
|
||||
const name = typeof data.name === 'string' ? data.name : ''
|
||||
if (!owner || !name) return null
|
||||
return { owner, name }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function writeRecord<T>(
|
||||
url: URL,
|
||||
payload: unknown,
|
||||
onOk: () => T,
|
||||
): Promise<Result<T>> {
|
||||
try {
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: await authHeaders(),
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }
|
||||
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>
|
||||
if (body.status === 'error') return { ok: false, error: msgOf(body) }
|
||||
return { ok: true, value: onOk() }
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
return { listOrgs, createOrg, createProject, linkWallet }
|
||||
}
|
||||
|
||||
/** Pull the array payload out of an IAM list response (`data` or `data2`). */
|
||||
function extractRows(body: Record<string, unknown>): Record<string, unknown>[] {
|
||||
const candidate = Array.isArray(body.data) ? body.data : Array.isArray(body.data2) ? body.data2 : []
|
||||
return candidate.filter((r): r is Record<string, unknown> => typeof r === 'object' && r !== null)
|
||||
}
|
||||
|
||||
function toOrgRef(row: Record<string, unknown>): OrgRef | null {
|
||||
const name = typeof row.name === 'string' ? row.name : ''
|
||||
if (!name) return null
|
||||
const displayName = typeof row.displayName === 'string' && row.displayName ? row.displayName : name
|
||||
return { name, displayName }
|
||||
}
|
||||
|
||||
function msgOf(body: Record<string, unknown>): string {
|
||||
return typeof body.msg === 'string' && body.msg ? body.msg : 'request failed'
|
||||
}
|
||||
|
||||
/** EIP-55-agnostic 0x-prefixed 20-byte address check. */
|
||||
function isHexAddress(s: string): boolean {
|
||||
return /^0x[0-9a-fA-F]{40}$/.test(s)
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
import { useCallback, useEffect, useReducer, useState, type FormEvent } from 'react'
|
||||
import {
|
||||
STEPS,
|
||||
nextStep,
|
||||
prevStep,
|
||||
stepById,
|
||||
type OnboardingState,
|
||||
type OrgRef,
|
||||
type StepId,
|
||||
} from '../domain/types'
|
||||
import type { OnboardingService } from '../service/onboarding'
|
||||
|
||||
/**
|
||||
* Post-login onboarding flow.
|
||||
*
|
||||
* A self-contained three-step wizard (org → project → wallet) driven by an
|
||||
* internal step machine — no router lib, consistent with the rest of the
|
||||
* portal which routes on `window.location` and keeps page-local state in
|
||||
* React. The host renders this once after login and gets the accumulated
|
||||
* {@link OnboardingState} back via `onComplete`.
|
||||
*
|
||||
* White-label: all copy comes from the domain `STEPS` table + the `brandName`
|
||||
* prop. No brand-specific strings live in this component. Styling reuses the
|
||||
* portal's `hanzo-id-*` classes (defined in the web app's app.css).
|
||||
*/
|
||||
export interface OnboardingFlowProps {
|
||||
readonly service: OnboardingService
|
||||
/** Brand display name for headings (e.g. the resolved tenant brand). */
|
||||
readonly brandName: string
|
||||
/**
|
||||
* Host-supplied wallet connector. Returns the connected address (0x…) or
|
||||
* null if the user cancels. Kept as a prop so this pkg stays free of any
|
||||
* specific wallet library — the host wires Web3Onboard / wagmi / window
|
||||
* .ethereum. When omitted, the wallet step shows a "not available" note
|
||||
* and can only be skipped.
|
||||
*/
|
||||
readonly connectWallet?: () => Promise<string | null>
|
||||
/** Called once the flow reaches `done`, with the final accumulated state. */
|
||||
readonly onComplete: (state: OnboardingState) => void
|
||||
}
|
||||
|
||||
interface FlowState {
|
||||
readonly step: StepId
|
||||
readonly data: OnboardingState
|
||||
}
|
||||
|
||||
type FlowAction =
|
||||
| { type: 'advance'; patch: Partial<OnboardingState> }
|
||||
| { type: 'back' }
|
||||
|
||||
function reducer(state: FlowState, action: FlowAction): FlowState {
|
||||
switch (action.type) {
|
||||
case 'advance':
|
||||
return { step: nextStep(state.step), data: { ...state.data, ...action.patch } }
|
||||
case 'back': {
|
||||
const prev = prevStep(state.step)
|
||||
return prev ? { ...state, step: prev } : state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function OnboardingFlow({ service, brandName, connectWallet, onComplete }: OnboardingFlowProps) {
|
||||
const [state, dispatch] = useReducer(reducer, { step: 'org', data: {} })
|
||||
|
||||
// Terminal step: hand the accumulated state back to the host exactly once.
|
||||
useEffect(() => {
|
||||
if (state.step === 'done') onComplete(state.data)
|
||||
}, [state.step, state.data, onComplete])
|
||||
|
||||
const advance = useCallback((patch: Partial<OnboardingState>) => dispatch({ type: 'advance', patch }), [])
|
||||
const back = useCallback(() => dispatch({ type: 'back' }), [])
|
||||
|
||||
const desc = stepById(state.step)
|
||||
const stepIndex = STEPS.findIndex((s) => s.id === state.step)
|
||||
const showBack = stepIndex > 0
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-onboarding">
|
||||
{state.step !== 'done' && desc ? (
|
||||
<>
|
||||
<StepDots active={stepIndex} total={STEPS.length} />
|
||||
<header className="hanzo-id-onboarding-head">
|
||||
<h1>{desc.title}</h1>
|
||||
<p className="lede">{desc.byline}</p>
|
||||
</header>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{state.step === 'org' ? (
|
||||
<OrgStep service={service} onNext={advance} />
|
||||
) : null}
|
||||
{state.step === 'project' ? (
|
||||
<ProjectStep
|
||||
service={service}
|
||||
orgName={state.data.orgName}
|
||||
showBack={showBack}
|
||||
onBack={back}
|
||||
onNext={advance}
|
||||
/>
|
||||
) : null}
|
||||
{state.step === 'wallet' ? (
|
||||
<WalletStep
|
||||
service={service}
|
||||
connectWallet={connectWallet}
|
||||
showBack={showBack}
|
||||
onBack={back}
|
||||
onNext={advance}
|
||||
/>
|
||||
) : null}
|
||||
{state.step === 'done' ? <DoneStep brandName={brandName} data={state.data} /> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Linear progress dots. */
|
||||
function StepDots({ active, total }: { active: number; total: number }) {
|
||||
return (
|
||||
<div className="hanzo-id-stepdots" role="progressbar" aria-valuenow={active + 1} aria-valuemax={total}>
|
||||
{Array.from({ length: total }, (_, i) => (
|
||||
<span key={i} className={i <= active ? 'on' : ''} aria-hidden />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Step 1: organization ────────────────────────────────────────────
|
||||
|
||||
function OrgStep({
|
||||
service,
|
||||
onNext,
|
||||
}: {
|
||||
service: OnboardingService
|
||||
onNext: (patch: Partial<OnboardingState>) => void
|
||||
}) {
|
||||
const [orgs, setOrgs] = useState<OrgRef[] | null>(null)
|
||||
const [mode, setMode] = useState<'pick' | 'create'>('pick')
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
service.listOrgs().then((list) => {
|
||||
if (cancelled) return
|
||||
setOrgs(list)
|
||||
// No existing memberships → drop straight into create mode.
|
||||
if (list.length === 0) setMode('create')
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [service])
|
||||
|
||||
async function pick(org: OrgRef) {
|
||||
onNext({ orgName: org.name, orgCreated: false })
|
||||
}
|
||||
|
||||
async function create(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
const name = slugify(displayName)
|
||||
if (!name) {
|
||||
setError('Enter an organization name.')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
const res = await service.createOrg({ name, displayName: displayName.trim() })
|
||||
setBusy(false)
|
||||
if (!res.ok) {
|
||||
setError(humanizeError(res.error))
|
||||
return
|
||||
}
|
||||
onNext({ orgName: res.value.name, orgCreated: true })
|
||||
}
|
||||
|
||||
if (orgs === null) return <p className="hanzo-id-info">Loading your organizations…</p>
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-onboarding-body">
|
||||
{mode === 'pick' && orgs.length > 0 ? (
|
||||
<>
|
||||
<ul className="hanzo-id-org-list">
|
||||
{orgs.map((o) => (
|
||||
<li key={o.name}>
|
||||
<button type="button" className="hanzo-id-org-row" onClick={() => pick(o)}>
|
||||
<span>{o.displayName}</span>
|
||||
<span className="hanzo-id-org-slug">{o.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button type="button" className="hanzo-id-linkbtn" onClick={() => setMode('create')}>
|
||||
+ Create a new organization
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<form onSubmit={create} aria-busy={busy}>
|
||||
<label>
|
||||
<span>Organization name</span>
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="Acme Inc"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{displayName ? <p className="hanzo-id-slug-preview">slug: {slugify(displayName) || '—'}</p> : null}
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
<div className="hanzo-id-onboarding-actions">
|
||||
{orgs.length > 0 ? (
|
||||
<button type="button" className="hanzo-id-btn ghost" onClick={() => setMode('pick')}>
|
||||
Back
|
||||
</button>
|
||||
) : (
|
||||
// No org to fall back to and creation may be denied — let the
|
||||
// user proceed rather than dead-end. They land org-less; an
|
||||
// admin can add them to an org later.
|
||||
<button type="button" className="hanzo-id-btn ghost" onClick={() => onNext({})} disabled={busy}>
|
||||
Skip for now
|
||||
</button>
|
||||
)}
|
||||
<button type="submit" className="hanzo-id-btn primary" disabled={busy}>
|
||||
{busy ? 'Creating…' : 'Create organization'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Step 2: project (optional) ──────────────────────────────────────
|
||||
|
||||
function ProjectStep({
|
||||
service,
|
||||
orgName,
|
||||
showBack,
|
||||
onBack,
|
||||
onNext,
|
||||
}: {
|
||||
service: OnboardingService
|
||||
orgName?: string
|
||||
showBack: boolean
|
||||
onBack: () => void
|
||||
onNext: (patch: Partial<OnboardingState>) => void
|
||||
}) {
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function create(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!orgName) return // can't create a project without a home org
|
||||
const name = slugify(displayName)
|
||||
if (!name) {
|
||||
setError('Enter a project name.')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
const res = await service.createProject({ organization: orgName, name, displayName: displayName.trim() })
|
||||
setBusy(false)
|
||||
if (!res.ok) {
|
||||
setError(humanizeError(res.error))
|
||||
return
|
||||
}
|
||||
onNext({ projectName: res.value.name })
|
||||
}
|
||||
|
||||
// No org was chosen (org step skipped) — a project needs a home org, so
|
||||
// offer only to continue.
|
||||
if (!orgName) {
|
||||
return (
|
||||
<div className="hanzo-id-onboarding-body">
|
||||
<p className="hanzo-id-info">Choose an organization first to create a project. You can do this later.</p>
|
||||
<div className="hanzo-id-onboarding-actions">
|
||||
{showBack ? (
|
||||
<button type="button" className="hanzo-id-btn ghost" onClick={onBack}>
|
||||
Back
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="hanzo-id-btn primary" onClick={() => onNext({})}>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-onboarding-body">
|
||||
<form onSubmit={create} aria-busy={busy}>
|
||||
<label>
|
||||
<span>Project name</span>
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="Production"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
{displayName ? <p className="hanzo-id-slug-preview">slug: {slugify(displayName) || '—'}</p> : null}
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
<div className="hanzo-id-onboarding-actions">
|
||||
{showBack ? (
|
||||
<button type="button" className="hanzo-id-btn ghost" onClick={onBack}>
|
||||
Back
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="hanzo-id-btn ghost" onClick={() => onNext({})} disabled={busy}>
|
||||
Skip
|
||||
</button>
|
||||
<button type="submit" className="hanzo-id-btn primary" disabled={busy}>
|
||||
{busy ? 'Creating…' : 'Create project'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Step 3: wallet (optional) ───────────────────────────────────────
|
||||
|
||||
function WalletStep({
|
||||
service,
|
||||
connectWallet,
|
||||
showBack,
|
||||
onBack,
|
||||
onNext,
|
||||
}: {
|
||||
service: OnboardingService
|
||||
connectWallet?: () => Promise<string | null>
|
||||
showBack: boolean
|
||||
onBack: () => void
|
||||
onNext: (patch: Partial<OnboardingState>) => void
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function link() {
|
||||
if (!connectWallet) return
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const address = await connectWallet()
|
||||
if (!address) {
|
||||
setBusy(false)
|
||||
return // user cancelled the wallet prompt
|
||||
}
|
||||
const res = await service.linkWallet(address)
|
||||
setBusy(false)
|
||||
if (!res.ok) {
|
||||
setError(res.error)
|
||||
return
|
||||
}
|
||||
onNext({ walletAddress: res.value })
|
||||
} catch (e) {
|
||||
setBusy(false)
|
||||
setError(String(e))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-onboarding-body">
|
||||
{connectWallet ? null : (
|
||||
<p className="hanzo-id-info">Wallet linking isn’t available here. You can add one later in settings.</p>
|
||||
)}
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
<div className="hanzo-id-onboarding-actions">
|
||||
{showBack ? (
|
||||
<button type="button" className="hanzo-id-btn ghost" onClick={onBack}>
|
||||
Back
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="hanzo-id-btn ghost" onClick={() => onNext({})} disabled={busy}>
|
||||
Skip
|
||||
</button>
|
||||
{connectWallet ? (
|
||||
<button type="button" className="hanzo-id-btn primary" onClick={link} disabled={busy}>
|
||||
{busy ? 'Connecting…' : 'Connect wallet'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Terminal: success ───────────────────────────────────────────────
|
||||
|
||||
function DoneStep({ brandName, data }: { brandName: string; data: OnboardingState }) {
|
||||
return (
|
||||
<div className="hanzo-id-onboarding-done">
|
||||
<h1>You’re all set</h1>
|
||||
<p className="lede">Welcome to {brandName}.</p>
|
||||
<dl className="hanzo-id-summary">
|
||||
{data.orgName ? (
|
||||
<>
|
||||
<dt>Organization</dt>
|
||||
<dd>{data.orgName}</dd>
|
||||
</>
|
||||
) : null}
|
||||
{data.projectName ? (
|
||||
<>
|
||||
<dt>Project</dt>
|
||||
<dd>{data.projectName}</dd>
|
||||
</>
|
||||
) : null}
|
||||
{data.walletAddress ? (
|
||||
<>
|
||||
<dt>Wallet</dt>
|
||||
<dd>{shortAddr(data.walletAddress)}</dd>
|
||||
</>
|
||||
) : null}
|
||||
</dl>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Map raw IAM errors to a human sentence. Org/project creation is admin-gated
|
||||
* in IAM authz (`add-organization` requires the `admin` role; `add-project`
|
||||
* default-denies for non-admins), so a normal member hits a permission error
|
||||
* — say so plainly instead of leaking an HTTP code, and the step stays
|
||||
* skippable so onboarding never hard-blocks.
|
||||
*/
|
||||
function humanizeError(raw: string): string {
|
||||
const lower = raw.toLowerCase()
|
||||
if (lower.includes('403') || lower.includes('permission') || lower.includes('not allowed') || lower.includes('unauthorized')) {
|
||||
return 'You don’t have permission to create this here. Pick an existing organization, or ask an admin to invite you.'
|
||||
}
|
||||
if (lower.includes('already') || lower.includes('exist') || lower.includes('conflict') || lower.includes('409')) {
|
||||
return 'That name is taken. Try a different one.'
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
/** Lower-kebab a display name into an org/project slug. */
|
||||
function slugify(s: string): string {
|
||||
return s
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 40)
|
||||
}
|
||||
|
||||
function shortAddr(a: string): string {
|
||||
return a.length > 12 ? `${a.slice(0, 6)}…${a.slice(-4)}` : a
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@hanzo/id-shared",
|
||||
"version": "0.1.1",
|
||||
"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",
|
||||
"test": "node --test --experimental-strip-types 'src/**/*.test.ts'"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { BrandContract } from './types'
|
||||
|
||||
/**
|
||||
* Resolve a BrandContract from a tenant's brand package.
|
||||
*
|
||||
* Each per-org brand pkg (`@hanzo/brand`, `@luxfi/brand`, `@zooai/brand`,
|
||||
* `@parsdao/brand`) ships a `brand.json` at the package root. This loader
|
||||
* fetches it at runtime so the portal does not need to import every brand
|
||||
* package's bundle (the unused ones tree-shake away).
|
||||
*
|
||||
* Build-time path (server, Node): use dynamic import of the JSON.
|
||||
* Runtime path (browser): fetch from `/brand/${pkg}/brand.json` (the
|
||||
* Vite plugin or the Express static serves the assets from each pkg's
|
||||
* `assets/` directory at this path).
|
||||
*/
|
||||
export async function loadBrand(brandPackage: string): Promise<BrandContract> {
|
||||
// Browser: served by the app from /brand/<pkg>/brand.json. The brand is
|
||||
// purely cosmetic, so a transient fetch failure must NEVER blank the login
|
||||
// form. Retry the (occasionally 502-flaky) asset a few times, then fall back
|
||||
// to a neutral brand so the form always renders.
|
||||
if (typeof window !== 'undefined') {
|
||||
// Flat, encoding-safe path emitted by the Vite brandJsonPlugin:
|
||||
// `@hanzo/brand` -> `/brand/hanzo.json`. A nested `@scope/brand/brand.json`
|
||||
// URL cannot be served by the production static server (literal `@` +
|
||||
// encoded `%2F` miss the on-disk file -> SPA catch-all returns index.html).
|
||||
const slug = brandPackage.replace(/^@/, '').split('/')[0] ?? 'hanzo'
|
||||
const url = `/brand/${slug}.json`
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const res = await fetch(url, { cache: 'no-store' })
|
||||
if (res.ok) {
|
||||
const raw = await res.json()
|
||||
return raw.brand as BrandContract
|
||||
}
|
||||
} catch {
|
||||
// network error — fall through to retry
|
||||
}
|
||||
if (attempt < 2) await new Promise((r) => setTimeout(r, 150 * (attempt + 1)))
|
||||
}
|
||||
return fallbackBrand(brandPackage)
|
||||
}
|
||||
// Node: dynamic import (build step + SSR fallback)
|
||||
const mod = (await import(/* @vite-ignore */ `${brandPackage}/brand.json`, {
|
||||
with: { type: 'json' },
|
||||
})) as { default: { brand: BrandContract } }
|
||||
return mod.default.brand
|
||||
}
|
||||
|
||||
/**
|
||||
* Last-resort brand when the asset is unreachable after retries. Keeps the
|
||||
* login form usable (a generic heading) instead of blanking the page. The
|
||||
* display name is derived from the pkg scope (`@hanzo/brand` -> "Hanzo"); the
|
||||
* few tenants whose scope differs from their display name are mapped.
|
||||
*/
|
||||
function fallbackBrand(brandPackage: string): BrandContract {
|
||||
const scope = brandPackage.replace(/^@/, '').split('/')[0] ?? 'hanzo'
|
||||
const overrides: Record<string, string> = { luxfi: 'Lux', zooai: 'Zoo', parsdao: 'Pars' }
|
||||
const name = overrides[scope] ?? scope.charAt(0).toUpperCase() + scope.slice(1)
|
||||
return {
|
||||
name,
|
||||
title: name,
|
||||
description: '',
|
||||
appDomain: '',
|
||||
logoUrl: '',
|
||||
faviconUrl: '',
|
||||
}
|
||||
}
|
||||
|
||||
/** 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,87 @@
|
||||
/**
|
||||
* Tenant-resolver tests — run with the Node built-in runner + native TS strip:
|
||||
*
|
||||
* pnpm --filter @hanzo/id-shared test
|
||||
*
|
||||
* Focus: a host that exists ONLY in the runtime catalog (no built-in entry)
|
||||
* must resolve to ITS OWN brand and issuer — never inherit Hanzo's. This is the
|
||||
* osage.id brand-leak regression: the catalog carries `brandUrl`, and the
|
||||
* resolver must map it to `brandPackage` and derive issuer/origin from the host.
|
||||
*/
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { resolveTenant, parseCatalog } from './tenant.ts'
|
||||
|
||||
// Mirrors the K8s ConfigMap shape: entries carry `brandUrl`, not `brandPackage`.
|
||||
const CATALOG = {
|
||||
'lux.id': {
|
||||
orgId: 'lux',
|
||||
clientId: 'lux-cloud',
|
||||
appName: 'lux-cloud',
|
||||
brandUrl: 'https://cdn.jsdelivr.net/npm/@luxfi/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',
|
||||
},
|
||||
}
|
||||
|
||||
test('a built-in host resolves to its own brand with no catalog', () => {
|
||||
const t = resolveTenant('hanzo.id')
|
||||
assert.equal(t.orgId, 'hanzo')
|
||||
assert.equal(t.brandPackage, '@hanzo/brand')
|
||||
assert.equal(t.iamUrl, 'https://hanzo.id')
|
||||
})
|
||||
|
||||
test('a catalog entry overrides clientId/appName but keeps a consistent brand', () => {
|
||||
const t = resolveTenant('lux.id', { catalog: CATALOG })
|
||||
assert.equal(t.orgId, 'lux')
|
||||
assert.equal(t.clientId, 'lux-cloud')
|
||||
assert.equal(t.brandPackage, '@luxfi/brand')
|
||||
assert.equal(t.iamUrl, 'https://lux.id')
|
||||
assert.equal(t.publicOrigin, 'https://lux.id')
|
||||
})
|
||||
|
||||
test('a catalog-ONLY host does NOT leak the Hanzo brand (osage.id regression)', () => {
|
||||
const t = resolveTenant('osage.id', { catalog: CATALOG })
|
||||
assert.equal(t.orgId, 'osage')
|
||||
assert.equal(t.clientId, 'osage-id-portal')
|
||||
// brandUrl is mapped onto brandPackage, and it is NOT Hanzo's.
|
||||
assert.equal(t.brandPackage, '@osage/brand')
|
||||
assert.notEqual(t.brandPackage, '@hanzo/brand')
|
||||
// issuer + origin are the host itself, never hanzo.id.
|
||||
assert.equal(t.iamUrl, 'https://osage.id')
|
||||
assert.equal(t.iamIssuer, 'https://osage.id')
|
||||
assert.equal(t.publicOrigin, 'https://osage.id')
|
||||
})
|
||||
|
||||
test('pars built-in uses the working pars-console portal app (not the missing pars-id)', () => {
|
||||
const t = resolveTenant('pars.id')
|
||||
assert.equal(t.clientId, 'pars-console')
|
||||
assert.equal(t.brandPackage, '@parsdao/brand')
|
||||
})
|
||||
|
||||
test('osage built-in resolves to Osage even with NO catalog (fallback safety)', () => {
|
||||
const t = resolveTenant('osage.id')
|
||||
assert.equal(t.orgId, 'osage')
|
||||
assert.equal(t.brandPackage, '@osage/brand')
|
||||
assert.notEqual(t.brandPackage, '@hanzo/brand')
|
||||
assert.equal(t.iamUrl, 'https://osage.id')
|
||||
})
|
||||
|
||||
test('an unknown host falls back to the default org but keeps its own origin', () => {
|
||||
const t = resolveTenant('preview.example.com')
|
||||
assert.equal(t.orgId, 'hanzo')
|
||||
assert.equal(t.publicOrigin, 'https://preview.example.com')
|
||||
})
|
||||
|
||||
test('parseCatalog tolerates junk', () => {
|
||||
assert.deepEqual(parseCatalog(undefined), {})
|
||||
assert.deepEqual(parseCatalog(null), {})
|
||||
assert.deepEqual(parseCatalog('not json'), {})
|
||||
assert.deepEqual(parseCatalog('{"osage.id":{"orgId":"osage"}}'), {
|
||||
'osage.id': { orgId: 'osage' },
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,208 @@
|
||||
import type { TenantConfig } from './types'
|
||||
|
||||
/**
|
||||
* Resolve a TenantConfig by hostname.
|
||||
*
|
||||
* Resolution order (first hit wins):
|
||||
* 1. `IAM_TENANT_CONFIG_JSON` runtime catalog (set in K8s ConfigMap, served
|
||||
* to the browser via `/config.json` at pod startup).
|
||||
* 2. Built-in defaults for the four canonical Hanzo identity hosts.
|
||||
* 3. `IAM_DEFAULT_ORG` (or "hanzo") fallback — used for unknown hosts
|
||||
* (preview deploys, local dev, custom domains pre-launch).
|
||||
*
|
||||
* No hardcoded hostname switches anywhere downstream. Adding a tenant
|
||||
* means editing the runtime catalog, never editing source.
|
||||
*/
|
||||
|
||||
const TRIM_TRAILING_SLASH = (s: string): string => s.replace(/\/+$/, '')
|
||||
|
||||
/**
|
||||
* Built-in tenants for the four canonical identity hosts.
|
||||
*
|
||||
* `iamUrl` is the per-brand OIDC ISSUER — the host that serves
|
||||
* `/.well-known/openid-configuration` and the `/v1/iam/*` surface. Per
|
||||
* HIP-0111 this is the brand's own `*.id` host (hanzo.id / lux.id / …),
|
||||
* NOT `iam.hanzo.ai`: discovery must be host-relative so the SDK never
|
||||
* resolves to the wrong origin (or the IAM SPA HTML catch-all). The IAM
|
||||
* backend tenant-scopes on the `organization` body param; one backend
|
||||
* serves every brand behind its own issuer host.
|
||||
*
|
||||
* `clientId` is the brand `-id` app registered in `init_data.json`
|
||||
* (`hanzo-id`, `lux-id`, …) so the portal authenticates as that app — the
|
||||
* same app whose enabled providers (password + GitHub + Google + Web3)
|
||||
* `get-app-login` reports.
|
||||
*/
|
||||
const DEFAULT_TENANTS: Record<string, TenantConfig> = {
|
||||
'hanzo.id': {
|
||||
orgId: 'hanzo',
|
||||
iamUrl: 'https://hanzo.id',
|
||||
iamIssuer: 'https://hanzo.id',
|
||||
clientId: 'hanzo-id',
|
||||
appName: 'hanzo-id',
|
||||
publicOrigin: 'https://hanzo.id',
|
||||
brandPackage: '@hanzo/brand',
|
||||
},
|
||||
'lux.id': {
|
||||
orgId: 'lux',
|
||||
iamUrl: 'https://lux.id',
|
||||
iamIssuer: 'https://lux.id',
|
||||
clientId: 'lux-id',
|
||||
appName: 'lux-id',
|
||||
publicOrigin: 'https://lux.id',
|
||||
brandPackage: '@luxfi/brand',
|
||||
},
|
||||
'zoo.id': {
|
||||
orgId: 'zoo',
|
||||
iamUrl: 'https://zoo.id',
|
||||
iamIssuer: 'https://zoo.id',
|
||||
clientId: 'zoo-id',
|
||||
appName: 'zoo-id',
|
||||
publicOrigin: 'https://zoo.id',
|
||||
brandPackage: '@zooai/brand',
|
||||
},
|
||||
'pars.id': {
|
||||
orgId: 'pars',
|
||||
iamUrl: 'https://pars.id',
|
||||
iamIssuer: 'https://pars.id',
|
||||
// The portal app is `pars-console` (it carries the https://pars.id/callback
|
||||
// redirect); a bare `pars-id` app does not exist in IAM.
|
||||
clientId: 'pars-console',
|
||||
appName: 'pars-console',
|
||||
publicOrigin: 'https://pars.id',
|
||||
brandPackage: '@parsdao/brand',
|
||||
},
|
||||
// Osage is served by this portal too; without a built-in it would fall back
|
||||
// to the Hanzo default and leak the wrong brand if the runtime catalog ever
|
||||
// fails to load. (osage-id-portal is pre-launch — no IAM app yet — but the
|
||||
// brand must read as Osage, never Hanzo.)
|
||||
'osage.id': {
|
||||
orgId: 'osage',
|
||||
iamUrl: 'https://osage.id',
|
||||
iamIssuer: 'https://osage.id',
|
||||
clientId: 'osage-id-portal',
|
||||
appName: 'osage-id',
|
||||
publicOrigin: 'https://osage.id',
|
||||
brandPackage: '@osage/brand',
|
||||
},
|
||||
'www.osage.id': {
|
||||
orgId: 'osage',
|
||||
iamUrl: 'https://www.osage.id',
|
||||
iamIssuer: 'https://www.osage.id',
|
||||
clientId: 'osage-id-portal',
|
||||
appName: 'osage-id',
|
||||
publicOrigin: 'https://www.osage.id',
|
||||
brandPackage: '@osage/brand',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* A runtime catalog entry as it appears in the K8s ConfigMap / `/config.json`.
|
||||
* It carries the human-authored shape — notably `brandUrl` (a CDN URL), which
|
||||
* this module maps onto the code-facing `brandPackage`. All fields optional;
|
||||
* whatever is present overrides the host-derived base.
|
||||
*/
|
||||
export type CatalogEntry = Partial<TenantConfig> & {
|
||||
/** CDN URL of the brand package, e.g. `…/npm/@osage/brand@latest/brand.json`. */
|
||||
readonly brandUrl?: string
|
||||
}
|
||||
|
||||
export interface ResolveOptions {
|
||||
/** Optional runtime catalog (parsed from IAM_TENANT_CONFIG_JSON or /config.json). */
|
||||
readonly catalog?: Record<string, CatalogEntry>
|
||||
/** Default org slug when host has no entry. */
|
||||
readonly defaultOrg?: string
|
||||
}
|
||||
|
||||
export function resolveTenant(hostname: string, opts: ResolveOptions = {}): TenantConfig {
|
||||
const host = stripPort(hostname).toLowerCase()
|
||||
const catalogEntry = opts.catalog?.[host]
|
||||
const builtIn = DEFAULT_TENANTS[host]
|
||||
if (catalogEntry || builtIn) {
|
||||
// Base = the built-in tenant if one exists, else a skeleton derived from
|
||||
// THIS host. Never another brand's config: a catalog-only host (osage.id,
|
||||
// zoolabs.id) must not inherit Hanzo's issuer or brand package.
|
||||
const base = builtIn ?? hostSkeleton(host)
|
||||
const merged: TenantConfig = { ...base, ...fromCatalog(catalogEntry) } as TenantConfig
|
||||
return normalize(merged)
|
||||
}
|
||||
const defaultOrg = opts.defaultOrg ?? 'hanzo'
|
||||
const fallback = DEFAULT_TENANTS[`${defaultOrg}.id`] ?? DEFAULT_TENANTS['hanzo.id']
|
||||
return normalize({ ...fallback, publicOrigin: `https://${host}` })
|
||||
}
|
||||
|
||||
/**
|
||||
* A host-derived tenant skeleton for a catalog-only host (no built-in entry).
|
||||
* URLs point at the host itself so nothing leaks from another brand; the
|
||||
* catalog entry spread over this supplies orgId / clientId / appName /
|
||||
* brandPackage. brandPackage defaults empty → the brand loader falls back to a
|
||||
* neutral wordmark rather than showing the wrong brand.
|
||||
*/
|
||||
function hostSkeleton(host: string): TenantConfig {
|
||||
return {
|
||||
orgId: '',
|
||||
iamUrl: `https://${host}`,
|
||||
iamIssuer: `https://${host}`,
|
||||
clientId: '',
|
||||
appName: '',
|
||||
publicOrigin: `https://${host}`,
|
||||
brandPackage: '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a catalog entry onto a TenantConfig patch, mapping `brandUrl` →
|
||||
* `brandPackage` (the code-facing field) when an explicit `brandPackage` isn't
|
||||
* given. Only defined string fields are emitted, so the host-derived base shows
|
||||
* through for anything the entry omits.
|
||||
*/
|
||||
function fromCatalog(entry: CatalogEntry | undefined): Partial<TenantConfig> {
|
||||
if (!entry) return {}
|
||||
const out: Record<string, string> = {}
|
||||
for (const k of ['orgId', 'iamUrl', 'iamIssuer', 'clientId', 'appName', 'publicOrigin', 'oauthCallbackOrigin', 'brandPackage'] as const) {
|
||||
const v = entry[k]
|
||||
if (typeof v === 'string' && v.length > 0) out[k] = v
|
||||
}
|
||||
if (!out.brandPackage && typeof entry.brandUrl === 'string') {
|
||||
const pkg = brandPackageFromUrl(entry.brandUrl)
|
||||
if (pkg) out.brandPackage = pkg
|
||||
}
|
||||
return out as Partial<TenantConfig>
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the npm package name from a CDN brand URL, e.g.
|
||||
* `https://cdn.jsdelivr.net/npm/@osage/brand@latest/brand.json` → `@osage/brand`.
|
||||
*/
|
||||
function brandPackageFromUrl(url: string): string {
|
||||
const m = /\/npm\/(@[^/]+\/[^@/]+|[^@/]+)(?:@|\/)/.exec(url)
|
||||
return m ? m[1]! : ''
|
||||
}
|
||||
|
||||
function stripPort(h: string): string {
|
||||
return h.replace(/:\d+$/, '')
|
||||
}
|
||||
|
||||
function normalize(t: TenantConfig): TenantConfig {
|
||||
const publicOrigin = TRIM_TRAILING_SLASH(t.publicOrigin)
|
||||
return {
|
||||
...t,
|
||||
iamUrl: TRIM_TRAILING_SLASH(t.iamUrl),
|
||||
iamIssuer: TRIM_TRAILING_SLASH(t.iamIssuer || t.iamUrl),
|
||||
publicOrigin,
|
||||
// The social OAuth hop's redirect_uri must hit the provider's registered
|
||||
// callback host. Default to this host; brands sharing a single OAuth client
|
||||
// override it (via the catalog) to that client's registered origin.
|
||||
oauthCallbackOrigin: TRIM_TRAILING_SLASH(t.oauthCallbackOrigin || publicOrigin),
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse the runtime catalog JSON safely; returns {} on any error. */
|
||||
export function parseCatalog(raw: string | undefined | null): Record<string, Partial<TenantConfig>> {
|
||||
if (!raw) return {}
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
return parsed && typeof parsed === 'object' ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Per-tenant configuration resolved at runtime.
|
||||
*
|
||||
* One image, many hosts. The portal resolves a TenantConfig for each
|
||||
* incoming request by hostname; the IAM backend, OAuth client id, and
|
||||
* brand package are all wired from this single object.
|
||||
*/
|
||||
export interface TenantConfig {
|
||||
/** Tenant org slug (matches the JWT `owner` claim and the IAM `<org>-<app>` namespace). */
|
||||
readonly orgId: string
|
||||
/** IAM (OIDC) backend origin, no trailing slash. */
|
||||
readonly iamUrl: string
|
||||
/** Pinned OIDC issuer claim. Defaults to iamUrl. */
|
||||
readonly iamIssuer: string
|
||||
/** Default OAuth client_id (used when the request has no `?client_id=` param). */
|
||||
readonly clientId: string
|
||||
/** Underlying IAM application slug. */
|
||||
readonly appName: string
|
||||
/** Canonical public origin for the host (used for OIDC discovery rewrites). */
|
||||
readonly publicOrigin: string
|
||||
/**
|
||||
* Origin whose `/callback` is registered as the social OAuth providers'
|
||||
* authorized redirect URI. The shared GitHub/Google OAuth clients are
|
||||
* registered against ONE callback host — the IAM backend (`iam.hanzo.ai`) —
|
||||
* so the provider hop MUST send `redirect_uri=<oauthCallbackOrigin>/callback`
|
||||
* or the provider rejects it with `redirect_uri_mismatch`. That host serves
|
||||
* the same headless `Callback` SPA, which completes the exchange and forwards
|
||||
* back to the originating app. Defaults to `publicOrigin` (per-host clients /
|
||||
* local dev). NO trailing slash. */
|
||||
readonly oauthCallbackOrigin?: string
|
||||
/** npm package name of the brand pkg to load (e.g. `@hanzo/brand`). */
|
||||
readonly brandPackage: string
|
||||
/** Optional absolute URL to brand.json (e.g. a jsDelivr-hosted copy from
|
||||
* config.json). Preferred over the app-local /brand/<pkg>/brand.json. */
|
||||
readonly brandUrl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Brand contract that all per-org brand packages MUST satisfy.
|
||||
* Matches the consumer contract in `@hanzo/brand` / `@luxfi/brand` /
|
||||
* `@zooai/brand` / `@parsdao/brand`. Read from each pkg's `brand.json`.
|
||||
*/
|
||||
export interface BrandContract {
|
||||
/** Org display name shown in headings ("Hanzo", "Lux", "Zoo", "Pars"). */
|
||||
readonly name: string
|
||||
/** Browser tab title prefix. */
|
||||
readonly title: string
|
||||
/** Short tagline rendered on the portal hero. */
|
||||
readonly description: string
|
||||
/** Marketing site (footer link target). */
|
||||
readonly appDomain: string
|
||||
/** Logo + favicon URLs (CDN or data URI). */
|
||||
readonly logoUrl: string
|
||||
readonly faviconUrl: string
|
||||
/** Primary accent (CSS color string, e.g. "#ff6b35" or "var(--brand)"). */
|
||||
readonly accentColor?: string
|
||||
/** Optional social links rendered in the footer. */
|
||||
readonly twitter?: string
|
||||
readonly github?: string
|
||||
readonly discord?: string
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
packages:
|
||||
- "apps/*"
|
||||
- "pkgs/*"
|
||||
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
|
||||
# Security overrides: force patched transitives (Dependabot alerts).
|
||||
# esbuild <0.28.1 → GHSA-gv7w-rqvm-qjhr (high), GHSA-g7r4-m6w7-qqqr (low)
|
||||
# uuid <11.1.1 → GHSA-w5hq-g745-h8pq (medium); only pulled by xcode@3.0.1
|
||||
overrides:
|
||||
esbuild: "^0.28.1"
|
||||
uuid: "^11.1.1"
|
||||
@@ -1,9 +0,0 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 140 32" fill="none">
|
||||
<text x="0" y="24" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="24" font-weight="700" fill="#8b5cf6">Ad Nexus</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 226 B |