Compare commits

..
Author SHA1 Message Date
Antje Worring ad91418abe merge: origin/main into restore-design — main infra + apps-launcher Portal
Combines main's infra fixes (real IAM clientId <org>-id #7, /v1/iam/get-app-login
path #10, resilient brand loader #14, hanzoai/spa serving #11-13, social/SMS login
#9) with the restore-design UX (split-view login + apps-launcher Portal that detects
the session via ?signed_in=1 / get-account and links to Console).

Fixes the live hanzo.id login LOOP: the stale 0.1.17 image had an org-select →
connect-wallet → skip onboarding where 'skip' bounced back to sign-in and the org
click was a no-op (never reached console). The current flow is: login → /?signed_in=1
→ Portal apps launcher (Console → console.hanzo.ai). No org-select/wallet/skip exist.

Conflicts resolved: Dockerfile/locks/brand.ts = main; client.ts get-app-login = /v1
(main); types.ts = union (signupEnabled + brandUrl); app.css = redesign apps-launcher
styles. Verified: pnpm build green.
2026-06-21 11:53:45 -07:00
Antje Worring 73f431d54e 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
Antje Worring 22e0cc72e2 fix(id): emit per-brand brand.json in build (ESM require) + harden loadBrand
Root cause of the post-cutover white-screen: vite.config.ts is loaded as a
native ESM module where `require` is undefined, so the brandJsonPlugin's
`require.resolve('<pkg>/brand.json')` threw and the silent catch skipped
emitting EVERY brand.json. The SPA then served index.html (HTTP 200,
text/html) for /brand/<pkg>/brand.json, loadBrand did res.json() on HTML,
threw "Unexpected token '<'", and App's catch rendered that error as the
whole page — no login form ever mounted.

- vite.config.ts: build a createRequire(import.meta.url) resolver so the
  brand.json subpath resolves under ESM. Build now emits all four
  dist/brand/<pkg>/brand.json (verified: @hanzo brand.name = "Hanzo").
- brand.ts: read the response as text and JSON.parse explicitly; on
  non-JSON throw a precise diagnostic instead of a cryptic SyntaxError,
  and require the "brand" key.
- apps/web 0.1.8 -> 0.1.9.
2026-06-19 10:39:53 -07:00
Antje Worring f85de5d464 fix(id): rebuild SPA so login POSTs /v1/iam/login + serve via hanzoai/spa
The deployed hanzo.id SPA (id:0.1.7, 26d old) POSTed login to /api/login
with the implicit-flow body type:"token". That path hit the SPA static
catch-all -> index.html (HTML 200) -> JSON.parse threw -> "Unable to sign
in", and even reaching IAM, type:"token" is rejected (implicit flow
disabled). Current source already POSTs /v1/iam/login with
responseType=code; a fresh build of HEAD fixes it.

- Dockerfile: final stage ghcr.io/hanzoai/static:0.4.1 -> hanzoai/spa:1.2.0.
  static's default CSP is `default-src 'none'` (no script-src/connect-src)
  which blanks the SPA and blocks fetch() to IAM; spa serves /public on
  :3000 with index.html fallback and an SPA-safe CSP (frame-ancestors
  'none'). Matches the live deploy contract (containerPort 3000, /health
  probe) exactly.
- LoginForm identifier input stays type="text" (accepts `z` and
  `z@hanzo.ai`); forgot/signup keep type="email" (semantically correct).
- Adds social-provider + email/SMS-code login UI (best-effort appLogin),
  and a signupEnabled tenant flag for invite-only tenants.
- Tenant clientId hanzo-id (verified: POST /v1/iam/login app=hanzo-id
  org=hanzo user=z -> {"status":"ok","data":"hanzo/z"}).
- Pin @hanzo/iam to ^0.9.4 (published patch line).
- apps/web 0.1.1 -> 0.1.8.
2026-06-19 10:32:01 -07:00
475d0291b4 fix(brand): resilient brand loader — never blank the login page (#14)
loadBrand did res.json() on the SPA-fallback HTML (the spa server answers
unknown paths with index.html + HTTP 200 for client routing), so a missing
brand.json threw 'Unexpected token <' and crashed App boot -> blank login
page. brand.json is not bundled and the path also wrongly encoded the '/'
in '@hanzo/brand' (%2F), so it never resolved.

Fix:
- Prefer the tenant's brandUrl (the working jsDelivr brand.json from
  config.json), then the app-local path; reject non-JSON (SPA-fallback HTML)
  responses by content-type; on total failure return a minimal fallback brand
  derived from the package scope. Branding is cosmetic and must never block login.
- Add TenantConfig.brandUrl (already supplied by config.json) and pass it from App.
- Drop encodeURIComponent on the whole package (it mangled the scope slash).

Co-authored-by: Antje Worring <worringantje@gmail.com>
2026-06-18 17:56:31 -07:00
ab4f666a2b fix(docker): pin hanzoai/spa:1.2.0 (1.3.0 image not published) (#13)
The v1.3.0 git tag exists but no 1.3.0 image was published to ghcr
(build pipeline blocked). 1.2.0 is the latest published spa image and the
version the canonical RECIPE.md uses.

Co-authored-by: Antje Worring <worringantje@gmail.com>
2026-06-18 16:18:13 -07:00
5372a45fc7 fix(docker): serve SPA via hanzoai/spa, not hanzoai/static (#12)
hanzoai/static defaults to Content-Security-Policy: default-src 'none'
(no script-src) — built for static assets, not a SPA that loads its own
JS bundle. That CSP blocks index-*.js, so React never mounts and the
login page renders blank. hanzoai/spa is the purpose-built base: history-
API fallthrough for client-side routes + a SPA-safe CSP. Defaults
PORT=3000 / ROOT=/public, matching the id deploy probe.

Immediate prod was unblocked by setting HANZO_STATIC_CSP on the live
deployment; this is the durable, one-way fix so the override hack isn't
needed and every rebuild serves correctly.

Co-authored-by: Antje Worring <worringantje@gmail.com>
2026-06-18 16:14:27 -07:00
bf6acb79d8 fix(docker): static needs -root/-spa flags, not ROOT/PORT env (#11)
hanzoai/static:0.4.1 reads the serve root from the -root flag (default
/public) and port from -port (default 3000) — the ENV ROOT=/spa PORT=8080
were no-ops, so the built image served /public (crash) on :8080 while the
deploy probes :3000. Set ENTRYPOINT [/static -root /spa -spa] (default
port 3000). Matches the hand-built id:0.1.5 now live.

Co-authored-by: Antje Worring <worringantje@gmail.com>
2026-06-18 15:33:36 -07:00
60f914894d fix(id): appLogin uses /v1/iam/get-app-login (main dropped /api/*) (#10)
Merged IAM main serves the canonical /v1/iam/* only; /api/get-app-login
now returns the SPA shell. Point the providers/methods fetch at the
canonical path so social buttons + sign-in methods load.

Co-authored-by: Antje Worring <worringantje@gmail.com>
2026-06-18 14:08:32 -07:00
7503329462 feat(id): render social logins + email + SMS code on login/signup (#9)
The Vite portal rendered only email/password. Add — driven by the live
IAM app config (new AuthClient.appLogin -> get-app-login), no hardcoded
lists:
- social provider buttons (GitHub/Google/Apple/Web3) via authorize?provider=
- passwordless email / SMS code sign-in (send-verification-code + OTPForm)
- new ProviderButtons component; LoginForm + SignupForm render providers
Email/password sign-in unchanged. tsc --noEmit clean; vite build clean.

Functional prerequisites (display works now; these make it WORK):
- real OAuth client IDs for github/google/apple (IAM currently has
  GITHUB_CLIENT_ID_PLACEHOLDER etc.)
- an SMS gateway provider (Twilio/etc.) in IAM for SMS codes
- redirectUris backfill so the /callback OAuth return is allowed (iam#51)

Co-authored-by: Antje Worring <worringantje@gmail.com>
2026-06-18 13:42:41 -07:00
4186888067 chore(deps): clear Dependabot alerts — drop legacy-nextjs, patch esbuild/uuid (#8)
Resolves all 35 open Dependabot alerts on hanzoai/id:

- Delete legacy-nextjs/ (frozen Next.js predecessor, not wired into any
  build/Dockerfile/workspace glob; docs slated it for deletion after
  v0.1.0 — v0.1.1 has shipped). Removes 31 alerts (next, undici, ws,
  picomatch, postcss, js-yaml, cookie, defu, ...).

- pnpm overrides for the 3 active-workspace transitives:
    esbuild ^0.28.1  (was 0.27.7) — GHSA-gv7w-rqvm-qjhr (high), GHSA-g7r4-m6w7-qqqr (low)
    uuid    ^11.1.1  (was 7.0.3/10/x via xcode@3.0.1) — GHSA-w5hq-g745-h8pq (medium)

Verified: pnpm install + typecheck (4/4) + build green (vite 7.3.5,
esbuild 0.28.1, 52 modules); 'pnpm audit' → no known vulnerabilities.

Co-authored-by: Antje Worring <worringantje@gmail.com>
2026-06-18 12:23:57 -07:00
8fce0ef2f2 fix(tenant): use real IAM clientId (<org>-id, not <org>-id-portal) (#7)
The IAM apps are registered with clientId '<org>-id' (hanzo-id, lux-id,
zoo-id, pars-id) — there is no '-portal' variant. clientId 'hanzo-id-portal'
returns 'Invalid client_id' from IAM, breaking the login flow. Align all
four tenant clientIds with their appName and the IAM seed convention.

Co-authored-by: Antje Worring <worringantje@gmail.com>
2026-06-18 11:34:50 -07:00
Antje WorringandClaude Opus 4.8 d423a077c3 docs: tidy LLM.md indexes; CLAUDE.md -> LLM.md symlink convention
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 09:40:39 -07:00
1e43cfe72a fix(id): pin @hanzo/iam to published ^0.9.4 (0.10.0 unpublished, breaks pnpm install) (#6)
Co-authored-by: hanzo-dev <dev@hanzo.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 04:03:28 -07:00
dd9addbb94 chore(decomplect): deploy + per-host catalog live in universe, not the app repo (#5)
The id repo is brand-neutral (zero brand data bundled), but apps/web/k8s/
carried the per-host brand catalog + Deployment/Ingress — duplicated in
hanzoai/universe infra/k8s/id/ (the single deploy source-of-truth). Removed
the overlay so app (brand-neutral image) and deploy (per-host catalog +
manifests, in universe) are separated. One way, one place.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 18:05:31 -07:00
hanzo-devandClaude Opus 4.8 9b0e7a1ba9 chore(decomplect): deploy + per-host catalog live in universe, not the app repo
The id repo is brand-neutral (zero brand data bundled), but apps/web/k8s/
carried the per-host brand catalog + Deployment/Ingress — duplicated in
hanzoai/universe infra/k8s/id/ (the single deploy source-of-truth). Removed
the overlay so app (brand-neutral image) and deploy (per-host catalog +
manifests, in universe) are separated. One way, one place.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 18:05:11 -07:00
d4540a08f8 fix(auth): IAM wire contract (body params) + brand-app catalog (v0.1.1) (#4)
* fix(auth): IAM wire contract + brand-app catalog

- client.ts: send type/application/organization in the request BODY (IAM reads
  auth fields from body, OAuth params from query) so the branded login form
  authenticates; build the code-redirect from the response.
- canonical /v1/iam/oauth/{authorize,token,logout} paths.
- tenant-catalog: point each host at its brand's real IAM app
  (hanzo-console/lux-cloud/zoo-console/pars-console).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(release): 0.1.1

---------

Co-authored-by: hanzo-dev <dev@hanzo.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 18:00:41 -07:00
hanzo-dev 99b619bcaa chore: update 2026-06-10 14:12:32 -07:00
hanzo-dev 9cc264b867 merge: id-vite-monorepo (-X theirs) 2026-06-01 18:09:45 -07:00
hanzo-dev 4a7eacdb64 merge: ci/canonical-docker-build-1776995843 (-X theirs) 2026-06-01 18:09:44 -07:00
hanzo-dev 7c00afb201 refactor: brand-neutral identity portal (zero brand-specific code)
Image carries no brand identity. Every per-tenant value comes from the
runtime catalog (K8s ConfigMap → /config.json → window.__ID_CATALOG__)
or is derived from the hostname.

Source changes:
- TenantConfig.brandPackage → brandUrl (absolute URL to brand.json).
  Brands self-host (npm + jsDelivr is convention; any URL works).
- DEFAULT_TENANTS = {} (deleted). Hostname-derivation is the new fallback:
    foo.id / www.foo.id / id.foo.net / iam.foo.net → orgId=foo,
    clientId=foo-id-portal, appName=foo-id,
    brandUrl=https://cdn.jsdelivr.net/npm/@foo/brand@latest/brand.json
  Works out-of-the-box when the npm scope matches the org. The catalog
  handles mismatches.
- brand.ts loadBrand(brandUrl) fetches the URL directly. localizeAssets()
  removed — brand.json's URLs are absolute, used as-is.
- vite.config.ts: removed brandJsonPlugin + BRAND_PACKAGES list. Vite
  bundles no brand assets. dist drops from ~250kB to 205kB (64kB gzip).
- apps/web/package.json: dropped @hanzo/brand, @luxfi/brand, @zooai/brand,
  @parsdao/brand deps. Zero brand packages in the image.
- main.tsx: fetches /config.json before mount; sets
  window.__ID_CATALOG__ from cfg.iamTenantConfigJson (templated by
  hanzoai/spa runtime from SPA_IAM_TENANT_CONFIG_JSON env var).
- App.tsx: loadBrand(t.brandUrl).
- LLM.md: documents the brand-neutral architecture and how to add a
  brand (publish brand.json + add to deploy's catalog ConfigMap; image
  never changes).

Deploy changes:
- apps/web/k8s/tenant-catalog.yaml: NEW ConfigMap carrying the Hanzo
  deployment's full host→tenant map for 11 hosts (hanzo / lux / zoo /
  pars / osage and their id.*.network / iam.*.network / www.* variants).
  Brand-specific knowledge lives ONLY here.
- apps/web/k8s/deployment.yaml: envFrom: configMapRef: id-tenant-catalog.
- apps/web/k8s/kustomization.yaml: includes tenant-catalog.yaml first.

Adding a brand from here on is:
  1. publish @<scope>/brand to npm with brand.json
  2. add the host(s) to the deployment's ConfigMap
  3. add the host to ingress.yaml (cert-manager auto-provisions TLS)
  4. DNS → cluster ingress IP

No source change. No image rebuild.
2026-05-29 11:37:05 -07:00
hanzo-dev 9f184bb20a feat(k8s): add www.zoolabs.id + osage.id + www.osage.id to id ingress
cert-manager.io/cluster-issuer=letsencrypt-prod-cf provisions per-host
TLS via DNS-01 (the Traefik file-route certResolver path is non-functional
in this cluster — CF env vars on the ingress deployment are empty, ACME
in /data/acme.json is empty). cert-manager owns TLS for identity hosts.

After apply + cert issuance (~90s): all 7 identity hosts return their
own OIDC issuer with valid LE certs:
  lux.id, hanzo.id, pars.id, zoolabs.id, www.zoolabs.id, osage.id, www.osage.id
2026-05-29 11:07:17 -07:00
hanzo-dev c4c2760374 feat(tenant): add osage.id + www.osage.id + www.zoolabs.id tenants
DEFAULT_TENANTS now resolves all 4 active identity hosts that didn't
have built-in entries:
- www.zoolabs.id → orgId=zoo, brand=@zooai/brand
- osage.id      → orgId=osage, brand=@osage/brand
- www.osage.id  → orgId=osage, brand=@osage/brand

zoolabs.id was already present. orgId, iamIssuer, clientId, appName,
publicOrigin, brandPackage all follow the existing per-host shape.

@osage/brand is unpublished (~/work/osage/brand v0.1.0 needs build +
publish). Until then, /brand/@osage/brand/brand.json 404s and the
SPA's loadBrand falls back to the @hanzo/brand default — adequate
until osage.id DNS flips off Cloudflare Pages.
2026-05-28 18:27:27 -07:00
hanzo-dev f118890485 chore: update 2026-05-25 15:14:35 -07:00
hanzo-dev ca8dd4ab74 feat: add zoolabs.id (canonical Zoo identity host; was zoo.id)
Per user clarification, the Zoo identity domain is zoolabs.id (zoo.id is
not owned). Add tenant entry + Ingress host + TLS secret. DNS A record
already repointed in CF to 129.212.164.5.
2026-05-24 14:10:33 -07:00
hanzo-dev bdb08d0d98 fix(tenant): hanzo.id clientId was mangled by previous sed (id-portal-portal → hanzo-id-portal) 2026-05-24 10:32:18 -07:00
hanzo-dev 70a14dc902 style: rename CSS classes hanzo-id-* → id-portal-*
Brand-neutral CSS class names match the public-facing identity of the
portal (id-portal). Cosmetic only; no rendered text changes. Per
playwright agent's source-view note.
2026-05-24 10:31:03 -07:00
hanzo-dev 1dbc79387b feat: extend id portal to .network identity hosts
Add iam.lux.network + id.lux.network (Lux brand) + id.zoo.network
(Zoo brand) to the id Ingress + tenant catalog. Same image, same code
path — only DEFAULT_TENANTS + Ingress hosts/tls grow.

Cutover plan (post-merge):
  1. kubectl apply -k infra/k8s/id  (cert-manager issues 3 new TLS certs via DNS-01)
  2. Repoint CF DNS A records → 129.212.164.5
  3. Delete the 3 hanzo-id Worker routes
  4. Archive hanzoai/hanzo.id-worker repo (no routes remaining)
2026-05-24 10:12:23 -07:00
hanzo-dev 74d41880b5 feat(brand): bundle brand logo SVGs as static assets
The published @hanzo/brand / @luxfi/brand / @zooai/brand / @parsdao/brand
npm tarballs don't ship the assets/ directory (only dist/ + brand.json
per the pkg's 'files' field). The Vite plugin can only emit what's
present on disk; result: 404 on /brand/<pkg>/assets/logo/logo.svg.

Workaround until the brand pkgs republish with assets included: commit
the SVGs into apps/web/public/brand/<pkg>/assets/logo/ so they ship in
the SPA bundle directly. Same path the localizeAssets rewrite produces,
so loadBrand() output is unchanged.

To update: re-copy from ~/work/<org>/brand/assets/logo/ and bump
@hanzo/id patch.
2026-05-24 09:39:59 -07:00
hanzo-dev 7853644811 fix(brand): regex now matches @scoped/name pkg URLs
The old regex [^@/]+ excluded the leading @ in @scope/name pkg paths
on jsdelivr, so the rewrite silently passed through to the upstream
CDN URL and the browser rendered a broken-image glyph. Add @? prefix.
2026-05-24 09:32:47 -07:00
hanzo-dev b1ed54303c fix(brand): serve logo + assets from /brand/<pkg>/, switch to DNS-01 issuer
playwright agent flagged: brand.json works but logo SVG 404s on
jsdelivr because the brand pkgs' npm tarballs don't ship assets/logo/.

Fix at this side instead of waiting on a brand-pkg republish:
  1. vite plugin now serves ALL files under <pkg>/assets/ (not just
     brand.json), at /brand/<pkg>/assets/...
  2. loadBrand() in pkgs/shared rewrites the brand.json logoUrl and
     faviconUrl from /npm/<pkg>@latest/<rest> → /brand/<pkg>/<rest>.
     Only the brand's own pkg URLs rewrite; third-party CDN refs pass
     through unchanged.

Also flip the cert-manager cluster-issuer for the id Ingress from
letsencrypt-prod (HTTP-01) to letsencrypt-prod-cf (DNS-01 via
Cloudflare). The Ingress unconditionally 308-redirects all HTTP→HTTPS
including /.well-known/acme-challenge/*, so HTTP-01 never solves.
DNS-01 via CF API works regardless of HTTP path routing.
2026-05-24 09:17:36 -07:00
hanzo-dev d3dbc1e132 fix(vite): brand-json plugin actually emits files at build time
The previous plugin used require.resolve in ESM context which failed
silently — generateBundle had no warning and no assets emitted. Result:
production image had no /brand/<pkg>/brand.json files, hanzoai/spa
fell back to index.html for those paths, and the browser tried to JSON-parse
HTML → 'Unexpected token <' on every host.

Fix: use createRequire(import.meta.url) + paths fallback walk through
node_modules. emitFile now writes dist/brand/<pkg>/brand.json.

Verified by: pnpm build outputs dist/brand/@hanzo/brand/brand.json etc.
playwright report flagged this as the root cause of the white-label
blank page across hanzo.id / lux.id / pars.id.
2026-05-24 08:43:02 -07:00
hanzo-dev f211991862 fix(docker): switch to hanzoai/spa (zero-config SPA server)
hanzoai/static is for traditional static-file serving (no SPA fallback,
needs --spa flag for client-side routing, /healthz only). hanzoai/spa is
purpose-built for SPAs: SPA mode always on, runtime config via SPA_* env
vars, /health endpoint. K8s probes flip /healthz → /health to match.
2026-05-24 07:46:08 -07:00
hanzo-dev 3e774999da fix(docker): static image serves /public on :3000 (not /spa:8080)
hanzoai/static:0.4.1 is hardcoded to read /public and listen on :3000.
PORT/ROOT env vars from the Dockerfile are ignored. Move dist there.

K8s deployment containerPort + probes flipped from 8080 to 3000 to match.
Service targetPort already :3000. Service port 80 stays the same.
2026-05-24 07:12:28 -07:00
hanzo-dev 8545a85d5d deps: pin @hanzo/iam to ^0.9.4 (latest published); drop @hanzo/gui (not published yet)
The 0.10.0 / 7.2.4 versions in source are unpublished WIP. Use the latest
public-npm versions so the Docker build can resolve. @hanzo/gui re-adds
once 7.2.x publishes to npm (per hanzoai/gui PR #2 dist/ emit fix).
2026-05-24 06:57:56 -07:00
hanzo-dev 396b0700f5 ci: amd64-only (arm64 ARC pool paused on DOKS) 2026-05-24 06:50:23 -07:00
hanzo-dev dbaf3079f5 ci: drop pre-build-command — Dockerfile self-contains pnpm build 2026-05-24 06:38:53 -07:00
hanzo-dev 293787a9ff ci: drop CF Pages deploy (was Next.js-only; Vite SPA ships via Docker) 2026-05-24 06:25:55 -07:00
hanzo-dev 0dd2a82d24 ci: add id-token permission + allow pnpm to generate lockfile
Tag-push of v0.1.0 hit startup_failure because the caller workflow lacked
id-token: write. The hanzoai/.github reusable docker-build.yml requires it
at the caller's top-level (see universe LLM.md, 2026-05-05 sprint notes).

Also drop --frozen-lockfile since this is a fresh rewrite without a
checked-in lockfile yet — let pnpm generate one.
2026-05-24 06:09:28 -07:00
hanzo-devandGitHub 6957e9a07d rewrite: Vite + @hanzo/gui monorepo (drops CF Worker + Next.js) (#2)
Replaces the Cloudflare Worker (hanzo.id-worker) and the Next.js portal
with a pnpm monorepo following the a-monorepo/id pattern.

Layout:
  apps/web/        Vite + React 19 SPA, embeds @hanzo/gui shell
    k8s/           Deployment(2) + Service + Ingress (4 hosts, 4 TLS)
  pkgs/shared/     @hanzo/id-shared — TenantConfig, resolveTenant,
                                       loadBrand (browser + node)
  pkgs/auth/       @hanzo/id-auth   — AuthClient (wraps @hanzo/iam REST)
                                       + LoginForm/SignupForm/ForgotForm/OTPForm
  pkgs/idv/        @hanzo/id-idv    — pluggable IDV (stub, persona,
                                       onfido, veriff) behind one
                                       IDVProvider interface
  legacy-nextjs/   Frozen — Next.js predecessor. Delete after v0.1.0 ships.
  Dockerfile       Two-stage: pnpm build → hanzoai/static:0.4.1 serves /spa
  README.md
  LLM.md           Architecture, dev, deploy, cutover plan

Tenant resolution: hostname → TenantConfig (orgId, iamUrl, clientId,
appName, publicOrigin, brandPackage). Built-in defaults for
hanzo.id/lux.id/zoo.id/pars.id; runtime override via
IAM_TENANT_CONFIG_JSON env (served as /config.json at pod startup).

Brand resolution: each per-org brand pkg (@hanzo/brand, @luxfi/brand,
@zooai/brand, @parsdao/brand) ships brand.json. The Vite plugin
brandJsonPlugin emits /brand/<pkg>/brand.json verbatim; the browser
fetches the right one based on the resolved tenant. No bundle bloat.

IDV: stub (default for dev), persona, onfido, veriff. Each adapter
implements `IDVProvider` from pkgs/idv/src/provider.ts. Swap providers
with one registration call at boot — portal code unchanged.

Cutover (separate ops PR — not in this commit):
  1. Tag + push image ghcr.io/hanzoai/id:0.1.0
  2. Apply k8s manifests, cert-manager issues TLS
  3. Remove CF Worker routes for hanzo.id/lux.id/zoo.id/pars.id
  4. CF A records → 129.212.164.5 (hanzo ingress LB)
  5. Archive hanzo.id-worker repo
2026-05-24 06:00:20 -07:00
hanzo-dev aaf43491ab rewrite: Vite + @hanzo/gui monorepo (drops CF Worker + Next.js)
Replaces the Cloudflare Worker (hanzo.id-worker) and the Next.js portal
with a pnpm monorepo following the a-monorepo/id pattern.

Layout:
  apps/web/        Vite + React 19 SPA, embeds @hanzo/gui shell
    k8s/           Deployment(2) + Service + Ingress (4 hosts, 4 TLS)
  pkgs/shared/     @hanzo/id-shared — TenantConfig, resolveTenant,
                                       loadBrand (browser + node)
  pkgs/auth/       @hanzo/id-auth   — AuthClient (wraps @hanzo/iam REST)
                                       + LoginForm/SignupForm/ForgotForm/OTPForm
  pkgs/idv/        @hanzo/id-idv    — pluggable IDV (stub, persona,
                                       onfido, veriff) behind one
                                       IDVProvider interface
  legacy-nextjs/   Frozen — Next.js predecessor. Delete after v0.1.0 ships.
  Dockerfile       Two-stage: pnpm build → hanzoai/static:0.4.1 serves /spa
  README.md
  LLM.md           Architecture, dev, deploy, cutover plan

Tenant resolution: hostname → TenantConfig (orgId, iamUrl, clientId,
appName, publicOrigin, brandPackage). Built-in defaults for
hanzo.id/lux.id/zoo.id/pars.id; runtime override via
IAM_TENANT_CONFIG_JSON env (served as /config.json at pod startup).

Brand resolution: each per-org brand pkg (@hanzo/brand, @luxfi/brand,
@zooai/brand, @parsdao/brand) ships brand.json. The Vite plugin
brandJsonPlugin emits /brand/<pkg>/brand.json verbatim; the browser
fetches the right one based on the resolved tenant. No bundle bloat.

IDV: stub (default for dev), persona, onfido, veriff. Each adapter
implements `IDVProvider` from pkgs/idv/src/provider.ts. Swap providers
with one registration call at boot — portal code unchanged.

Cutover (separate ops PR — not in this commit):
  1. Tag + push image ghcr.io/hanzoai/id:0.1.0
  2. Apply k8s manifests, cert-manager issues TLS
  3. Remove CF Worker routes for hanzo.id/lux.id/zoo.id/pars.id
  4. CF A records → 129.212.164.5 (hanzo ingress LB)
  5. Archive hanzo.id-worker repo
2026-05-24 01:50:39 -07:00
hanzo-dev 63ddef635d fix(middleware): proxy /v1/iam/* canonical IAM surface
Bug: hanzo.id returned 405 on POST /v1/iam/login because:

1. The matcher had no /v1/iam/:path* rule — the middleware never fired,
   the static SPA had no POST handler at that path, CF Pages returned
   405 directly.
2. IAM_PATH_PREFIXES carried '/api/' but not '/v1/iam/' — even if the
   matcher did fire, shouldProxyToIAM() returned false.
3. PATH_REWRITES mapped RFC OAuth paths (/oauth/token, /oauth/userinfo,
   …) onto legacy /api/* targets — wrong direction; IAM serves /v1/iam/*
   natively.

Fix: drop the entire /api/* hop. RFC aliases now collapse onto canonical
/v1/iam/* targets (one-way mapping, no legacy detour). The matcher lists
/v1/iam/:path*. SPA components, lib/oauth.ts, and the server-side logout
handler all call /v1/iam/* directly. The discovery rewriter strips the
canonical /v1/iam/* form back to RFC-public /oauth/* for OIDC clients.

Net: one canonical surface (/v1/iam/*), three RFC-spec public aliases
(/oauth/*, /login/oauth/*, /.well-known/*). No /api/* anywhere in the
caller path.
2026-05-15 14:35:21 -07:00
hanzo-devandGitHub 8ac9f1e2a4 ci: migrate to canonical hanzoai/.github/docker-build.yml reusable (#1) 2026-04-23 18:58:37 -07:00
hanzo-dev 2fae3831be ci: migrate to canonical hanzoai/.github/docker-build.yml reusable 2026-04-23 18:57:29 -07:00
hanzo-dev b8cba121a9 feat: add id.lux.cloud as Lux tenant (was defaulting to Hanzo) 2026-04-20 21:37:05 -07:00
hanzo-dev 1de63b9cc3 refactor: flatten id-{dev,test}.hanzo.ai so *.hanzo.ai Universal SSL covers them 2026-04-20 21:31:45 -07:00
hanzo-dev 3e85378bc2 feat: zoolabs.id as canonical Zoo tenant (replaces zoo.id)
zoolabs.id was just acquired to replace zoo.id which we no longer own.
Same Zoo branding + content + socialProviders as id.zoo.network.
2026-04-20 19:45:15 -07:00
hanzo-dev cbae8df738 feat(branding): TENANT_BRANDING_JSON env var for runtime white-labeling
Allows any deployment of hanzo-login image to add/override tenants without
white-label deployment to inject their own domain branding + auth providers.

Example:
  env:
    - name: TENANT_BRANDING_JSON
      value: |
        {
            "orgId": "liquidity",
            "orgName": "",
            "content": { "title": "Trade digital securities" },
            "auth": { "socialProviders": ["google", "apple"] }
          }
        }
2026-04-20 19:35:09 -07:00
49 changed files with 1095 additions and 3340 deletions
+8 -42
View File
@@ -1,8 +1,4 @@
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:
workflow_dispatch:
push:
@@ -13,41 +9,11 @@ permissions:
packages: write
jobs:
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
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push (linux/amd64)
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
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
uses: hanzoai/.github/.github/workflows/docker-build.yml@main
with:
image: ghcr.io/hanzoai/id
pre-build-command: |
corepack enable
pnpm install --frozen-lockfile
pnpm build
secrets: inherit
-1
View File
@@ -10,7 +10,6 @@ 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
COPY apps apps
+31 -93
View File
@@ -1,28 +1,4 @@
# LLM.md — Hanzo ID
## PKCE on password login (fixed 0.1.13)
`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.
# Hanzo ID
## What this is
@@ -51,57 +27,12 @@ pars.id ──┘ │
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)
https://iam.hanzo.ai (Casdoor fork, Go)
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
```
@@ -109,14 +40,8 @@ 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).
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.
@@ -138,6 +63,29 @@ legacy-nextjs/ Frozen predecessor. Delete after v0.1.0 ships.
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** ("<Brand> 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
```bash
@@ -202,21 +150,11 @@ Custom providers: implement the `IDVProvider` interface in
## 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:
`github.com/hanzoai/iam`, image `ghcr.io/hanzoai/iam`). This portal talks
to it via the routes in `pkgs/auth/src/client.ts`:
- 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).
- `/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
+3 -4
View File
@@ -1,8 +1,8 @@
{
"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.",
"version": "0.1.10",
"description": "Hanzo ID \u2014 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",
@@ -13,10 +13,9 @@
"dependencies": {
"@hanzo/brand": "^1.3.0",
"@hanzo/gui": "^7.2.4",
"@hanzo/iam": "^0.11.0",
"@hanzo/iam": "^0.9.4",
"@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",
+14 -42
View File
@@ -6,7 +6,6 @@ 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
@@ -19,45 +18,17 @@ export function App() {
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
const runtimeCatalog = (window as unknown as { __ID_CATALOG__?: string }).__ID_CATALOG__
const t = resolveTenant(window.location.hostname, { catalog: parseCatalog(runtimeCatalog) })
setTenant(t)
loadBrand(t.brandPackage, t.brandUrl)
.then((b) => {
setBrand(b)
document.title = `Sign in — ${b.name}`
const fav = document.getElementById('favicon') as HTMLLinkElement | null
if (fav && b.faviconUrl) fav.href = b.faviconUrl
} catch (e) {
if (!cancelled) setError(String(e))
}
}
void boot()
return () => {
cancelled = true
}
})
.catch((e) => setError(String(e)))
}, [])
const client = useMemo(() => (tenant ? createAuthClient({ tenant }) : null), [tenant])
@@ -65,11 +36,12 @@ export function App() {
if (error) return <div className="hanzo-id-error">{error}</div>
if (!tenant || !brand || !client) return <div>Loading</div>
// undefined = enabled; only an explicit `false` disables self-service signup.
const signupEnabled = tenant.signupEnabled !== false
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} />
if (path === '/login' || path.startsWith('/login/')) return <Login client={client} brand={brand} tenant={tenant} signupEnabled={signupEnabled} />
if (path === '/signup' || path.startsWith('/signup/')) return signupEnabled ? <Signup client={client} brand={brand} tenant={tenant} /> : <Login client={client} brand={brand} tenant={tenant} signupEnabled={signupEnabled} />
if (path === '/forget' || path === '/forgot' || path.startsWith('/forg')) return <Forgot client={client} brand={brand} tenant={tenant} />
if (path === '/callback' || path.startsWith('/callback/')) return <Callback client={client} brand={brand} tenant={tenant} />
return <Portal client={client} brand={brand} tenant={tenant} signupEnabled={signupEnabled} />
}
+208 -104
View File
@@ -31,14 +31,6 @@ body {
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;
@@ -81,12 +73,6 @@ form input:focus { outline: 2px solid var(--brand); outline-offset: -1px; }
.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;
@@ -103,39 +89,20 @@ form input:focus { outline: 2px solid var(--brand); outline-offset: -1px; }
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;
/* --- Social providers + passwordless (email/SMS) login + signup --- */
.hanzo-id-login,
.hanzo-id-signup,
.hanzo-id-code-login {
display: flex;
flex-direction: column;
gap: 16px;
}
.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-providers {
display: flex;
flex-direction: column;
gap: 10px;
}
.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 {
.hanzo-id-provider-btn {
display: flex;
align-items: center;
justify-content: center;
@@ -144,83 +111,220 @@ form input:focus { outline: 2px solid var(--brand); outline-offset: -1px; }
color: var(--fg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 11px 14px;
padding: 12px 16px;
font-size: 15px;
font-weight: 500;
font-weight: 600;
cursor: pointer;
text-decoration: none;
text-align: center;
}
.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 {
.hanzo-id-provider-btn:hover {
border-color: var(--muted);
background: #161616;
}
.hanzo-id-provider-web3 {
border-color: #3a3357;
}
.hanzo-id-or {
display: flex;
align-items: center;
text-align: center;
gap: 12px;
color: var(--muted);
font-size: 13px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.hanzo-id-divider::before,
.hanzo-id-divider::after {
.hanzo-id-or::before,
.hanzo-id-or::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);
color: var(--muted);
font-size: 14px;
font-weight: 500;
width: auto;
padding: 4px;
cursor: pointer;
text-align: left;
padding: 4px 0;
text-align: center;
}
.hanzo-id-linkbtn:hover {
color: var(--fg);
text-decoration: underline;
}
.hanzo-id-slug-preview { color: var(--muted); font-size: 13px; margin: -8px 0 0; font-family: ui-monospace, monospace; }
.hanzo-id-toggle-mode {
margin-top: 4px;
}
.hanzo-id-notice {
background: #0a1f2d;
color: #78b8ff;
border: 1px solid #14385a;
padding: 12px;
border-radius: 8px;
font-size: 14px;
}
.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; }
/* ============================================================
Split-view login (form left, marketing panel right)
Ported from legacy-nextjs/app/login + components/MarketingPanel
============================================================ */
.hanzo-id-split {
flex: 1;
display: flex;
min-height: 100vh;
}
.hanzo-id-split-form {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
}
@media (min-width: 1024px) {
.hanzo-id-split-form { width: 50%; }
}
.hanzo-id-card {
width: 100%;
max-width: 400px;
display: flex;
flex-direction: column;
gap: 24px;
}
.hanzo-id-card-head { display: flex; align-items: center; justify-content: space-between; }
.hanzo-id-card-head img { display: block; }
.hanzo-id-card-title { margin: 0; font-size: 28px; font-weight: 700; }
.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; }
/* Right panel — hidden below lg, gradient backdrop above */
.hanzo-id-split-brand {
display: none;
}
@media (min-width: 1024px) {
.hanzo-id-split-brand {
width: 50%;
display: flex;
align-items: center;
justify-content: center;
padding: 48px;
background: linear-gradient(135deg, #000 0%, #18181b 50%, #000 100%);
border-left: 1px solid var(--border);
}
}
.hanzo-id-marketing {
max-width: 28rem;
display: flex;
flex-direction: column;
gap: 32px;
}
.hanzo-id-pill {
display: inline-flex;
align-items: center;
gap: 8px;
align-self: flex-start;
padding: 6px 14px;
border-radius: 9999px;
border: 1px solid #3f3f46;
font-size: 14px;
color: #d4d4d8;
}
.hanzo-id-pill-star { color: #facc15; }
.hanzo-id-marketing-title { margin: 0; font-size: 36px; line-height: 1.1; font-weight: 700; color: #fff; }
.hanzo-id-marketing-subtitle { margin: 0; font-size: 18px; color: #a1a1aa; }
.hanzo-id-quote-card {
background: rgba(24, 24, 27, 0.5);
border: 1px solid #27272a;
border-radius: 16px;
padding: 24px;
}
.hanzo-id-quote-card blockquote { margin: 0 0 16px; color: #fff; font-size: 16px; line-height: 1.6; }
.hanzo-id-quote-mark { color: #52525b; font-size: 22px; }
.hanzo-id-quote-author { display: flex; align-items: center; gap: 12px; }
.hanzo-id-quote-avatar {
width: 40px; height: 40px; border-radius: 9999px;
display: flex; align-items: center; justify-content: center;
color: #09090b; font-weight: 600; font-size: 14px; flex: 0 0 auto;
}
.hanzo-id-quote-name { font-weight: 600; color: #fff; }
.hanzo-id-quote-role { font-size: 14px; color: #71717a; }
.hanzo-id-quote-dots { display: flex; justify-content: center; gap: 8px; margin-top: 16px; }
.hanzo-id-quote-dot {
width: 8px; height: 8px; border-radius: 9999px; border: 0;
background: #52525b; cursor: pointer; padding: 0;
}
/* ============================================================
Authenticated portal — apps launcher ("all the apps")
Ported from legacy-nextjs/app/account/page.tsx
============================================================ */
.hanzo-id-loading {
flex: 1; min-height: 100vh;
display: flex; align-items: center; justify-content: center;
background: #000;
}
.hanzo-id-spinner {
width: 32px; height: 32px; border-radius: 9999px;
border: 2px solid #3f3f46; border-top-color: #fff;
animation: hanzo-id-spin 0.8s linear infinite;
}
@keyframes hanzo-id-spin { to { transform: rotate(360deg); } }
.hanzo-id-portal-authed { flex: 1; min-height: 100vh; }
.hanzo-id-nav {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 24px; border-bottom: 1px solid rgba(39, 39, 42, 0.5);
}
.hanzo-id-nav img { display: block; }
.hanzo-id-nav-links { display: flex; align-items: center; gap: 20px; }
.hanzo-id-nav-links a { color: #a1a1aa; font-size: 14px; text-decoration: none; }
.hanzo-id-nav-links a:hover { color: #fff; }
.hanzo-id-portal-body { max-width: 56rem; margin: 0 auto; padding: 48px 24px; }
.hanzo-id-profile { display: flex; align-items: center; gap: 24px; margin-bottom: 48px; }
.hanzo-id-profile-avatar { width: 80px; height: 80px; border-radius: 9999px; object-fit: cover; }
.hanzo-id-profile-avatar-fallback {
display: flex; align-items: center; justify-content: center;
background: rgba(255, 255, 255, 0.08); font-size: 30px; font-weight: 700;
}
.hanzo-id-profile h1 { margin: 0; font-size: 30px; font-weight: 700; color: #fff; }
.hanzo-id-profile .lede { margin: 4px 0 0; }
.hanzo-id-section-title { margin: 0 0 16px; font-size: 18px; font-weight: 600; color: #fff; }
.hanzo-id-apps-grid {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
}
@media (min-width: 640px) { .hanzo-id-apps-grid { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 1024px) { .hanzo-id-apps-grid { grid-template-columns: repeat(3, 1fr); } }
.hanzo-id-app-card {
display: block;
padding: 16px;
border-radius: 12px;
border: 1px solid #27272a;
background: rgba(24, 24, 27, 0.3);
text-decoration: none;
transition: background 0.15s, border-color 0.15s;
}
.hanzo-id-app-card:hover { background: rgba(24, 24, 27, 0.6); border-color: #3f3f46; }
.hanzo-id-app-card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.hanzo-id-app-name { font-weight: 600; color: #fff; }
.hanzo-id-app-arrow { color: #52525b; font-size: 16px; }
.hanzo-id-app-card:hover .hanzo-id-app-arrow { color: #a1a1aa; }
.hanzo-id-app-desc { margin: 0; font-size: 14px; color: #71717a; }
.hanzo-id-portal-footer {
margin-top: 48px; padding-top: 32px; border-top: 1px solid #27272a;
display: flex; align-items: center; justify-content: space-between;
}
.hanzo-id-muted-link { color: #71717a; font-size: 14px; text-decoration: none; }
.hanzo-id-muted-link:hover { color: #fff; }
.hanzo-id-btn.ghost {
background: transparent; color: #a1a1aa;
border: 1px solid #3f3f46; font-size: 14px; padding: 8px 16px;
}
.hanzo-id-btn.ghost:hover { color: #fff; border-color: #71717a; }
+4 -17
View File
@@ -1,24 +1,11 @@
import { useState } from 'react'
import type { BrandContract } from '@hanzo/id-shared'
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
import { BrandLogo } from './BrandLogo'
/**
* 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
export function BrandHeader({ brand, tenant }: { brand: BrandContract; tenant: TenantConfig }) {
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>
)}
<BrandLogo brand={brand} tenant={tenant} height={32} />
</a>
</header>
)
+34
View File
@@ -0,0 +1,34 @@
import { useState } from 'react'
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
/**
* Brand logo with a self-hosted fallback.
*
* `brand.logoUrl` is a CDN URL (jsdelivr). When that 404s or is blocked, we
* fall back to the brand package's logo shipped inside this image at
* `/brand/<pkg>/assets/logo/logo.svg` — the same package `loadBrand` fetches,
* served by the same `hanzoai/spa` server. No external dependency required.
*/
export function BrandLogo({
brand,
tenant,
height = 32,
}: {
brand: BrandContract
tenant: TenantConfig
height?: number
}) {
const local = `/brand/${encodeURIComponent(tenant.brandPackage)}/assets/logo/logo.svg`
const [src, setSrc] = useState(brand.logoUrl || local)
return (
<img
src={src}
alt={brand.name}
height={height}
style={{ height, width: 'auto', display: 'block' }}
onError={() => {
if (src !== local) setSrc(local)
}}
/>
)
}
@@ -0,0 +1,73 @@
import { useEffect, useState } from 'react'
import type { Marketing } from '../marketing'
/**
* Right-hand branding panel for the split-view login. Ported from the frozen
* `legacy-nextjs/components/MarketingPanel.tsx`: a tagline pill, hero copy, and
* an auto-rotating testimonial card. Accent color comes from the brand
* contract; copy comes from the per-org marketing map.
*/
export function MarketingPanel({ marketing, accent }: { marketing: Marketing; accent: string }) {
const quotes = marketing.quotes
const [i, setI] = useState(0)
useEffect(() => {
if (quotes.length <= 1) return
const t = setInterval(() => setI((p) => (p + 1) % quotes.length), 5000)
return () => clearInterval(t)
}, [quotes.length])
const q = quotes[i]
return (
<div className="hanzo-id-marketing">
{marketing.tagline ? (
<div className="hanzo-id-pill">
<span className="hanzo-id-pill-star"></span>
<span>{marketing.tagline}</span>
</div>
) : null}
<h2 className="hanzo-id-marketing-title">{marketing.title}</h2>
<p className="hanzo-id-marketing-subtitle">{marketing.subtitle}</p>
{q ? (
<div className="hanzo-id-quote-card">
<blockquote>
<span className="hanzo-id-quote-mark"></span>
{q.text}
<span className="hanzo-id-quote-mark"></span>
</blockquote>
<div className="hanzo-id-quote-author">
<div className="hanzo-id-quote-avatar" style={{ backgroundColor: accent }}>
{q.author
.split(' ')
.map((n) => n[0])
.join('')
.slice(0, 2)
.toUpperCase()}
</div>
<div>
<div className="hanzo-id-quote-name">{q.author}</div>
{q.role ? <div className="hanzo-id-quote-role">{q.role}</div> : null}
</div>
</div>
{quotes.length > 1 ? (
<div className="hanzo-id-quote-dots">
{quotes.map((_, n) => (
<button
key={n}
type="button"
aria-label={`Show testimonial ${n + 1}`}
className="hanzo-id-quote-dot"
onClick={() => setI(n)}
style={{ backgroundColor: n === i ? accent : undefined }}
/>
))}
</div>
) : null}
</div>
) : null}
</div>
)
}
+19 -75
View File
@@ -1,93 +1,37 @@
import { useEffect, useState } from 'react'
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
import { createIam, createAuthClient } from '@hanzo/id-auth'
import type { AuthClient } 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 }) {
export function Callback({ client, brand, tenant }: { client: AuthClient; brand: BrandContract; tenant: TenantConfig }) {
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)))
const sp = new URLSearchParams(window.location.search)
const code = sp.get('code')
if (!code) {
setError('Missing authorization code.')
return
}
// Case (1): the portal's own OIDC PKCE return.
const iam = createIam(tenant)
iam
.handleCallback(window.location.href)
const codeVerifier = sessionStorage.getItem('pkce_verifier') ?? undefined
client
.exchange(code, codeVerifier)
.then((tok) => {
const target = sessionStorage.getItem('post_login_redirect')
// Forward the tokens to whichever app initiated this flow.
const target = sessionStorage.getItem('post_login_redirect') ?? '/'
const url = new URL(target, window.location.origin)
url.searchParams.set('access_token', tok.accessToken)
if (tok.refreshToken) url.searchParams.set('refresh_token', tok.refreshToken)
if (tok.idToken) url.searchParams.set('id_token', tok.idToken)
sessionStorage.removeItem('pkce_verifier')
sessionStorage.removeItem('post_login_redirect')
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')
window.location.replace(url.toString())
})
.catch((e) => setError(String(e)))
}, [tenant])
}, [client])
return (
<div className="hanzo-id-page hanzo-id-callback">
<BrandHeader brand={brand} />
<BrandHeader brand={brand} tenant={tenant} />
<main>
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : <p>Completing sign-in</p>}
</main>
+3 -3
View File
@@ -1,11 +1,11 @@
import type { BrandContract } from '@hanzo/id-shared'
import type { BrandContract, TenantConfig } 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 }) {
export function Forgot({ client, brand, tenant }: { client: AuthClient; brand: BrandContract; tenant: TenantConfig }) {
return (
<div className="hanzo-id-page hanzo-id-forgot">
<BrandHeader brand={brand} />
<BrandHeader brand={brand} tenant={tenant} />
<main>
<h1>Reset your {brand.name} password</h1>
<ForgotForm client={client} />
+55 -109
View File
@@ -1,119 +1,65 @@
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'
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
import { LoginForm, type AuthClient } from '@hanzo/id-auth'
import { BrandLogo } from '../components/BrandLogo'
import { MarketingPanel } from '../components/MarketingPanel'
import { marketingFor } from '../marketing'
export function Login({ client, brand }: { client: AuthClient; brand: BrandContract }) {
/**
* Split-view login (restored from the legacy Next.js design): the login card
* on the left, the per-org marketing/branding panel on the right (hidden on
* narrow viewports). The auth wiring is untouched — `<LoginForm>` from
* `@hanzo/id-auth` still drives the `/v1/iam/login` + code flow.
*/
export function Login({
client,
brand,
tenant,
signupEnabled,
}: {
client: AuthClient
brand: BrandContract
tenant: TenantConfig
signupEnabled: boolean
}) {
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>
)
}
const accent = brand.accentColor ?? '#ffffff'
const marketing = marketingFor(tenant.orgId)
const search = window.location.search
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 className="hanzo-id-split">
<section className="hanzo-id-split-form">
<div className="hanzo-id-card">
<header className="hanzo-id-card-head">
<a href="/" aria-label={brand.name}>
<BrandLogo brand={brand} tenant={tenant} height={36} />
</a>
</header>
<h1 className="hanzo-id-card-title">Sign in to {brand.name}</h1>
<LoginForm
client={client}
redirectUri={redirectUri}
state={state}
clientIdOverride={clientIdOverride ?? undefined}
/>
<p className="hanzo-id-footer-links">
<a href="/forget">Forgot password?</a>
{signupEnabled ? (
<>
{' '}·{' '}
<a href={`/signup${search}`}>Create account</a>
</>
) : null}
</p>
</div>
</section>
<aside className="hanzo-id-split-brand">
<MarketingPanel marketing={marketing} accent={accent} />
</aside>
</div>
)
}
-72
View File
@@ -1,72 +0,0 @@
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>
}
+111 -68
View File
@@ -1,125 +1,168 @@
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 { BrandLogo } from '../components/BrandLogo'
import { appsFor, billingFor } from '../marketing'
type Auth =
| { s: 'loading' }
| { s: 'anon' }
| { s: 'authed'; name?: string; email?: string }
interface SessionUser {
readonly id: string
readonly name?: string
readonly displayName?: string
readonly email?: string
readonly avatar?: string
}
type AuthState = { status: 'loading' } | { status: 'anon' } | { status: 'authed'; user: SessionUser }
/**
* Root portal (`/`). The portal IS the login surface, not a marketing hero:
* Post-login portal. Detects the IAM session via the same-origin
* `/v1/iam/get-account` proxy (cookie-scoped, `credentials: 'include'`):
*
* - 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.
* - signed in → the apps launcher ("all the apps") + account/billing cards,
* restored from the legacy `account` page.
* - signed out → the brand hero with sign-in / create-account CTAs.
*
* 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.
* No token juggling in localStorage — the portal is a cookie-session OIDC
* provider, so the session lives in the IAM cookie and we just read it.
*/
export function Portal({
client,
brand,
tenant,
signupEnabled,
}: {
client: AuthClient
brand: BrandContract
tenant: TenantConfig
signupEnabled: boolean
}) {
const [auth, setAuth] = useState<Auth>({ s: 'loading' })
const [auth, setAuth] = useState<AuthState>({ status: 'loading' })
useEffect(() => {
let alive = true
// `?signed_in=1` is set by the bare-portal login flow the moment the IAM
// session cookie is established this tab. It's our authoritative "just
// authenticated" signal; `get-account` is the richer-but-best-effort source
// for the user's name/email/avatar.
const justSignedIn = new URLSearchParams(window.location.search).get('signed_in') === '1'
fetch(new URL('/v1/iam/get-account', tenant.iamUrl).toString(), {
fetch(new URL('/v1/iam/get-account', tenant.publicOrigin).toString(), {
credentials: 'include',
headers: { Accept: 'application/json' },
})
.then((r) => r.json())
.then((b: Record<string, unknown>) => {
.then((body: 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) })
const d = body.data as Record<string, unknown> | undefined
if (body.status === 'ok' && d && typeof d === 'object') {
setAuth({
status: 'authed',
user: {
id: String(d.id ?? d.name ?? ''),
name: typeof d.name === 'string' ? d.name : undefined,
displayName: typeof d.displayName === 'string' ? d.displayName : undefined,
email: typeof d.email === 'string' ? d.email : undefined,
avatar: typeof d.avatar === 'string' ? d.avatar : undefined,
},
})
} else if (justSignedIn) {
setAuth({ status: 'authed', user: { id: '' } })
} else {
setAuth(justSignedIn ? { s: 'authed' } : { s: 'anon' })
setAuth({ status: 'anon' })
}
})
.catch(() => {
if (alive) setAuth(justSignedIn ? { s: 'authed' } : { s: 'anon' })
if (!alive) return
setAuth(justSignedIn ? { status: 'authed', user: { id: '' } } : { status: 'anon' })
})
return () => {
alive = false
}
}, [tenant.iamUrl])
}, [tenant.publicOrigin])
if (auth.s === 'loading') {
if (auth.status === 'loading') {
return (
<div className="hanzo-id-page" style={{ minHeight: '40vh' }}>
<div className="hanzo-id-loading">
<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} />
if (auth.status === 'anon') {
return (
<div className="hanzo-id-page hanzo-id-portal">
<header className="hanzo-id-brand-header">
<a href="/" aria-label={brand.name}>
<BrandLogo brand={brand} tenant={tenant} height={32} />
</a>
</header>
<main>
<h1>Welcome to {brand.name}</h1>
<p className="lede">{brand.description}</p>
<div className="hanzo-id-cta-row">
<a className="hanzo-id-btn primary" href="/login">Sign in</a>
{signupEnabled ? <a className="hanzo-id-btn" href="/signup">Create account</a> : null}
</div>
</main>
</div>
)
}
// Signed in: the apps launcher.
const { user } = auth
const apps = appsFor(tenant.orgId)
const billingUrl = billingFor(tenant.orgId)
const logoutUrl = client.logout(undefined, `${tenant.publicOrigin}/login`)
const display = user.displayName || user.name || user.email || 'Account'
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 className="hanzo-id-portal-authed">
<nav className="hanzo-id-nav">
<a href="/" aria-label={brand.name}>
<BrandLogo brand={brand} tenant={tenant} height={28} />
</a>
<div className="hanzo-id-nav-links">
<a href={billingUrl}>Billing</a>
<a href={logoutUrl}>Sign out</a>
</div>
</nav>
<div className="hanzo-id-portal-body">
<div className="hanzo-id-profile">
{user.avatar ? (
<img className="hanzo-id-profile-avatar" src={user.avatar} alt="" />
) : (
<div
className="hanzo-id-profile-avatar hanzo-id-profile-avatar-fallback"
style={{ color: brand.accentColor ?? '#fff' }}
>
<div style={{ fontWeight: 600, display: 'flex', justifyContent: 'space-between' }}>
<span>{a.name}</span>
<span aria-hidden style={{ opacity: 0.5 }}></span>
{display[0]?.toUpperCase()}
</div>
)}
<div>
<h1>{display}</h1>
{user.email ? <p className="lede">{user.email}</p> : null}
</div>
</div>
<h2 className="hanzo-id-section-title">{brand.name} apps</h2>
<div className="hanzo-id-apps-grid">
{apps.map((app) => (
<a key={app.name} className="hanzo-id-app-card" href={app.href}>
<div className="hanzo-id-app-card-head">
<span className="hanzo-id-app-name">{app.name}</span>
<span className="hanzo-id-app-arrow" aria-hidden="true"></span>
</div>
<div style={{ opacity: 0.6, fontSize: 13, marginTop: 4 }}>{a.description}</div>
<p className="hanzo-id-app-desc">{app.description}</p>
</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 className="hanzo-id-portal-footer">
<a className="hanzo-id-muted-link" href={brand.appDomain ? `https://${brand.appDomain}` : '/'}>
Back to {brand.name}
</a>
<a className="hanzo-id-btn ghost" href={logoutUrl}>Sign out</a>
</div>
</main>
</div>
</div>
)
}
function str(v: unknown): string | undefined {
return typeof v === 'string' && v.length > 0 ? v : undefined
}
+30 -21
View File
@@ -1,28 +1,37 @@
import type { BrandContract } from '@hanzo/id-shared'
import { SignupForm, SocialButtons, type AuthClient } from '@hanzo/id-auth'
import { BrandHeader } from '../components/BrandHeader'
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
import { SignupForm, type AuthClient } from '@hanzo/id-auth'
import { BrandLogo } from '../components/BrandLogo'
import { MarketingPanel } from '../components/MarketingPanel'
import { marketingFor } from '../marketing'
export function Signup({ client, brand }: { client: AuthClient; brand: BrandContract }) {
/** Split-view signup, mirroring the restored login layout. */
export function Signup({ client, brand, tenant }: { client: AuthClient; brand: BrandContract; tenant: TenantConfig }) {
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
const accent = brand.accentColor ?? '#ffffff'
const marketing = marketingFor(tenant.orgId)
const search = window.location.search
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 className="hanzo-id-split">
<section className="hanzo-id-split-form">
<div className="hanzo-id-card">
<header className="hanzo-id-card-head">
<a href="/" aria-label={brand.name}>
<BrandLogo brand={brand} tenant={tenant} height={36} />
</a>
</header>
<h1 className="hanzo-id-card-title">Create your {brand.name} account</h1>
<SignupForm client={client} inviteCode={inviteCode} />
<p className="hanzo-id-footer-links">
Already have an account? <a href={`/login${search}`}>Sign in</a>
</p>
</div>
</section>
<aside className="hanzo-id-split-brand">
<MarketingPanel marketing={marketing} accent={accent} />
</aside>
</div>
)
}
+14 -22
View File
@@ -4,46 +4,38 @@ 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)
// Vite loads this config as a native ESM module, where `require` is undefined.
// Build a CJS-style resolver bound to this file so `<pkg>/brand.json` subpath
// resolution works in both `configureServer` and `generateBundle`.
const require = 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`.
* `@parsdao/brand`) ships a `brand.json` at the package root. We serve them
* verbatim at `/brand/<pkg>/brand.json` so the runtime tenant resolver can
* fetch the right one based on hostname.
*
* Assets (logos, favicons) are imported by URL inside the per-brand `brand.json`
* (CDN URLs in production), so no further asset copying is needed.
*/
const BRAND_PACKAGES = ['@hanzo/brand', '@luxfi/brand', '@zooai/brand', '@parsdao/brand']
/** 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 ?? '')
server.middlewares.use((req: any, res: any, next: any) => {
const m = /^\/brand\/(.+)\/brand\.json$/.exec(req.url ?? '')
if (!m) return next()
const slug = m[1]!
const pkg = BRAND_PACKAGES.find((p) => brandSlug(p) === slug)
if (!pkg) {
const pkg = decodeURIComponent(m[1]!)
if (!BRAND_PACKAGES.includes(pkg)) {
res.statusCode = 404
return res.end()
}
try {
const path = req.resolve(`${pkg}/brand.json`)
const path = require.resolve(`${pkg}/brand.json`)
res.setHeader('Content-Type', 'application/json')
res.setHeader('Cache-Control', 'no-store')
return res.end(readFileSync(path, 'utf8'))
@@ -56,11 +48,11 @@ function brandJsonPlugin() {
generateBundle(this: any) {
for (const pkg of BRAND_PACKAGES) {
try {
const path = req.resolve(`${pkg}/brand.json`)
const path = require.resolve(`${pkg}/brand.json`)
if (!existsSync(path)) continue
this.emitFile({
type: 'asset',
fileName: `brand/${brandSlug(pkg)}.json`,
fileName: `brand/${pkg}/brand.json`,
source: readFileSync(path, 'utf8'),
})
} catch {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@hanzo/id",
"private": true,
"version": "0.1.29",
"version": "0.1.1",
"description": "Hanzo ID — white-label login + identity verification portal (Vite + @hanzo/gui)",
"scripts": {
"build": "pnpm -r build",
+3 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzo/id-auth",
"version": "0.1.1",
"version": "0.1.0",
"description": "Composable login / signup / OTP / OAuth-PKCE flows on top of @hanzo/iam. UI primitives in @hanzo/gui.",
"license": "BSD-3-Clause",
"type": "module",
@@ -14,13 +14,11 @@
},
"files": ["src"],
"scripts": {
"tc": "tsc --noEmit",
"test": "node --test --experimental-strip-types 'src/**/*.test.ts'"
"tc": "tsc --noEmit"
},
"dependencies": {
"@hanzo/id-shared": "workspace:*",
"@hanzo/iam": "^0.11.0",
"@paulmillr/qr": "^0.3.0"
"@hanzo/iam": "^0.9.4"
},
"peerDependencies": {
"react": ">=19",
-150
View File
@@ -1,150 +0,0 @@
/**
* 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')
})
+75 -299
View File
@@ -1,27 +1,15 @@
import type { TenantConfig } from '@hanzo/id-shared'
import type {
AppLogin,
AppProvider,
AppLoginInfo,
CodeLoginRequest,
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.
*
@@ -51,63 +39,12 @@ export interface AuthClient {
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
/** Fetch the application's enabled providers + sign-in methods (drives which buttons render). */
appLogin(): Promise<AppLoginInfo>
/** Send an email/SMS verification code for passwordless login. dest = email or E.164 phone. */
sendLoginCode(dest: string): Promise<{ ok: boolean; error?: string }>
/** Complete a passwordless login with the code sent to dest. */
loginWithCode(req: CodeLoginRequest): Promise<LoginResponse>
}
export interface AuthClientOptions {
@@ -128,10 +65,6 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
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',
@@ -202,6 +135,7 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
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.provider) url.searchParams.set('provider', req.provider)
if (req.nonce) url.searchParams.set('nonce', req.nonce)
if (req.codeChallenge) {
url.searchParams.set('code_challenge', req.codeChallenge)
@@ -246,145 +180,72 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
return url.toString()
}
async function getAppLogin(clientId?: string): Promise<AppLogin | null> {
const id = clientId ?? tenant.clientId
// appLogin fetches the application's enabled providers + sign-in methods so
// the UI renders exactly what the IAM app offers (social buttons, code login).
async function appLogin(): Promise<AppLoginInfo> {
const url = new URL('/v1/iam/get-app-login', tenant.iamUrl)
url.searchParams.set('clientId', id)
url.searchParams.set('clientId', tenant.clientId)
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
url.searchParams.set('state', 'login')
const res = await f(url.toString(), { credentials: 'include' })
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>
const d = (body.data ?? {}) as Record<string, unknown>
const providers = Array.isArray(d.providers)
? (d.providers as Array<Record<string, unknown>>).map((p) => {
const prov = (p.provider ?? {}) as Record<string, unknown>
return {
name: String(p.name ?? prov.name ?? ''),
displayName: typeof prov.displayName === 'string' ? prov.displayName : undefined,
type: typeof prov.type === 'string' ? prov.type : undefined,
category: typeof prov.category === 'string' ? prov.category : undefined,
canSignIn: p.canSignIn !== false,
canSignUp: p.canSignUp !== false,
}
})
: []
const signinMethods = Array.isArray(d.signinMethods)
? (d.signinMethods as Array<Record<string, unknown>>).map((m) => ({
name: String(m.name ?? ''),
rule: typeof m.rule === 'string' ? m.rule : undefined,
}))
: []
return {
name: String(d.name ?? tenant.appName),
displayName: typeof d.displayName === 'string' ? d.displayName : undefined,
providers,
signinMethods,
enablePassword: d.enablePassword !== false,
enableCodeSignin: d.enableCodeSignin === true,
enableSignUp: d.enableSignUp !== false,
}
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)
}
}
async function sendLoginCode(dest: string): Promise<{ ok: boolean; error?: string }> {
const url = new URL('/v1/iam/send-verification-code', tenant.iamUrl)
url.searchParams.set('clientId', tenant.clientId)
url.searchParams.set('organization', tenant.orgId)
const isEmail = dest.includes('@')
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,
applicationId: `admin/${tenant.appName}`,
organization: tenant.orgId,
dest,
type: isEmail ? 'email' : 'phone',
method: 'login',
checkUser: dest,
}),
})
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' })
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }
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
if (body.status === 'error') return { ok: false, error: typeof body.msg === 'string' ? body.msg : 'failed' }
return { ok: true }
}
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> {
async function loginWithCode(req: CodeLoginRequest): Promise<LoginResponse> {
const type = req.redirectUri ? 'code' : 'login'
const url = new URL('/v1/iam/login', tenant.iamUrl)
url.searchParams.set('clientId', req.clientId)
@@ -392,24 +253,21 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
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 isEmail = req.dest.includes('@')
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,
username: req.dest,
code: req.code,
application: req.application,
organization: req.organization,
enableMfaRemember: req.rememberDevice ?? false,
signinMethod: 'Verification code',
...(isEmail ? { email: req.dest } : { phone: req.dest }),
autoSignin: true,
}),
})
return parseLoginResponse(res, req)
@@ -423,76 +281,9 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
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,
appLogin,
sendLoginCode,
loginWithCode,
}
}
@@ -511,23 +302,6 @@ async function parseLoginResponse(
}
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) {
@@ -537,12 +311,12 @@ async function parseLoginResponse(
}
}
// 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.
// Bare portal sign-in: the IAM session cookie is now set; land on the portal.
// The `signed_in` marker tells the portal the session was just established
// this tab — it shows the apps launcher even if the cross-proxy
// `get-account` session lookup hasn't propagated yet.
if (!req?.redirectUri) {
return { redirectUrl: '/onboarding' }
return { redirectUrl: '/?signed_in=1' }
}
// Fallback: a nested token payload (future direct-token IAM responses).
@@ -552,5 +326,7 @@ async function parseLoginResponse(
refreshToken: typeof d.refresh_token === 'string' ? d.refresh_token : undefined,
idToken: typeof d.id_token === 'string' ? d.id_token : undefined,
expiresAt: typeof d.expires_at === 'number' ? d.expires_at : undefined,
mfaRequired: d.mfa_required === true,
mfaChannel: typeof d.mfa_channel === 'string' ? (d.mfa_channel as LoginResponse['mfaChannel']) : undefined,
}
}
-21
View File
@@ -1,21 +0,0 @@
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',
})
}
+5 -20
View File
@@ -1,29 +1,14 @@
export {
createAuthClient,
mfaChannelOf,
MFA_TOTP,
type AuthClient,
type AuthClientOptions,
} from './client'
export { createIam } from './iam'
export {
startProviderLogin,
buildProviderAuthUrl,
isHoppableProvider,
type ProviderLoginParams,
} from './social'
export { createAuthClient, type AuthClient, type AuthClientOptions } from './client'
export type {
LoginRequest,
LoginResponse,
MfaChannel,
MfaChallengeRequest,
MfaIdentity,
MfaSetup,
SignupRequest,
ForgotRequest,
OAuthAuthorizeRequest,
TokenResponse,
AppLogin,
AppProvider,
ProviderInfo,
SigninMethod,
AppLoginInfo,
CodeLoginRequest,
} from './types'
export * from './ui'
-89
View File
@@ -1,89 +0,0 @@
/**
* 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)
})
-103
View File
@@ -1,103 +0,0 @@
/**
* 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)
}
+1 -110
View File
@@ -6,79 +6,19 @@ export interface LoginRequest {
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 mfaChannel?: 'totp' | 'sms' | 'email'
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
@@ -156,52 +96,3 @@ export interface TokenResponse {
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[]
}
-8
View File
@@ -1,8 +0,0 @@
/** 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>
)
}
+166 -37
View File
@@ -1,26 +1,58 @@
import { useState, type FormEvent } from 'react'
import { useEffect, useState, type FormEvent } from 'react'
import type { AuthClient } from '../client'
import type { LoginResponse } from '../types'
import type { AppLoginInfo, LoginResponse } from '../types'
import { ProviderButtons } from './ProviderButtons'
import { OTPForm } from './OTPForm'
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 [app, setApp] = useState<AppLoginInfo | null>(null)
const [mode, setMode] = useState<'password' | 'code'>('password')
const [identifier, setIdentifier] = useState('')
const [password, setPassword] = useState('')
const [codeSent, setCodeSent] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [notice, setNotice] = useState<string | null>(null)
async function onSubmit(e: FormEvent) {
// Load the application's providers + sign-in methods so we render exactly
// what IAM offers (social buttons, email/SMS code). Best-effort: on failure
// we still show password login.
useEffect(() => {
let alive = true
client
.appLogin()
.then((a) => {
if (alive) setApp(a)
})
.catch(() => {})
return () => {
alive = false
}
}, [client])
const codeEnabled =
!!app &&
(app.enableCodeSignin ||
app.signinMethods.some((m) => m.name === 'Verification code' && m.rule !== 'None'))
function handleResult(res: LoginResponse) {
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)
}
async function onPasswordSubmit(e: FormEvent) {
e.preventDefault()
setBusy(true)
setError(null)
@@ -33,17 +65,27 @@ export function LoginForm(props: LoginFormProps) {
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
handleResult(res)
} catch (err) {
setError(String(err))
} finally {
setBusy(false)
}
}
async function onSendCode(e: FormEvent) {
e.preventDefault()
setBusy(true)
setError(null)
setNotice(null)
try {
const r = await client.sendLoginCode(identifier)
if (r.ok) {
setCodeSent(true)
setNotice(`Code sent to ${identifier}`)
} else {
props.onSuccess?.(res)
setError(r.error ?? 'Could not send code')
}
} catch (err) {
setError(String(err))
@@ -52,30 +94,117 @@ export function LoginForm(props: LoginFormProps) {
}
}
async function onVerifyCode(code: string) {
setBusy(true)
setError(null)
try {
const res = await client.loginWithCode({
dest: identifier,
code,
clientId: props.clientIdOverride ?? client.tenant.clientId,
application: client.tenant.appName,
organization: client.tenant.orgId,
redirectUri: props.redirectUri,
state: props.state,
})
handleResult(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
<div className="hanzo-id-login">
{app && app.providers.length > 0 ? (
<ProviderButtons
client={client}
providers={app.providers}
mode="login"
redirectUri={props.redirectUri}
state={props.state}
clientIdOverride={props.clientIdOverride}
/>
</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>
) : null}
{mode === 'password' ? (
<form onSubmit={onPasswordSubmit} 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>
) : (
<div className="hanzo-id-code-login">
{!codeSent ? (
<form onSubmit={onSendCode} className="hanzo-id-login-form" aria-busy={busy}>
<label>
<span>Email or phone</span>
<input
type="text"
autoComplete="username"
placeholder="you@example.com or +1 555 555 5555"
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 code'}</button>
</form>
) : (
<>
{notice ? <p className="hanzo-id-notice">{notice}</p> : null}
<OTPForm channel={identifier.includes('@') ? 'email' : 'sms'} onSubmit={onVerifyCode} />
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
<button
type="button"
className="hanzo-id-linkbtn"
onClick={() => {
setCodeSent(false)
setNotice(null)
}}
>
Use a different address
</button>
</>
)}
</div>
)}
{codeEnabled ? (
<button
type="button"
className="hanzo-id-linkbtn hanzo-id-toggle-mode"
onClick={() => {
setMode(mode === 'password' ? 'code' : 'password')
setError(null)
setNotice(null)
setCodeSent(false)
}}
>
{mode === 'password' ? 'Sign in with email or SMS code' : 'Sign in with password'}
</button>
) : null}
</div>
)
}
-132
View File
@@ -1,132 +0,0 @@
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>
)
}
-2
View File
@@ -1,5 +1,4 @@
import { useState, type FormEvent } from 'react'
import { SmsConsentNotice } from './SmsConsent'
export interface OTPFormProps {
readonly onSubmit: (code: string) => void | Promise<void>
@@ -40,7 +39,6 @@ export function OTPForm(props: OTPFormProps) {
required
/>
</label>
{channel === 'sms' ? <SmsConsentNotice /> : null}
<button type="submit" disabled={busy || code.length !== length}>{busy ? 'Verifying…' : 'Verify'}</button>
</form>
)
+41 -20
View File
@@ -1,5 +1,7 @@
import { useState, type FormEvent } from 'react'
import { useEffect, useState, type FormEvent } from 'react'
import type { AuthClient } from '../client'
import type { AppLoginInfo } from '../types'
import { ProviderButtons } from './ProviderButtons'
export interface SignupFormProps {
readonly client: AuthClient
@@ -9,11 +11,25 @@ export interface SignupFormProps {
export function SignupForm(props: SignupFormProps) {
const { client } = props
const [app, setApp] = useState<AppLoginInfo | null>(null)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let alive = true
client
.appLogin()
.then((a) => {
if (alive) setApp(a)
})
.catch(() => {})
return () => {
alive = false
}
}, [client])
async function onSubmit(e: FormEvent) {
e.preventDefault()
setBusy(true)
@@ -38,24 +54,29 @@ export function SignupForm(props: SignupFormProps) {
}
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>
<div className="hanzo-id-signup">
{app && app.providers.length > 0 ? (
<ProviderButtons client={client} providers={app.providers} mode="signup" />
) : null}
<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>
</div>
)
}
-38
View File
@@ -1,38 +0,0 @@
// 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>
)
}
-169
View File
@@ -1,169 +0,0 @@
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 />
</>
)
}
-43
View File
@@ -1,43 +0,0 @@
/**
* 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>
)
}
+1 -4
View File
@@ -2,7 +2,4 @@ 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'
export { ProviderButtons } from './ProviderButtons'
+1 -2
View File
@@ -4,6 +4,5 @@
"outDir": "dist",
"noEmit": true
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
"include": ["src"]
}
-36
View File
@@ -1,36 +0,0 @@
{
"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"
}
}
-108
View File
@@ -1,108 +0,0 @@
/**
* 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
}
-30
View File
@@ -1,30 +0,0 @@
// @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'
-143
View File
@@ -1,143 +0,0 @@
/**
* 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' })
})
-207
View File
@@ -1,207 +0,0 @@
/**
* 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)
}
-452
View File
@@ -1,452 +0,0 @@
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 isnt 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>Youre 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 dont 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
}
-9
View File
@@ -1,9 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"noEmit": true
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzo/id-shared",
"version": "0.1.1",
"version": "0.1.0",
"description": "Shared types + tenant resolver for the Hanzo ID portal. No UI deps.",
"license": "BSD-3-Clause",
"type": "module",
@@ -15,8 +15,7 @@
"files": ["src"],
"scripts": {
"tc": "tsc --noEmit",
"build": "tsc --noEmit",
"test": "node --test --experimental-strip-types 'src/**/*.test.ts'"
"build": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.9.3"
+27 -27
View File
@@ -13,29 +13,31 @@ import type { BrandContract } from './types'
* 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.
export async function loadBrand(brandPackage: string, brandUrl?: string): Promise<BrandContract> {
// Browser: try the tenant's brandUrl (e.g. the jsDelivr-hosted brand.json
// from config.json) first, then the app-local /brand/<pkg>/brand.json.
//
// Branding is cosmetic — it must NEVER block the login form. The SPA server
// answers unknown paths with index.html (HTTP 200, text/html) for client-
// side routing, so a missing brand.json comes back as HTML, not a 404. We
// therefore reject any non-JSON response and, if every candidate fails,
// return a minimal fallback brand so the app still renders. (Previously this
// did `res.json()` on the HTML fallback → "Unexpected token '<'" → blank page.)
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++) {
const candidates = [brandUrl, `/brand/${brandPackage}/brand.json`].filter(
(u): u is string => typeof u === 'string' && u.length > 0,
)
for (const url of candidates) {
try {
const res = await fetch(url, { cache: 'no-store' })
if (res.ok) {
const raw = await res.json()
return raw.brand as BrandContract
}
if (!res.ok) continue
const ct = res.headers.get('content-type') ?? ''
if (!ct.includes('json')) continue // SPA fallback HTML — not our brand.json
const raw = (await res.json()) as { brand?: BrandContract }
if (raw?.brand) return raw.brand
} catch {
// network error — fall through to retry
// try the next candidate
}
if (attempt < 2) await new Promise((r) => setTimeout(r, 150 * (attempt + 1)))
}
return fallbackBrand(brandPackage)
}
@@ -47,22 +49,20 @@ export async function loadBrand(brandPackage: string): Promise<BrandContract> {
}
/**
* 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.
* Minimal brand used only when no brand.json could be loaded keeps the login
* form rendering. Derives a display name from the package scope
* (`@hanzo/brand` "Hanzo").
*/
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)
const scope = brandPackage.match(/@([^/]+)\//)?.[1] ?? 'hanzo'
const name = scope.charAt(0).toUpperCase() + scope.slice(1)
return {
name,
title: name,
title: 'Sign in',
description: '',
appDomain: '',
logoUrl: '',
faviconUrl: '',
faviconUrl: 'data:,',
}
}
-87
View File
@@ -1,87 +0,0 @@
/**
* 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' },
})
})
+12 -117
View File
@@ -16,26 +16,10 @@ import type { TenantConfig } from './types'
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',
iamUrl: 'https://iam.hanzo.ai',
iamIssuer: 'https://hanzo.id',
clientId: 'hanzo-id',
appName: 'hanzo-id',
@@ -44,7 +28,7 @@ const DEFAULT_TENANTS: Record<string, TenantConfig> = {
},
'lux.id': {
orgId: 'lux',
iamUrl: 'https://lux.id',
iamUrl: 'https://iam.hanzo.ai',
iamIssuer: 'https://lux.id',
clientId: 'lux-id',
appName: 'lux-id',
@@ -53,7 +37,7 @@ const DEFAULT_TENANTS: Record<string, TenantConfig> = {
},
'zoo.id': {
orgId: 'zoo',
iamUrl: 'https://zoo.id',
iamUrl: 'https://iam.hanzo.ai',
iamIssuer: 'https://zoo.id',
clientId: 'zoo-id',
appName: 'zoo-id',
@@ -62,53 +46,18 @@ const DEFAULT_TENANTS: Record<string, TenantConfig> = {
},
'pars.id': {
orgId: 'pars',
iamUrl: 'https://pars.id',
iamUrl: 'https://iam.hanzo.ai',
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',
clientId: 'pars-id',
appName: 'pars-id',
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>
readonly catalog?: Record<string, Partial<TenantConfig>>
/** Default org slug when host has no entry. */
readonly defaultOrg?: string
}
@@ -118,11 +67,10 @@ export function resolveTenant(hostname: string, opts: ResolveOptions = {}): Tena
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
const merged: TenantConfig = {
...(builtIn ?? DEFAULT_TENANTS['hanzo.id']),
...(catalogEntry ?? {}),
} as TenantConfig
return normalize(merged)
}
const defaultOrg = opts.defaultOrg ?? 'hanzo'
@@ -130,69 +78,16 @@ export function resolveTenant(hostname: string, opts: ResolveOptions = {}): Tena
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),
publicOrigin: TRIM_TRAILING_SLASH(t.publicOrigin),
}
}
+6 -10
View File
@@ -18,18 +18,14 @@ export interface TenantConfig {
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
/**
* Allow self-service account creation. Defaults to true (undefined = true).
* Set false for invite-only / admin-provisioned tenants: the `/signup` route
* falls back to `/login` and every "Create account" link is hidden.
*/
readonly signupEnabled?: boolean
/** 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
+1 -2
View File
@@ -4,6 +4,5 @@
"outDir": "dist",
"noEmit": true
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
"include": ["src"]
}
+146 -183
View File
@@ -4,6 +4,10 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
overrides:
esbuild: ^0.28.1
uuid: ^11.1.1
importers:
.:
@@ -21,17 +25,14 @@ importers:
specifier: ^7.2.4
version: 7.3.0(expo@56.0.12)(react-dom@19.2.7(react@19.2.7))(react-native@0.86.0(@babel/core@7.29.7)(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(sf-symbols-typescript@2.2.0)
'@hanzo/iam':
specifier: ^0.11.0
version: 0.11.0(react@19.2.7)
specifier: ^0.9.4
version: 0.9.4(react@19.2.7)
'@hanzo/id-auth':
specifier: workspace:*
version: link:../../pkgs/auth
'@hanzo/id-idv':
specifier: workspace:*
version: link:../../pkgs/idv
'@hanzo/id-onboarding':
specifier: workspace:*
version: link:../../pkgs/onboarding
'@hanzo/id-shared':
specifier: workspace:*
version: link:../../pkgs/shared
@@ -73,14 +74,11 @@ importers:
pkgs/auth:
dependencies:
'@hanzo/iam':
specifier: ^0.11.0
version: 0.11.0(react@19.2.7)
specifier: ^0.9.4
version: 0.9.4(react@19.2.7)
'@hanzo/id-shared':
specifier: workspace:*
version: link:../shared
'@paulmillr/qr':
specifier: ^0.3.0
version: 0.3.0
devDependencies:
'@types/react':
specifier: ^19.0.0
@@ -114,31 +112,6 @@ importers:
specifier: ^5.9.3
version: 5.9.3
pkgs/onboarding:
dependencies:
'@hanzo/iam':
specifier: ^0.11.0
version: 0.11.0(react@19.2.7)
'@hanzo/id-shared':
specifier: workspace:*
version: link:../shared
devDependencies:
'@types/node':
specifier: ^22.0.0
version: 22.19.21
'@types/react':
specifier: ^19.0.0
version: 19.2.17
react:
specifier: ^19.2.0
version: 19.2.7
react-dom:
specifier: ^19.2.0
version: 19.2.7(react@19.2.7)
typescript:
specifier: ^5.9.3
version: 5.9.3
pkgs/shared:
devDependencies:
typescript:
@@ -508,158 +481,158 @@ packages:
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
engines: {node: '>=6.9.0'}
'@esbuild/aix-ppc64@0.27.7':
resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==}
'@esbuild/aix-ppc64@0.28.1':
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.27.7':
resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==}
'@esbuild/android-arm64@0.28.1':
resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.27.7':
resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==}
'@esbuild/android-arm@0.28.1':
resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.27.7':
resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==}
'@esbuild/android-x64@0.28.1':
resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.27.7':
resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==}
'@esbuild/darwin-arm64@0.28.1':
resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.27.7':
resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==}
'@esbuild/darwin-x64@0.28.1':
resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.27.7':
resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==}
'@esbuild/freebsd-arm64@0.28.1':
resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.7':
resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==}
'@esbuild/freebsd-x64@0.28.1':
resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.27.7':
resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==}
'@esbuild/linux-arm64@0.28.1':
resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.27.7':
resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==}
'@esbuild/linux-arm@0.28.1':
resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.27.7':
resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==}
'@esbuild/linux-ia32@0.28.1':
resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.27.7':
resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==}
'@esbuild/linux-loong64@0.28.1':
resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.27.7':
resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==}
'@esbuild/linux-mips64el@0.28.1':
resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.27.7':
resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==}
'@esbuild/linux-ppc64@0.28.1':
resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.27.7':
resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==}
'@esbuild/linux-riscv64@0.28.1':
resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.27.7':
resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==}
'@esbuild/linux-s390x@0.28.1':
resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.27.7':
resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==}
'@esbuild/linux-x64@0.28.1':
resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.27.7':
resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==}
'@esbuild/netbsd-arm64@0.28.1':
resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.7':
resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==}
'@esbuild/netbsd-x64@0.28.1':
resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.27.7':
resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==}
'@esbuild/openbsd-arm64@0.28.1':
resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.7':
resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==}
'@esbuild/openbsd-x64@0.28.1':
resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.7':
resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==}
'@esbuild/openharmony-arm64@0.28.1':
resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.27.7':
resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==}
'@esbuild/sunos-x64@0.28.1':
resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.27.7':
resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==}
'@esbuild/win32-arm64@0.28.1':
resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.27.7':
resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==}
'@esbuild/win32-ia32@0.28.1':
resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.27.7':
resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==}
'@esbuild/win32-x64@0.28.1':
resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
@@ -850,8 +823,8 @@ packages:
react: '>=19'
react-native: '*'
'@hanzo/iam@0.11.0':
resolution: {integrity: sha512-AGTYSl+En0Ci3UTBJs/kJ1MjxIAKOW9pMwLD6QCFLsBZIZCp1JljESepTEs4aBLifK0L3h/j3ya53ZyU3dSXjA==}
'@hanzo/iam@0.9.4':
resolution: {integrity: sha512-IJzjvwZct2TB1PpMaWJvytud/DYLTek14qZ8UBpTjVtFWm/bkgcny46CfmhE7IA8EGKCkV8Fvr4Nu82K6eNqpA==}
engines: {node: '>=18'}
peerDependencies:
react: '>=17'
@@ -1463,10 +1436,6 @@ packages:
peerDependencies:
react: '>=18.0.0'
'@paulmillr/qr@0.3.0':
resolution: {integrity: sha512-3s/cagXuoXTA2gWSfSfJNanNgm2ifmqgoX8WLOs5//3qrIJ3WWHFjqFqCxvYGf46Afwv6PctT9eAOXLDGwp96Q==}
deprecated: 'Switch to "qr" (new package name) for security updates: npm install qr'
'@react-native/assets-registry@0.86.0':
resolution: {integrity: sha512-nIaXbm2jX1OTYp0qbviJ3O6KZivoE8z3BnhUQ2LsqfZSWRoOK/n1qsiAr6oALiNKWnXY3j2KPwtYORnZzp8xew==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
@@ -1588,66 +1557,79 @@ packages:
resolution: {integrity: sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.62.0':
resolution: {integrity: sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.62.0':
resolution: {integrity: sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.62.0':
resolution: {integrity: sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.62.0':
resolution: {integrity: sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.62.0':
resolution: {integrity: sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.62.0':
resolution: {integrity: sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.62.0':
resolution: {integrity: sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.62.0':
resolution: {integrity: sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.62.0':
resolution: {integrity: sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.62.0':
resolution: {integrity: sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.62.0':
resolution: {integrity: sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.62.0':
resolution: {integrity: sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.62.0':
resolution: {integrity: sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==}
@@ -1730,9 +1712,6 @@ packages:
'@types/istanbul-reports@3.0.4':
resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==}
'@types/node@22.19.21':
resolution: {integrity: sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==}
'@types/node@25.9.3':
resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==}
@@ -2095,8 +2074,8 @@ packages:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
esbuild@0.27.7:
resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==}
esbuild@0.28.1:
resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==}
engines: {node: '>=18'}
hasBin: true
@@ -2429,9 +2408,6 @@ packages:
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
engines: {node: '>=6'}
libphonenumber-js@1.13.7:
resolution: {integrity: sha512-rvr3HIMdOgzhz1RFGjftji+wjoAFlzhqCNqJOU/MKTZQ8d9NZxAR/tI+0weDicyoucqVR0U1GCniqHJ0f8aM2A==}
lighthouse-logger@1.4.2:
resolution: {integrity: sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==}
@@ -2470,24 +2446,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -3069,9 +3049,6 @@ packages:
uid2@0.0.4:
resolution: {integrity: sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici-types@7.24.6:
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
@@ -3110,9 +3087,8 @@ packages:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'}
uuid@7.0.3:
resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
uuid@11.1.1:
resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==}
hasBin: true
validate-npm-package-name@5.0.1:
@@ -3716,82 +3692,82 @@ snapshots:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
'@esbuild/aix-ppc64@0.27.7':
'@esbuild/aix-ppc64@0.28.1':
optional: true
'@esbuild/android-arm64@0.27.7':
'@esbuild/android-arm64@0.28.1':
optional: true
'@esbuild/android-arm@0.27.7':
'@esbuild/android-arm@0.28.1':
optional: true
'@esbuild/android-x64@0.27.7':
'@esbuild/android-x64@0.28.1':
optional: true
'@esbuild/darwin-arm64@0.27.7':
'@esbuild/darwin-arm64@0.28.1':
optional: true
'@esbuild/darwin-x64@0.27.7':
'@esbuild/darwin-x64@0.28.1':
optional: true
'@esbuild/freebsd-arm64@0.27.7':
'@esbuild/freebsd-arm64@0.28.1':
optional: true
'@esbuild/freebsd-x64@0.27.7':
'@esbuild/freebsd-x64@0.28.1':
optional: true
'@esbuild/linux-arm64@0.27.7':
'@esbuild/linux-arm64@0.28.1':
optional: true
'@esbuild/linux-arm@0.27.7':
'@esbuild/linux-arm@0.28.1':
optional: true
'@esbuild/linux-ia32@0.27.7':
'@esbuild/linux-ia32@0.28.1':
optional: true
'@esbuild/linux-loong64@0.27.7':
'@esbuild/linux-loong64@0.28.1':
optional: true
'@esbuild/linux-mips64el@0.27.7':
'@esbuild/linux-mips64el@0.28.1':
optional: true
'@esbuild/linux-ppc64@0.27.7':
'@esbuild/linux-ppc64@0.28.1':
optional: true
'@esbuild/linux-riscv64@0.27.7':
'@esbuild/linux-riscv64@0.28.1':
optional: true
'@esbuild/linux-s390x@0.27.7':
'@esbuild/linux-s390x@0.28.1':
optional: true
'@esbuild/linux-x64@0.27.7':
'@esbuild/linux-x64@0.28.1':
optional: true
'@esbuild/netbsd-arm64@0.27.7':
'@esbuild/netbsd-arm64@0.28.1':
optional: true
'@esbuild/netbsd-x64@0.27.7':
'@esbuild/netbsd-x64@0.28.1':
optional: true
'@esbuild/openbsd-arm64@0.27.7':
'@esbuild/openbsd-arm64@0.28.1':
optional: true
'@esbuild/openbsd-x64@0.27.7':
'@esbuild/openbsd-x64@0.28.1':
optional: true
'@esbuild/openharmony-arm64@0.27.7':
'@esbuild/openharmony-arm64@0.28.1':
optional: true
'@esbuild/sunos-x64@0.27.7':
'@esbuild/sunos-x64@0.28.1':
optional: true
'@esbuild/win32-arm64@0.27.7':
'@esbuild/win32-arm64@0.28.1':
optional: true
'@esbuild/win32-ia32@0.27.7':
'@esbuild/win32-ia32@0.28.1':
optional: true
'@esbuild/win32-x64@0.27.7':
'@esbuild/win32-x64@0.28.1':
optional: true
'@expo/cli@56.1.16(@expo/dom-webview@56.0.5)(expo-constants@56.0.18(expo@56.0.12)(react-native@0.86.0(@babel/core@7.29.7)(@types/react@19.2.17)(react@19.2.7)))(expo-font@56.0.7(expo@56.0.12)(react-native@0.86.0(@babel/core@7.29.7)(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(expo@56.0.12)(react-dom@19.2.7(react@19.2.7))(react-native@0.86.0(@babel/core@7.29.7)(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@5.9.3)':
@@ -4249,10 +4225,9 @@ snapshots:
- sf-symbols-typescript
- zeego
'@hanzo/iam@0.11.0(react@19.2.7)':
'@hanzo/iam@0.9.4(react@19.2.7)':
dependencies:
jose: 6.2.3
libphonenumber-js: 1.13.7
passport-oauth2: 1.8.0
optionalDependencies:
react: 19.2.7
@@ -6132,7 +6107,7 @@ snapshots:
'@jest/schemas': 29.6.3
'@types/istanbul-lib-coverage': 2.0.6
'@types/istanbul-reports': 3.0.4
'@types/node': 22.19.21
'@types/node': 25.9.3
'@types/yargs': 17.0.35
chalk: 4.1.2
@@ -6166,8 +6141,6 @@ snapshots:
dependencies:
react: 19.2.7
'@paulmillr/qr@0.3.0': {}
'@react-native/assets-registry@0.86.0': {}
'@react-native/babel-plugin-codegen@0.85.3(@babel/core@7.29.7)':
@@ -6428,14 +6401,9 @@ snapshots:
dependencies:
'@types/istanbul-lib-report': 3.0.3
'@types/node@22.19.21':
dependencies:
undici-types: 6.21.0
'@types/node@25.9.3':
dependencies:
undici-types: 7.24.6
optional: true
'@types/react-dom@19.2.3(@types/react@19.2.17)':
dependencies:
@@ -6674,7 +6642,7 @@ snapshots:
chrome-launcher@0.15.2:
dependencies:
'@types/node': 22.19.21
'@types/node': 25.9.3
escape-string-regexp: 4.0.0
is-wsl: 2.2.0
lighthouse-logger: 1.4.2
@@ -6683,7 +6651,7 @@ snapshots:
chromium-edge-launcher@0.3.0:
dependencies:
'@types/node': 22.19.21
'@types/node': 25.9.3
escape-string-regexp: 4.0.0
is-wsl: 2.2.0
lighthouse-logger: 1.4.2
@@ -6810,34 +6778,34 @@ snapshots:
es-errors@1.3.0: {}
esbuild@0.27.7:
esbuild@0.28.1:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.7
'@esbuild/android-arm': 0.27.7
'@esbuild/android-arm64': 0.27.7
'@esbuild/android-x64': 0.27.7
'@esbuild/darwin-arm64': 0.27.7
'@esbuild/darwin-x64': 0.27.7
'@esbuild/freebsd-arm64': 0.27.7
'@esbuild/freebsd-x64': 0.27.7
'@esbuild/linux-arm': 0.27.7
'@esbuild/linux-arm64': 0.27.7
'@esbuild/linux-ia32': 0.27.7
'@esbuild/linux-loong64': 0.27.7
'@esbuild/linux-mips64el': 0.27.7
'@esbuild/linux-ppc64': 0.27.7
'@esbuild/linux-riscv64': 0.27.7
'@esbuild/linux-s390x': 0.27.7
'@esbuild/linux-x64': 0.27.7
'@esbuild/netbsd-arm64': 0.27.7
'@esbuild/netbsd-x64': 0.27.7
'@esbuild/openbsd-arm64': 0.27.7
'@esbuild/openbsd-x64': 0.27.7
'@esbuild/openharmony-arm64': 0.27.7
'@esbuild/sunos-x64': 0.27.7
'@esbuild/win32-arm64': 0.27.7
'@esbuild/win32-ia32': 0.27.7
'@esbuild/win32-x64': 0.27.7
'@esbuild/aix-ppc64': 0.28.1
'@esbuild/android-arm': 0.28.1
'@esbuild/android-arm64': 0.28.1
'@esbuild/android-x64': 0.28.1
'@esbuild/darwin-arm64': 0.28.1
'@esbuild/darwin-x64': 0.28.1
'@esbuild/freebsd-arm64': 0.28.1
'@esbuild/freebsd-x64': 0.28.1
'@esbuild/linux-arm': 0.28.1
'@esbuild/linux-arm64': 0.28.1
'@esbuild/linux-ia32': 0.28.1
'@esbuild/linux-loong64': 0.28.1
'@esbuild/linux-mips64el': 0.28.1
'@esbuild/linux-ppc64': 0.28.1
'@esbuild/linux-riscv64': 0.28.1
'@esbuild/linux-s390x': 0.28.1
'@esbuild/linux-x64': 0.28.1
'@esbuild/netbsd-arm64': 0.28.1
'@esbuild/netbsd-x64': 0.28.1
'@esbuild/openbsd-arm64': 0.28.1
'@esbuild/openbsd-x64': 0.28.1
'@esbuild/openharmony-arm64': 0.28.1
'@esbuild/sunos-x64': 0.28.1
'@esbuild/win32-arm64': 0.28.1
'@esbuild/win32-ia32': 0.28.1
'@esbuild/win32-x64': 0.28.1
escalade@3.2.0: {}
@@ -7101,7 +7069,7 @@ snapshots:
jest-util@29.7.0:
dependencies:
'@jest/types': 29.6.3
'@types/node': 22.19.21
'@types/node': 25.9.3
chalk: 4.1.2
ci-info: 3.9.0
graceful-fs: 4.2.11
@@ -7118,7 +7086,7 @@ snapshots:
jest-worker@29.7.0:
dependencies:
'@types/node': 22.19.21
'@types/node': 25.9.3
jest-util: 29.7.0
merge-stream: 2.0.0
supports-color: 8.1.1
@@ -7145,8 +7113,6 @@ snapshots:
leven@3.1.0: {}
libphonenumber-js@1.13.7: {}
lighthouse-logger@1.4.2:
dependencies:
debug: 2.6.9
@@ -7875,10 +7841,7 @@ snapshots:
uid2@0.0.4: {}
undici-types@6.21.0: {}
undici-types@7.24.6:
optional: true
undici-types@7.24.6: {}
unicode-canonical-property-names-ecmascript@2.0.1: {}
@@ -7905,7 +7868,7 @@ snapshots:
utils-merge@1.0.1: {}
uuid@7.0.3: {}
uuid@11.1.1: {}
validate-npm-package-name@5.0.1: {}
@@ -7913,7 +7876,7 @@ snapshots:
vite@7.3.5(@types/node@25.9.3)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0):
dependencies:
esbuild: 0.27.7
esbuild: 0.28.1
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
postcss: 8.5.15
@@ -7957,7 +7920,7 @@ snapshots:
xcode@3.0.1:
dependencies:
simple-plist: 1.3.1
uuid: 7.0.3
uuid: 11.1.1
xml2js@0.6.0:
dependencies: