Files
id/LLM.md
zooqueen e7b303e57a feat(id): restore split-view login + apps launcher (ported from legacy-nextjs)
Login/Signup are now the polished two-column split — auth card on the left,
per-org marketing/branding panel on the right — and the Portal shows the
apps launcher grid for signed-in users, both ported from the frozen
legacy-nextjs design. Auth wiring is untouched: LoginForm from @hanzo/id-auth
still drives the /v1/iam/login + responseType=code flow with a type=text
username input.

- apps/web/src/marketing.ts: per-org marketing copy + app links + billing
  URLs keyed by tenant.orgId (ported from legacy staticBranding.content +
  orgApps). Keeps the brand-neutral BrandContract visual-only.
- apps/web/src/components/MarketingPanel.tsx: rotating-quote branding panel.
- apps/web/src/components/BrandLogo.tsx: brand.logoUrl with self-hosted
  /brand/<pkg>/assets/logo/logo.svg fallback (fixes the broken-logo CDN 404).
- Portal detects auth via /v1/iam/get-account OR the signed_in=1 marker the
  bare-login flow now returns (cross-proxy get-account does not echo the
  session). pkgs/auth response shaping only — wire contract unchanged.
- app.css: split-view + marketing + apps-launcher styles (no Tailwind).
- LLM.md: document the UI surfaces.

Deployed registry.digitalocean.com/hanzo/id:0.1.10. Verified on hanzo.id and
lux.id: split-view renders, apps launcher shows post-login, sign-in still
works (authenticated, no Unable to sign in).
2026-06-19 11:07:01 -07:00

7.1 KiB

Hanzo ID

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)
                                                                 │
                                                                 ▼
                                                  https://iam.hanzo.ai (Casdoor fork, Go)
                                                                 │
                                                                 ▼
                                                  iam-* postgres in hanzo namespace

Workspace

apps/
  web/          @hanzo/id-web  — Vite + React 19 + @hanzo/gui SPA
pkgs/
  shared/       @hanzo/id-shared — TenantConfig, resolveTenant, loadBrand
  auth/         @hanzo/id-auth   — composable login/signup/OTP forms +
                                   AuthClient (wraps @hanzo/iam REST)
  idv/          @hanzo/id-idv    — pluggable identity verification
                                   (Persona, Onfido, Veriff, stub)
legacy-nextjs/  Frozen predecessor. Delete after v0.1.0 ships.

Why this layout (and not just Next.js)

  1. Vite > Next.js for an SPA. No SSR needed; auth is post-load only. ~200kb gzip vs ~600kb. Build is 3s vs 90s.
  2. @hanzo/gui v7 is the canonical UI. Same shell as every other Hanzo admin surface. No bespoke components.
  3. Per-org brand packages. @hanzo/brand, @luxfi/brand, @zooai/brand, @parsdao/brand already ship brand.json files; we fetch them at runtime. Adding a brand = pnpm add @newco/brand
    • one line in pkgs/shared/src/tenant.ts (or a runtime catalog entry — no rebuild).
  4. Provider-pluggable IDV. Same surface for Persona, Onfido, Veriff, custom backends. No vendor lock-in at the portal layer.
  5. Mirrors downstream tenant id-app forks. Same apps/ + pkgs/ pattern, same @hanzo/gui shell, same per-tenant brand resolution.

UI surfaces

  • Login / Signup (apps/web/src/pages/Login.tsx, Signup.tsx): split-view — auth card (left) + per-org marketing/branding panel (right, .hanzo-id-split-brand, hidden < 1024px). Restored from legacy-nextjs design. The auth itself is the untouched <LoginForm>/<SignupForm> from @hanzo/id-auth; the page only wraps it in layout. Do not move auth logic into the page.
  • Portal (apps/web/src/pages/Portal.tsx): after a bare sign-in the user lands on /?signed_in=1 and sees the apps launcher (" apps", grid of appsFor(orgId) cards) + account/billing nav. Logged-out visitors get the brand hero. Auth state = /v1/iam/get-account (cookie session) OR the ?signed_in=1 marker (the cross-proxy get-account does not echo the bare-login session, so the marker is the authoritative "just authenticated" signal).
  • Marketing copy + app links + billing URLs are per-org in apps/web/src/marketing.ts, keyed by tenant.orgId (hanzo/lux/zoo/pars), ported from the legacy staticBranding.content + orgApps. The brand-neutral BrandContract stays visual-only; this file holds the org-specific copy.
  • Logo: apps/web/src/components/BrandLogo.tsx renders brand.logoUrl (CDN) and falls back to the in-image /brand/<pkg>/assets/logo/logo.svg on error (the jsdelivr CDN path 404s for some brands — the self-hosted asset always works).
  • Styling is hand-written CSS in apps/web/src/app.css (.hanzo-id-*). No Tailwind in this SPA — match existing class conventions.

Local dev

pnpm install
pnpm dev      # http://localhost:5173 → defaults to hanzo brand

For multi-tenant preview:

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

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.

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

import { registerProvider, createPersonaProvider } from '@hanzo/id-idv'
registerProvider(createPersonaProvider({ templateId, apiKey, environment: 'production' }))

Pre-built providers

id source docs
stub @hanzo/id-idv/providers/stub in-memory, dev-only
persona @hanzo/id-idv/providers/persona https://docs.withpersona.com
onfido @hanzo/id-idv/providers/onfido https://documentation.onfido.com
veriff @hanzo/id-idv/providers/veriff https://developers.veriff.com

Custom providers: implement the IDVProvider interface in pkgs/idv/src/provider.ts and register it.

Cutover from the CF Worker

  1. Build + push image (PR scaffolds; image bump comes after CI green).
  2. kubectl apply -k apps/web/k8s — Deployment + Service + Ingress live.
  3. cert-manager issues TLS for all 4 hosts.
  4. Remove the Cloudflare Worker routes for the 4 identity hosts.
  5. Repoint CF A records: hanzo.id, lux.id, zoo.id, pars.id → 129.212.164.5 (hanzo cluster ingress LB), CF-proxied.
  6. Verify each host loads its own brand.
  7. Archive hanzo.id-worker repo.

Backend

The Go IAM backend lives at ~/work/hanzo/iam (Casdoor fork, module github.com/hanzoai/iam, image ghcr.io/hanzoai/iam). This portal talks to it via the routes in pkgs/auth/src/client.ts:

  • /v1/iam/login /v1/iam/signup /v1/iam/send-verification-code
  • /oauth/authorize /oauth/token /oauth/logout

All hostnames talk to the same IAM backend — the org is carried in the request body (organization: <orgId>), and the IAM backend tenant-scopes on that.