zooqueen 674b6bb263 the sign-in order runs through the credential form
The wallet and the phone ranked third and fourth on the login screen, above the
email field, because they were entries in a strip and the strip led the page.
Somebody arriving to sign up for a small business met two specialist ways in
before the one they were going to use. They are still offered, they just stop
outranking the field: Google and GitHub lead, the form follows, the wallet and
the phone trail.

PROVIDER_ORDER carries the whole arrangement now rather than the buttons' half of
it, so the form has a named place in the one list and the column can be read in
one line. That is why the form is a child of the component that draws the column
— entries fall on both sides of it, and a component cannot place entries around a
sibling it does not contain. The alternative was a second list on the page saying
which entries lead and which trail, and two lists of one arrangement drift.

The rule travels with the form and appears only when something sits above it, so
an app with no providers configured no longer gets a rule separating its form
from nothing. It keeps the 24px of air it had when the page's gap owned it.

The form no longer waits on the two descriptor reads. It was safe to return
nothing until IAM answered while the form was a sibling; as a child that would be
a blank screen wherever IAM is slow.

Signup and device approval draw the same column from the same list. Device
approval had the form above the strip under a comment claiming that matched the
login page, which stopped being true when the one-click entries moved to the
front.
2026-08-16 17:37:43 -07:00
2026-02-14 05:26:48 -08:00
2026-06-28 20:06:13 -07:00

id

@hanzo/id

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.

Layout

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.

Local dev

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

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

pnpm build               # builds apps/web -> dist/
docker build -t ghcr.io/hanzoai/id:0.1.0 .

Adding a brand

  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.

That's it — no per-brand Worker, no per-brand image, no per-brand deployment. One binary, four brands.

Plugging an IDV provider

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',
}))

The portal stays unchanged — switching providers is a single registration call at boot.

S
Description
No description provided
Readme MIT
3.7 MiB
Languages
TypeScript 92.1%
CSS 4.5%
JavaScript 2.6%
Dockerfile 0.6%
HTML 0.2%