Compare commits

..
Author SHA1 Message Date
4180ac6481 ci(docker): read version via sed not node (ARC runner lacks node → exit 127) (#18)
Docker / docker (push) Failing after 12s
Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-06-28 20:05:40 -07:00
99db5965cf ci(docker): SHA tag from $GITHUB_SHA not git binary (ARC runner lacks git -> exit 127) (#17)
Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-06-28 20:01:25 -07:00
zeekay 97e799caba ci(docker): route to ARC pool hanzo-build-linux-amd64 (not offline native runners) 2026-06-28 18:11:29 -07:00
zeekay 3df982b9fc feat(auth): enforce forced TOTP MFA in the portal (RequiredMfa / NextMfa)
IAM signals MFA with a STRING in the login response `data`
("RequiredMfa" = org forces MFA, user not enrolled; "NextMfa" = user has
MFA, needs a challenge) — never the `mfa_required` boolean the portal was
checking. So every first login fell through to /onboarding, silently
bypassing 2FA.

parseLoginResponse now branches on `data` BEFORE the /onboarding return:
  - RequiredMfa -> { mfaRequired, mfaStage: 'enroll' }
  - NextMfa     -> { mfaRequired, mfaStage: 'challenge', mfaTypes }

New AuthClient methods drive the flow against the canonical /v1/iam surface:
  getAccount, mfaInitiate, mfaVerify, mfaEnable, mfaChallenge.

Wire contract (verified live vs iam.hanzo.ai): the /v1/iam/mfa/setup/*
calls put EVERY param on the query string with an EMPTY body — the only
shape that satisfies both IAM's authz self-match (objOwner/objName are
read from the query only when the body is empty; a urlencoded body fails
the JSON-unmarshal and yields "Unauthorized operation") and the MFA
controller. owner/name ride the query on every call, incl. verify.

UI: MfaEnrollForm renders forced TOTP enrollment — QR from the otpauth://
URI via @paulmillr/qr (secret never leaves the browser), manual-key
fallback, recovery code, and the existing OTPForm for code entry. No skip
control. Login.tsx routes onMfaRequired by stage; the existing OTPForm is
reused for the NextMfa challenge.

Tests: 8 new MFA wire-contract tests (pure, mocked fetch). 14/14 green.
2026-06-28 17:59:39 -07:00
zeekay 18d0ae0eb0 Merge branch 'feat/sms-consent-disclosure' 2026-06-23 11:32:36 -07:00
zeekay a8b4d9a007 feat(auth): A2P SMS consent disclosure on the SMS verification surface
Twilio A2P 10DLC requires the SMS consent disclosure wherever a user enters
or uses a phone number to receive messages. Add one shared, verbatim consent
component (matches hanzo.ai/sms-opt-in SMS_CONSENT_TEXT and the IAM phone-login
UI) and render it on the portal's SMS surface, gated to the SMS channel only.

- New pkgs/auth/src/ui/SmsConsent.tsx: single source of SMS_CONSENT_TEXT plus
  <SmsConsentNotice/> (disclosure + Terms/Privacy links). Exported from the ui
  barrel for reuse by any future phone-collection surface.
- OTPForm: render SmsConsentNotice beneath the code input only when
  channel === 'sms' (not for totp/email).
- app.css: muted styling for .hanzo-id-sms-consent, matching footer-links.

Note: this portal does not render its own phone-number field — phone-number
COLLECTION happens in the IAM-hosted UI (covered separately), where signup uses
a required, submit-gating consent checkbox. The portal's only SMS-facing step
today is the verification-code entry, so the disclosure-only notice applies
here. Typecheck + tests green (pnpm -r tc, @hanzo/id-auth test).
2026-06-23 09:38:27 -07:00
Hanzo 187b74cbad ci(docker): self-contained build on self-hosted runners
The shared reusable workflow (hanzoai/.github docker-build.yml@main) fails graph
validation for EVERY caller right now (org-wide startup_failure, 0 jobs created),
so id never built via CI (0.1.x images were pushed manually). Build the amd64
image directly on our self-hosted runners instead — no shared-infra dependency.
Tags: sha-<short> + package.json version.
2026-06-22 23:30:19 -07:00
Hanzo debd1e71bb fix(social): real Google endpoint + registered redirect_uri (iam.hanzo.ai/callback)
The provider hop sent redirect_uri=${origin}/callback (= hanzo.id/callback),
which the shared Google/GitHub OAuth client does NOT accept — verified live the
Google client accepts ONLY https://iam.hanzo.ai/callback (every other URI →
redirect_uri_mismatch). It also used the invalid authorize endpoint
accounts.google.com/signin/oauth.

- social.ts: Google endpoint → https://accounts.google.com/o/oauth2/v2/auth
  (canonical/stable); add callbackOrigin param to buildProviderAuthUrl/
  startProviderLogin (defaults to origin — local/single-host unchanged).
- TenantConfig.oauthCallbackOrigin (catalog-driven; defaults to publicOrigin);
  SocialButtons passes it so the hop returns to the provider's REGISTERED
  /callback. iam.hanzo.ai serves the same @hanzo/id SPA: the headless Callback
  completes the exchange and forwards to the originating app.
- tests: lock the registered-callback override + canonical Google endpoint.

bump id 0.1.1→0.1.22, id-auth/id-shared 0.1.0→0.1.1
2026-06-22 23:26:35 -07:00
Antje Worring 31a720dc77 fix(portal): root IS the login form (logged-out) / apps launcher (logged-in)
- '/' rendered a static 'Welcome' marketing hero; for an identity portal the
  root should BE the login form when signed out, and the org's apps launcher
  when signed in. Portal now reads /v1/iam/get-account (same-origin, first-party
  cookie) and renders <Login> (anon) or the apps grid (authed).
- Onboarding completion redirected to '/' → the hero, which read as 'looped back
  to the beginning' after the last (wallet) step's Skip. It now lands on
  '/?signed_in=1' → the authed apps launcher. (The step machine was already
  correct: nextStep('wallet')==='done'.)
- Port marketing.ts (appsFor/billingFor) from the restore-design line.
2026-06-22 22:05:14 -07:00
zeekay ee2eee959c fix(auth): use feat's clean LoginForm/SignupForm (getAppLogin via SocialButtons; password+PKCE) — auto-merge had mixed main's appLogin/sendLoginCode API with feat's client 2026-06-21 19:43:15 -07:00
zeekay 965f7cfd91 fix(docker): COPY pkgs/onboarding/package.json — merged workspace needs it for pnpm install 2026-06-21 19:37:00 -07:00
zeekay ce87a7f57f merge(id): integrate feat/full-auth-onboarding — PKCE password-login fix + composable auth UI + social hop into main
# Conflicts:
#	Dockerfile
#	apps/web/src/App.tsx
#	apps/web/src/app.css
#	pkgs/auth/src/client.ts
#	pkgs/auth/src/index.ts
#	pkgs/auth/src/ui/index.ts
#	pkgs/shared/src/brand.ts
#	pkgs/shared/src/tenant.ts
2026-06-21 19:35:05 -07:00
zeekay 9d7d64797b feat(social): wire the provider hop + return exchange (gated until creds seeded)
Prep social login end-to-end so only OAuth-app registration + KMS creds remain.

- The hop (social.ts/startProviderLogin): redirects straight to GitHub/Google with a base64 state that round-trips the original authorize request — the Casdoor getAuthUrl contract. Replaces the @hanzo/iam signinRedirect that looped back to the login page. Pure URL builder is unit-tested (social.test.ts, 5 cases).

- SocialButtons now keeps the full provider records (type/clientId/scopes surfaced from get-app-login) and routes OAuth providers through the hop, wallet through the SDK.

- Callback gained a GATED provider-return branch: a provider state (base64 with provider+application) is exchanged via client.providerLogin at the IAM backend, then follows the continue-URL back into the normal OIDC path. The existing OIDC/password path is the untouched else-branch — and the social branch is unreachable until a configured provider exists, so zero impact today.

- Cred-sync already exists (init_data.json ${IAM_GITHUB_CLIENT_ID} <- iam-kms-sync KMSSecret, project hanzo-iam/prod). Runbook in LLM.md. End-to-end needs live verification once real creds are seeded.
2026-06-20 10:46:26 -07:00
zeekay 6d8753c9ab fix(login): deliver tenant catalog via /config.json; correct pars/osage/lux client wiring
Root cause osage.id still showed Hanzo: the SPA read the catalog from window.__ID_CATALOG__, which the runtime never injects — so every catalog-only host silently fell back to the bundled Hanzo default. Read the catalog from /config.json (what the static server actually templates from SPA_IAM_TENANT_CONFIG_JSON), with the global as a fallback.

Also corrected verified-broken client wiring: built-in pars-id does not exist (use pars-console, which carries the /callback redirect); added an osage built-in so a catalog-load failure can never leak the Hanzo brand. lux uses lux-id (lux-cloud lacks the portal redirect) — fixed in the ConfigMap. Tests: 7/7.
2026-06-20 02:51:08 -07:00
zeekay c7b794c857 fix(tenant): catalog-only hosts resolve their OWN brand; drop orphan divider
osage.id (and zoolabs.id) rendered as Hanzo — a white-label leak. Root cause: the runtime catalog entries carry brandUrl (+orgId/clientId), but a host with NO built-in DEFAULT_TENANTS entry used DEFAULT_TENANTS['hanzo.id'] as its merge base, so brandPackage/iamUrl silently inherited Hanzo. Now a catalog-only host derives iamUrl/iamIssuer/publicOrigin from the host itself and maps brandUrl -> brandPackage; it can never inherit another brand. Unknown brand asset -> neutral wordmark, not the wrong brand. Regression test added (pkgs/shared now has a test runner).

Drop the orphan 'or' divider: it lived between the social block and the password form, but with unconfigured social hidden it dangled above the form on every brand. Moved the divider INTO SocialButtons so it renders only alongside actual buttons; removed the standalone one from Login + Signup.
2026-06-20 02:38:31 -07:00
zeekay 2913f5b2db fix(login): only show configured social providers; never show a broken brand logo
Two things made login feel broken across every brand on the portal (hanzo.id/lux.id/pars.id/osage.id):

1) Social SSO dead-ended. The GitHub/Google/Apple/Web3 providers are seeded with PLACEHOLDER credentials (clientId=GITHUB_CLIENT_ID_PLACEHOLDER, etc.), so the OAuth redirect can never complete — clicking 'Continue with GitHub' just looped back to the login form. Render ONLY providers IAM actually holds a real clientId for (AppProvider.configured, computed from the nested provider record). With placeholders that hides all social buttons, leaving the working methods (email/password + email/SMS code) — and the buttons reappear automatically once real OAuth creds are seeded, no code change. When get-app-login is unreadable, render none rather than risk a dead-end.

2) Broken logo. brand.json points logo/favicon at jsdelivr @hanzo/brand assets, but the published brand packages ship no assets/ dir (404). BrandHeader now falls back to the brand NAME as a text wordmark on image error/absence, so a missing logo never renders a broken-image icon.

Enabling real social login later needs: (a) register OAuth apps (GitHub/Google/Apple) with callback https://<brand>/v1/iam/callback, (b) put their client_id/secret in KMS → IAM provider records, (c) the SPA provider-redirect hop. (a)+(b) are external provisioning; until then social is correctly hidden.
2026-06-20 02:21:35 -07:00
zeekay 05ddadc91a fix(auth): forward PKCE code_challenge on password login
login() built /v1/iam/login with clientId/responseType/redirectUri/state
but dropped the PKCE code_challenge/code_challenge_method that authorize()
already forwards. A downstream public SPA client (e.g. hanzo-platform)
sending a user to password sign-in got an auth code minted with an empty
stored challenge; the downstream /auth/callback token exchange then failed
PKCE validation and fell back to a client_secret check the public client
can't satisfy (token.CodeChallenge: empty -> invalid_client).

IAM's Login handler (controllers/account.go) reads code_challenge from the
query first, body fallback, so forward it on the query mirroring authorize().
Plumb it from the OAuth params already on the /login URL through Login page
-> LoginForm -> client.login(). Social login was unaffected (rides authorize).
2026-06-20 01:36:59 -07:00
zeekay 8b6a6471d2 fix(docker): bake SPA-appropriate CSP into the image (HANZO_STATIC_CSP)
hanzoai/static defaults CSP to `default-src 'none'` which blocks the
SPA's own bundle (no script-src) -> blank page. Bake a widened-but-locked
CSP that permits: self scripts + CF beacon, same-origin + jsDelivr brand
fetches, https/data images (brand logos), inline styles (@hanzo/gui).
Baking it (vs a deploy-time env) makes the image render correctly
standalone and survives universe reconcile.
2026-06-20 00:17:30 -07:00
zeekay 33e2cb1a0c fix(brand): emit + fetch brand.json at flat encoding-safe /brand/<scope>.json
Two coupled bugs broke runtime brand loading on the hanzoai/static
(scratch) image:

1. vite.config.ts is ESM ("type":"module") so the brandJsonPlugin's
   require.resolve('@scope/brand/brand.json') threw ReferenceError
   (no global require), was swallowed by the silent catch, and NO
   brand.json was ever emitted into dist/. Fixed with
   createRequire(import.meta.url).

2. The brand fetch path /brand/@hanzo/brand/brand.json carries a literal
   @ and an encoded %2F that the production static server cannot map to
   the on-disk file, so it falls through to the SPA catch-all and returns
   index.html — the SPA then parses HTML as JSON. Emit and fetch at a
   FLAT slug path instead: @hanzo/brand -> /brand/hanzo.json. The plugin
   and loadBrand derive the slug identically (npm scope).

With defensive loadBrand (prev commit) a miss degrades to a neutral
brand; this restores real per-brand branding.
2026-06-20 00:07:06 -07:00
zeekay 7c2607b899 fix(brand): never blank the login form on a transient brand.json failure
The browser brand loader threw on any non-ok /brand/<pkg>/brand.json fetch, so a single transient 502 on the cosmetic brand asset (intermittent behind Cloudflare) left the IAM login SPA blank — no form, just 'brand.json fetch failed: 502'. Retry the flaky asset up to 3x with backoff, then fall back to a neutral per-tenant brand so the form always renders. brand.json stays the source of truth on the happy path.
2026-06-19 23:54:11 -07:00
zeekay fd145f190a fix(docker): serve SPA from /public with --spa for hanzoai/static contract
hanzoai/static:0.4.1 is FROM scratch + ENTRYPOINT ["/static"]; the
binary defaults to -root /public -port 3000 and writes
/public/config.json from SPA_* env at boot. The previous final stage
copied the bundle to /spa and set dead ENV ROOT/PORT (the binary reads
flags, not env), so /public never existed and the runtime-config write
crashed (open /public/config.json.tmp: no such file or directory).

Copy the dist to /public (the default root + config.json target) and
pass --spa so client-routed paths (/auth/*, /callback) fall back to
index.html. Port 3000 matches the id Service and probes.
2026-06-19 23:48:00 -07:00
zeekay 851282b797 fix(docker): COPY pkgs/onboarding/package.json into install stage
apps/web depends on @hanzo/id-onboarding (workspace:*) since the
onboarding flow landed, but the Dockerfile's per-package manifest
COPY list (the layer feeding the dependency-resolution
`pnpm install` before the full `COPY pkgs pkgs`) was never
updated. In-cluster builds therefore failed at install with
ERR_PNPM_WORKSPACE_PKG_NOT_FOUND for @hanzo/id-onboarding.

Local builds masked this because node_modules was already
populated from a prior full install.
2026-06-19 23:40:16 -07:00
zeekay 5dca171915 test(onboarding): unit tests for step machine + IAM wire contracts; docs
9 tests via the Node built-in runner (--experimental-strip-types, no
test-framework dependency): step machine (org→project→wallet→done),
listOrgs mapping + error-resilience, createOrg request shape + error
surfacing, linkWallet address validation + get-account→update-user
(web3onboard, column-scoped) + fail-closed. tc excludes *.test.ts so the
build gate stays type-only.

LLM.md: corrected issuer to per-brand *.id host (not iam.hanzo.ai),
documented the full method set + get-app-login source-of-truth, the
onboarding pkg + its IAM routes + admin-gating reality, and the /v1/iam
OIDC paths.
2026-06-19 15:34:37 -07:00
zeekay 645828077d fix(auth): social/Web3 sign-in honors downstream redirect_uri
Social + Web3 always return to the portal's own /callback (the SDK's fixed
redirectUri), so a downstream app's redirect_uri would be lost. SocialButtons
now stashes it in post_login_redirect before signinRedirect; Callback reads it
back and forwards the tokens there, else lands on /onboarding. Login + Signup
pass redirect_uri through. Password path already forwards directly via the
auth-code response — both methods now reach the same downstream target.
2026-06-19 15:31:21 -07:00
zeekay afc6ae06fa fix(onboarding): correct IAM contracts — get-account+web3onboard wallet, authz-aware org/project
linkWallet now resolves the signed-in user via /v1/iam/get-account (owner/name)
and writes the lowercase `web3onboard` column scoped via ?columns= so the rest
of the user row is untouched — IAM's update-user is keyed by owner/name, not a
self alias.

Org/project creation is admin-gated in IAM authz (add-organization needs the
admin role; add-project default-denies non-admins). The common path — pick the
org you signed up into, listed via the *-allowed get-organizations — works for
everyone; create-new surfaces a plain permission message and the step stays
skippable so onboarding never hard-blocks. ProjectStep tolerates an org-less
flow (continue-only).
2026-06-19 15:29:42 -07:00
zeekay c69d05921e feat(onboarding): post-login org → project → wallet flow + serverUrl=hanzo.id
@hanzo/id-onboarding: domain (serializable step machine + types) / service
(IAM-backed writes via /v1/iam/{get-organizations,add-organization,add-project,
update-user}, cookie+bearer) / UI (self-contained 3-step OnboardingFlow, no
router lib). Web app mounts it at /onboarding; both password (cookie) and
social/Web3 (SDK token) sign-in land there for a bare portal login.

Callback now completes via the @hanzo/iam SDK handleCallback (matches
SocialButtons' signinRedirect) and forwards tokens to a downstream app only
when one initiated the flow.

tenant.ts: iamUrl is the per-brand OIDC issuer host (hanzo.id / lux.id /
zoo.id / pars.id), never iam.hanzo.ai — HIP-0111 host-relative discovery.
clientId is the brand -id app (hanzo-id), matching init_data.json.
2026-06-19 15:25:04 -07:00
zeekay 7b6ef89048 feat(auth): full method set — email/password + GitHub + Google + Web3
SocialButtons reads live enabled providers from /v1/iam/get-app-login and
drives the @hanzo/iam PKCE redirect (provider param) per method. Login/Signup
render the social row + divider above the email form. AppLogin/AppProvider
types model the get-app-login view; createIam() centralizes the one PKCE
client both SocialButtons and Callback share.
2026-06-19 15:19:50 -07:00
dd3045757c 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
f2211f7e27 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
a4c5610acc 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
95e71621de 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
b04faad9d8 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
bb972779f6 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
b2253b6b03 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
7c15bd3640 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
48bb754ab5 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
94aff5915a 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
f5fe4086b1 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 563d5482b7 chore: update 2026-06-10 14:12:32 -07:00
hanzo-dev afd9982e00 merge: id-vite-monorepo (-X theirs) 2026-06-01 18:09:45 -07:00
hanzo-dev 7808400d58 merge: ci/canonical-docker-build-1776995843 (-X theirs) 2026-06-01 18:09:44 -07:00
hanzo-dev 007a8fc262 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 4170fd1f3e 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 cf0a70f5fa 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 73a7d5be9a chore: update 2026-05-25 15:14:35 -07:00
hanzo-dev a5d696b772 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 7b73828908 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 1fccc6234c 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 0046cb6417 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 12d271025a 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 ed2b42617c 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 664e86624b 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 737171bc3b 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 8241a409b8 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 0e01abba27 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 edcc3dc71e 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 3ba61da43b ci: amd64-only (arm64 ARC pool paused on DOKS) 2026-05-24 06:50:23 -07:00
hanzo-dev b2ba1669cb ci: drop pre-build-command — Dockerfile self-contains pnpm build 2026-05-24 06:38:53 -07:00
hanzo-dev 1bbb6e8b5e ci: drop CF Pages deploy (was Next.js-only; Vite SPA ships via Docker) 2026-05-24 06:25:55 -07:00
hanzo-dev 57dec0a33e 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 1ac00b4b2b 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 ~/work/liquidity/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 85ce3e93c5 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 ~/work/liquidity/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 652c8150b6 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 514bf2704d ci: migrate to canonical hanzoai/.github/docker-build.yml reusable (#1) 2026-04-23 18:58:37 -07:00
hanzo-dev 95c9dbe899 ci: migrate to canonical hanzoai/.github/docker-build.yml reusable 2026-04-23 18:57:29 -07:00
hanzo-dev 894dd58091 feat: add id.lux.cloud as Lux tenant (was defaulting to Hanzo)
Docker / build-push (push) Failing after 3m26s
2026-04-20 21:37:05 -07:00
hanzo-dev b0779c7db9 refactor: flatten id-{dev,test}.hanzo.ai so *.hanzo.ai Universal SSL covers them 2026-04-20 21:31:45 -07:00
hanzo-dev 8369a4a68e 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 983c2b7a3e 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
65 changed files with 1206 additions and 7191 deletions
+42 -8
View File
@@ -1,4 +1,8 @@
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:
@@ -9,11 +13,41 @@ permissions:
packages: write
jobs:
docker:
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
# 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
+8 -15
View File
@@ -1,5 +1,5 @@
# syntax=docker/dockerfile:1.7
# Hanzo ID — Vite SPA built once, served by hanzoai/static.
# Hanzo ID — Vite SPA built once, served by hanzoai/spa.
FROM node:24-alpine AS build
WORKDIR /build
ENV PNPM_HOME=/pnpm PATH=$PNPM_HOME:$PATH
@@ -17,19 +17,12 @@ COPY apps apps
COPY pkgs pkgs
RUN pnpm --filter @hanzo/id-web build
# Static server stage — hanzoai/static (FROM scratch, ENTRYPOINT ["/static"]).
# The binary defaults to -root /public -port 3000 and, on boot, templates
# /public/config.json from SPA_* env (the id-tenant-catalog ConfigMap supplies
# SPA_IAM_TENANT_CONFIG_JSON). So the SPA MUST live at /public, and -spa must be
# on so client-routed paths (/auth/*, /callback) fall back to index.html.
FROM ghcr.io/hanzoai/static:0.4.1
# SPA server stage — hanzoai/spa is the correct base for a Vite SPA:
# history-API fallthrough for client-side routes AND a SPA-safe CSP.
# hanzoai/static defaults to `Content-Security-Policy: default-src 'none'`
# (built for static assets, not an app that loads its own bundle), which
# blocks the SPA's own scripts and leaves a blank page. hanzoai/spa serves
# index.html for all routes with a sane CSP. Defaults: PORT=3000, ROOT=/public.
FROM ghcr.io/hanzoai/spa:1.2.0
COPY --from=build /build/apps/web/dist /public
# hanzoai/static's default CSP is `default-src 'none'` — that blocks the SPA's
# OWN bundle (no script-src) and renders a blank page. This SPA needs to run its
# JS, load brand JSON same-origin, pull brand logos from the jsDelivr CDN, and
# talk to its host-relative IAM origin. Widen the CSP for an SPA (still
# locked-down: no wildcard script host beyond CF's beacon). Baked into the image
# so it renders correctly standalone, independent of any deploy-time env.
ENV HANZO_STATIC_CSP="default-src 'self'; script-src 'self' 'unsafe-inline' https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://cdn.jsdelivr.net https://cloudflareinsights.com https://static.cloudflareinsights.com; frame-ancestors 'none'; base-uri 'self'"
EXPOSE 3000
CMD ["--spa", "--port", "3000", "--root", "/public"]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@hanzo/id-web",
"private": true,
"version": "0.1.1",
"version": "0.1.22",
"description": "Hanzo ID — white-label login / signup / IDV portal. Vite + React 19 + @hanzo/gui. Same image serves hanzo.id / lux.id / zoo.id / pars.id.",
"type": "module",
"scripts": {
+1 -1
View File
@@ -71,5 +71,5 @@ export function App() {
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 brand={brand} />
return <Portal client={client} brand={brand} tenant={tenant} />
}
+36
View File
@@ -81,6 +81,12 @@ 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;
@@ -97,6 +103,36 @@ 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;
}
.hanzo-id-mfa-qr svg { width: 100%; height: 100%; display: block; }
.hanzo-id-mfa-manual { font-size: 14px; color: var(--muted); }
.hanzo-id-mfa-manual summary { cursor: pointer; }
.hanzo-id-mfa-secret,
.hanzo-id-mfa-recovery code {
display: inline-block;
margin-top: 8px;
padding: 6px 10px;
background: #111;
border: 1px solid var(--border);
border-radius: 6px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 2px;
word-break: break-all;
}
.hanzo-id-mfa-recovery { font-size: 13px; color: var(--muted); line-height: 1.6; }
.hanzo-id-mfa-recovery code { letter-spacing: normal; }
/* ── Social / Web3 sign-in buttons ─────────────────────────────── */
.hanzo-id-social { display: flex; flex-direction: column; gap: 10px; }
.hanzo-id-social-btn {
+113
View File
@@ -0,0 +1,113 @@
/**
* Per-org marketing content + app launcher links.
*
* The brand-neutral `BrandContract` (from `loadBrand`) carries only the
* visual essentials (name, logo, accent). The split-view login's marketing
* panel and the post-login apps launcher need richer, org-specific copy —
* ported verbatim from the frozen `legacy-nextjs` design (`staticBranding`
* content + `orgApps`). Keyed by `tenant.orgId` so it stays decoupled from
* hostname switches; unknown orgs fall back to `hanzo`.
*/
export interface Quote {
readonly text: string
readonly author: string
readonly role?: string
}
export interface Marketing {
/** Pill above the hero ("✦ <tagline>"). */
readonly tagline?: string
/** Hero heading. */
readonly title: string
/** Hero subheading. */
readonly subtitle: string
/** Rotating testimonials. */
readonly quotes: readonly Quote[]
}
export interface AppLink {
readonly name: string
readonly href: string
readonly description: string
}
const MARKETING: Record<string, Marketing> = {
hanzo: {
tagline: 'AI-powered development',
title: 'Start building in seconds',
subtitle: 'Describe your idea and watch AI bring it to life instantly.',
quotes: [
{ text: 'Hanzo is amazing. It is revolutionizing how we build and deploy applications.', author: 'Developer', role: 'Software Engineer' },
],
},
lux: {
tagline: 'Lux-powered infrastructure',
title: 'Start deploying in seconds',
subtitle: 'High-performance blockchain infrastructure for the Lux ecosystem.',
quotes: [
{ text: 'Lux is fast. We deploy chains in minutes, not weeks.', author: 'Validator', role: 'Node Operator' },
],
},
zoo: {
tagline: 'Open AI research network',
title: 'Build the future of DeAI',
subtitle: 'Open AI research and decentralized science for everyone.',
quotes: [
{ text: 'Zoo is where bleeding-edge DeAI experiments actually ship.', author: 'Researcher', role: 'ML Engineer' },
],
},
pars: {
tagline: 'Sovereign digital identity',
title: 'Welcome to Pars',
subtitle: 'The decentralized network for the next generation.',
quotes: [
{ text: 'Pars gives our community a sovereign, verifiable identity layer.', author: 'Member', role: 'Community Lead' },
],
},
}
const APPS: Record<string, readonly AppLink[]> = {
hanzo: [
{ name: 'Console', href: 'https://console.hanzo.ai', description: 'Observability & traces' },
{ name: 'Chat', href: 'https://hanzo.chat', description: 'AI chat interface' },
{ name: 'Cloud', href: 'https://cloud.hanzo.ai', description: 'AI model API' },
{ name: 'Analytics', href: 'https://analytics.hanzo.ai', description: 'Web analytics' },
{ name: 'Platform', href: 'https://platform.hanzo.ai', description: 'PaaS deployments' },
{ name: 'Storage', href: 'https://s3.hanzo.ai', description: 'S3-compatible storage' },
],
lux: [
{ name: 'Bridge', href: 'https://bridge.lux.network', description: 'Cross-chain bridge' },
{ name: 'Exchange', href: 'https://lux.exchange', description: 'DEX trading' },
{ name: 'Cloud', href: 'https://lux.cloud', description: 'Lux Cloud' },
{ name: 'Explorer', href: 'https://explore.lux.network', description: 'Block explorer' },
],
zoo: [
{ name: 'Network', href: 'https://zoo.ngo', description: 'Zoo Labs Foundation' },
{ name: 'ZIPs', href: 'https://zips.zoo.ngo', description: 'Improvement proposals' },
{ name: 'Chat', href: 'https://chat.zoo.ngo', description: 'DeAI chat interface' },
],
pars: [
{ name: 'Network', href: 'https://pars.network', description: 'Pars Network' },
{ name: 'Vote', href: 'https://pars.vote', description: 'Governance & proposals' },
],
}
const BILLING: Record<string, string> = {
hanzo: 'https://billing.hanzo.ai',
lux: 'https://billing.lux.network',
zoo: 'https://billing.zoo.network',
pars: 'https://billing.pars.network',
}
export function marketingFor(orgId: string): Marketing {
return MARKETING[orgId] ?? MARKETING.hanzo
}
export function appsFor(orgId: string): readonly AppLink[] {
return APPS[orgId] ?? APPS.hanzo
}
export function billingFor(orgId: string): string {
return BILLING[orgId] ?? BILLING.hanzo
}
+83 -1
View File
@@ -1,5 +1,14 @@
import { useState } from 'react'
import type { BrandContract } from '@hanzo/id-shared'
import { LoginForm, SocialButtons, type AuthClient } from '@hanzo/id-auth'
import {
LoginForm,
MfaEnrollForm,
OTPForm,
SocialButtons,
mfaChannelOf,
type AuthClient,
type LoginResponse,
} from '@hanzo/id-auth'
import { BrandHeader } from '../components/BrandHeader'
export function Login({ client, brand }: { client: AuthClient; brand: BrandContract }) {
@@ -9,6 +18,78 @@ export function Login({ client, brand }: { client: AuthClient; brand: BrandContr
const clientIdOverride = sp.get('client_id') ?? undefined
const codeChallenge = sp.get('code_challenge') ?? undefined
const codeChallengeMethod = (sp.get('code_challenge_method') as 'S256' | 'plain' | null) ?? undefined
// null = show the credential form; otherwise IAM returned an MFA signal and
// we render the matching step instead of navigating on.
const [mfa, setMfa] = useState<LoginResponse | null>(null)
const [challengeError, setChallengeError] = useState<string | null>(null)
const clientId = clientIdOverride ?? client.tenant.clientId
// The credential check succeeded (or MFA was satisfied). For a downstream
// OIDC request, re-enter authorize with the now-established IAM session so it
// mints the code; for a bare portal sign-in, land on onboarding.
function completeAfterAuth() {
if (redirectUri) {
window.location.href = client.authorize({
clientId,
redirectUri,
state: state ?? '',
codeChallenge,
codeChallengeMethod,
})
} else {
window.location.href = '/onboarding'
}
}
if (mfa?.mfaStage === 'enroll') {
return (
<div className="hanzo-id-page hanzo-id-login">
<BrandHeader brand={brand} />
<main>
<MfaEnrollForm client={client} onComplete={completeAfterAuth} />
</main>
</div>
)
}
if (mfa?.mfaStage === 'challenge') {
const iamType = mfa.mfaTypes?.[0] ?? 'app'
async function onChallenge(code: string) {
setChallengeError(null)
const res = await client.mfaChallenge({
mfaType: iamType,
passcode: code,
clientId,
application: client.tenant.appName,
organization: client.tenant.orgId,
redirectUri,
state,
codeChallenge,
codeChallengeMethod,
})
if (res.error) {
setChallengeError(res.error)
} else if (res.redirectUrl) {
window.location.href = res.redirectUrl
} else {
completeAfterAuth()
}
}
return (
<div className="hanzo-id-page hanzo-id-login">
<BrandHeader brand={brand} />
<main>
<h1>Two-factor authentication</h1>
<p className="lede">Enter the code from your authenticator app to finish signing in.</p>
{challengeError ? <p role="alert" className="hanzo-id-error">{challengeError}</p> : null}
<OTPForm channel={mfaChannelOf(iamType)} onSubmit={onChallenge} />
</main>
</div>
)
}
return (
<div className="hanzo-id-page hanzo-id-login">
<BrandHeader brand={brand} />
@@ -27,6 +108,7 @@ export function Login({ client, brand }: { client: AuthClient; brand: BrandContr
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>
+4 -1
View File
@@ -34,7 +34,10 @@ export function Onboarding({ tenant, brand }: { tenant: TenantConfig; brand: Bra
)
function onComplete(_state: OnboardingState) {
window.location.replace('/')
// 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 (
+115 -8
View File
@@ -1,18 +1,125 @@
import type { BrandContract } from '@hanzo/id-shared'
import { useEffect, useState } from 'react'
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
import type { AuthClient } from '@hanzo/id-auth'
import { Login } from './Login'
import { BrandHeader } from '../components/BrandHeader'
import { appsFor, billingFor } from '../marketing'
type Auth =
| { s: 'loading' }
| { s: 'anon' }
| { s: 'authed'; name?: string; email?: string }
/**
* Root portal (`/`). The portal IS the login surface, not a marketing hero:
*
* - signed out → the actual `<Login>` form (GitHub/Google/email+password),
* identical to `/login`. A bare sign-in here lands on
* onboarding, then back on `/` authenticated.
* - signed in → the apps launcher (the org's apps) + billing / sign-out.
*
* Auth is read same-origin from `/v1/iam/get-account` (cookie session;
* `tenant.iamUrl` is the brand's own `*.id` host, so this is first-party and
* the session cookie rides along). The `?signed_in=1` marker set by the
* bare-login / onboarding-complete redirect is the authoritative "just
* authenticated" signal when the cookie read hasn't propagated yet.
*/
export function Portal({
client,
brand,
tenant,
}: {
client: AuthClient
brand: BrandContract
tenant: TenantConfig
}) {
const [auth, setAuth] = useState<Auth>({ s: 'loading' })
useEffect(() => {
let alive = true
const justSignedIn = new URLSearchParams(window.location.search).get('signed_in') === '1'
fetch(new URL('/v1/iam/get-account', tenant.iamUrl).toString(), {
credentials: 'include',
headers: { Accept: 'application/json' },
})
.then((r) => r.json())
.then((b: Record<string, unknown>) => {
if (!alive) return
const d = b.data as Record<string, unknown> | undefined
if (b.status === 'ok' && d && typeof d === 'object') {
setAuth({ s: 'authed', name: str(d.displayName) ?? str(d.name), email: str(d.email) })
} else {
setAuth(justSignedIn ? { s: 'authed' } : { s: 'anon' })
}
})
.catch(() => {
if (alive) setAuth(justSignedIn ? { s: 'authed' } : { s: 'anon' })
})
return () => {
alive = false
}
}, [tenant.iamUrl])
if (auth.s === 'loading') {
return (
<div className="hanzo-id-page" style={{ minHeight: '40vh' }}>
<div className="hanzo-id-spinner" style={{ borderTopColor: brand.accentColor ?? '#fff' }} />
</div>
)
}
// Signed out: the root IS the login form (no marketing hero).
if (auth.s === 'anon') return <Login client={client} brand={brand} />
// Signed in: the apps launcher.
const apps = appsFor(tenant.orgId)
const billingUrl = billingFor(tenant.orgId)
const logoutUrl = client.logout(undefined, `${tenant.publicOrigin}/login`)
export function Portal({ brand }: { brand: BrandContract }) {
return (
<div className="hanzo-id-page hanzo-id-portal">
<BrandHeader brand={brand} />
<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>
<a className="hanzo-id-btn" href="/signup">Create account</a>
<main style={{ width: '100%', maxWidth: 760 }}>
<h1>Your {brand.name} apps</h1>
{auth.email ? <p className="lede">{auth.email}</p> : null}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(210px, 1fr))',
gap: 12,
marginTop: 24,
}}
>
{apps.map((a) => (
<a
key={a.name}
href={a.href}
style={{
display: 'block',
padding: '16px 18px',
border: '1px solid rgba(255,255,255,0.14)',
borderRadius: 12,
textDecoration: 'none',
color: 'inherit',
}}
>
<div style={{ fontWeight: 600, display: 'flex', justifyContent: 'space-between' }}>
<span>{a.name}</span>
<span aria-hidden style={{ opacity: 0.5 }}></span>
</div>
<div style={{ opacity: 0.6, fontSize: 13, marginTop: 4 }}>{a.description}</div>
</a>
))}
</div>
<div style={{ marginTop: 28, display: 'flex', gap: 18 }}>
<a className="hanzo-id-linkbtn" href={billingUrl}>Billing</a>
<a className="hanzo-id-linkbtn" href={logoutUrl}>Sign out</a>
</div>
</main>
</div>
)
}
function str(v: unknown): string | undefined {
return typeof v === 'string' && v.length > 0 ? v : undefined
}
-54
View File
@@ -1,54 +0,0 @@
FROM node:22-alpine AS base
# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# --- Dependencies ---
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile 2>/dev/null || pnpm install
# --- Build ---
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build args become env vars at build time (for white-label forks)
ARG NEXT_PUBLIC_IAM_URL
ARG NEXT_PUBLIC_ORG
ARG NEXT_PUBLIC_CLIENT_ID
ARG NEXT_PUBLIC_APP_NAME
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
# --- Production ---
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Runtime env vars for white-label configuration:
# IAM_ORIGIN — IAM backend URL (default: https://iam.hanzo.ai)
# NEXT_PUBLIC_IAM_URL — Same, for client-side
# NEXT_PUBLIC_ORG — Organization name (default: hanzo)
# NEXT_PUBLIC_CLIENT_ID — Default app client ID
CMD ["node", "server.js"]
-146
View File
@@ -1,146 +0,0 @@
# Hanzo ID - Hosted Login Pages
Configurable, white-label login pages for Hanzo IAM. Each organization can customize their login experience based on their domain (CNAME).
## Architecture
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ hanzo.id │ │ pars.id │ │ lux.id │
│ (CNAME) │ │ (CNAME) │ │ (CNAME) │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
└───────────────────┴───────────────────┘
┌──────▼──────┐
│ Hanzo ID │ ← This repo (frontend)
│ (Next.js) │
└──────┬──────┘
┌──────▼──────┐
│ Hanzo IAM │ ← Backend auth services
│ (Go API) │
└─────────────┘
```
## Features
- **Domain-based branding**: Logo, colors, content based on CNAME
- **Configurable auth methods**: Password, code, WebAuthn, Face ID
- **Social providers**: Google, GitHub, and more
- **Customizable content**: Quotes, testimonials, feature highlights
- **Dark mode by default**: Clean, modern design
- **Easy to fork**: Simple structure for white-labeling
## Configuration
Branding can be configured in two ways:
### 1. Static Configuration (for known domains)
Edit `lib/branding.ts` to add your domain:
```typescript
export const staticBranding: Record<string, Partial<BrandingConfig>> = {
'your-domain.com': {
orgId: 'your-org',
orgName: 'Your Organization',
logo: '/logos/your-logo.svg',
colors: {
primary: '#3b82f6',
primaryText: '#ffffff',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Welcome to Your App',
subtitle: 'Sign in to continue',
},
},
}
```
### 2. Dynamic Configuration (from IAM backend)
The login page fetches branding from IAM API:
```
GET https://api.hanzo.id/api/branding?domain=your-domain.com
```
Response:
```json
{
"orgId": "your-org",
"orgName": "Your Organization",
"logo": "https://...",
"colors": { ... },
"content": { ... },
"links": { ... },
"auth": { ... }
}
```
## Development
```bash
# Install dependencies
npm install
# Start development server
npm run dev
# Build for production
npm run build
# Start production server
npm start
```
## Environment Variables
```bash
# IAM backend URL
HANZO_IAM_URL=https://api.hanzo.id
# Public IAM URL (for client-side redirects)
NEXT_PUBLIC_IAM_URL=https://api.hanzo.id
```
## Forking for White-Label
1. Fork this repository
2. Update `lib/branding.ts` with your default branding
3. Add your logo to `public/logos/`
4. Update `app/globals.css` for custom styling
5. Deploy to your infrastructure
## Directory Structure
```
hanzo-id/
├── app/
│ ├── layout.tsx # Root layout with metadata
│ ├── page.tsx # Redirects to /login
│ ├── login/
│ │ └── page.tsx # Main login page
│ ├── signup/ # Sign up page
│ ├── forgot-password # Password reset
│ └── callback/ # OAuth callback handler
├── components/
│ ├── LoginForm.tsx # Login form component
│ └── MarketingPanel.tsx # Right side marketing content
├── lib/
│ └── branding.ts # Branding configuration
├── public/
│ └── logos/ # Organization logos
└── config/ # Additional configuration
```
## License
MIT - Fork and customize freely!
-286
View File
@@ -1,286 +0,0 @@
'use client'
export const runtime = 'edge'
import { useEffect, useState } from 'react'
import { fetchUserInfo } from '@/lib/oauth'
import { getIamUrl, getOrg } from '@/lib/iam'
import { staticBranding, defaultBranding, resolveBrandingDomain, type BrandingConfig } from '@/lib/branding'
interface User {
sub: string
name?: string
displayName?: string
email?: string
avatar?: string
}
// Per-org app links
const orgApps: Record<string, { name: string; href: string; description: string }[]> = {
hanzo: [
{ name: 'Console', href: 'https://console.hanzo.ai', description: 'Observability & traces' },
{ name: 'Chat', href: 'https://hanzo.chat', description: 'AI chat interface' },
{ name: 'Cloud', href: 'https://cloud.hanzo.ai', description: 'AI model API' },
{ name: 'Analytics', href: 'https://analytics.hanzo.ai', description: 'Web analytics' },
{ name: 'Platform', href: 'https://platform.hanzo.ai', description: 'PaaS deployments' },
{ name: 'Storage', href: 'https://s3.hanzo.ai', description: 'S3-compatible storage' },
],
lux: [
{ name: 'Bridge', href: 'https://bridge.lux.network', description: 'Cross-chain bridge' },
{ name: 'Exchange', href: 'https://lux.exchange', description: 'DEX trading' },
{ name: 'Cloud', href: 'https://cloud.lux.network', description: 'Lux Cloud' },
{ name: 'Explorer', href: 'https://explore.lux.network', description: 'Block explorer' },
],
zoo: [
{ name: 'Network', href: 'https://zoo.ngo', description: 'Zoo Labs Foundation' },
{ name: 'ZIPs', href: 'https://zips.zoo.ngo', description: 'Improvement proposals' },
],
pars: [
{ name: 'Network', href: 'https://pars.network', description: 'Pars Network' },
{ name: 'Foundation', href: 'https://parsis.foundation', description: 'Parsis Foundation' },
],
}
// Per-org billing URL
function getBillingUrl(org: string): string {
switch (org) {
case 'lux': return 'https://billing.lux.network'
case 'zoo': return 'https://billing.zoo.network'
case 'pars': return 'https://billing.pars.network'
default: return 'https://billing.hanzo.ai'
}
}
export default function AccountPage() {
const [user, setUser] = useState<User | null>(null)
const [isLoading, setIsLoading] = useState(true)
const host = typeof window !== 'undefined' ? window.location.hostname : 'hanzo.id'
const domain = resolveBrandingDomain(host)
const staticConfig = staticBranding[domain]
const branding: BrandingConfig = staticConfig
? { ...defaultBranding, ...staticConfig, domain }
: { ...defaultBranding, domain }
const org = getOrg(host)
const apps = orgApps[org] || orgApps.hanzo
const billingUrl = getBillingUrl(org)
useEffect(() => {
loadUser()
}, [])
async function loadUser() {
const token = localStorage.getItem('hanzo_access_token')
if (!token) {
window.location.href = '/login'
return
}
// Try cached user first
try {
const cached = localStorage.getItem('hanzo_user')
if (cached) {
setUser(JSON.parse(cached))
setIsLoading(false)
}
} catch {}
// Fetch fresh user info — use same-origin proxy to avoid CORS
try {
const userinfoUrl = window.location.origin + '/oauth/userinfo'
const info = await fetchUserInfo(userinfoUrl.replace('/oauth/userinfo', ''), token)
const userData: User = {
sub: info.sub,
name: info.name,
displayName: info.displayName,
email: info.email,
avatar: info.avatar || info.permanentAvatar,
}
// Also try decoding id_token for richer claims
if ((!userData.email || !userData.displayName) && localStorage.getItem('hanzo_id_token')) {
try {
const idToken = localStorage.getItem('hanzo_id_token')!
const p = JSON.parse(atob(idToken.split('.')[1]))
userData.email = userData.email || p.email
userData.displayName = userData.displayName || p.displayName || p.name || p.preferred_username
userData.name = userData.name || p.name || p.preferred_username
userData.avatar = userData.avatar || p.avatar || p.picture || p.permanentAvatar
} catch {}
}
setUser(userData)
localStorage.setItem('hanzo_user', JSON.stringify(userData))
} catch {
// Token expired or invalid
localStorage.removeItem('hanzo_access_token')
localStorage.removeItem('hanzo_user')
window.location.href = '/login'
return
} finally {
setIsLoading(false)
}
}
function handleLogout() {
localStorage.removeItem('hanzo_access_token')
localStorage.removeItem('hanzo_refresh_token')
localStorage.removeItem('hanzo_user')
window.location.href = '/login'
}
const cssVars = {
'--color-primary': branding.colors.primary,
'--color-primary-text': branding.colors.primaryText,
'--color-background': branding.colors.background,
'--color-surface': branding.colors.surface,
'--color-text': branding.colors.text,
'--color-text-muted': branding.colors.textMuted,
'--color-border': branding.colors.border,
'--color-error': branding.colors.error,
} as React.CSSProperties
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-black">
<div
className="animate-spin w-8 h-8 border-2 border-zinc-700 rounded-full"
style={{ borderTopColor: branding.colors.primary }}
/>
</div>
)
}
return (
<div className="min-h-screen bg-black" style={cssVars}>
{/* Nav */}
<nav className="flex items-center justify-between px-6 md:px-12 py-4 border-b border-zinc-800/50">
<a href="/" className="flex items-center gap-3">
<img src={branding.logo} alt={branding.orgName} className="h-8" />
</a>
<div className="flex items-center gap-4">
<a
href={billingUrl}
className="text-sm text-zinc-400 hover:text-white transition-colors"
>
Billing
</a>
<button
onClick={handleLogout}
className="text-sm text-zinc-400 hover:text-white transition-colors"
>
Sign out
</button>
</div>
</nav>
<div className="max-w-4xl mx-auto px-6 md:px-12 py-12">
{/* Profile header */}
<div className="flex items-center gap-6 mb-12">
{user?.avatar ? (
<img src={user.avatar} alt="" className="w-20 h-20 rounded-full" />
) : (
<div
className="w-20 h-20 rounded-full flex items-center justify-center text-3xl font-bold"
style={{ backgroundColor: branding.colors.primary + '20', color: branding.colors.primary }}
>
{(user?.displayName || user?.name || user?.email || '?')[0].toUpperCase()}
</div>
)}
<div>
<h1 className="text-3xl font-bold text-white">
{user?.displayName || user?.name || 'User'}
</h1>
{user?.email && (
<p className="text-zinc-400 mt-1">{user.email}</p>
)}
</div>
</div>
<div className="grid md:grid-cols-2 gap-6">
{/* Account info */}
<div className="p-6 rounded-xl border border-zinc-800 bg-zinc-900/30">
<h2 className="text-lg font-semibold text-white mb-4">Account</h2>
<div className="space-y-4">
<div>
<div className="text-xs text-zinc-500 uppercase tracking-wider mb-1">User ID</div>
<div className="text-white font-mono text-sm">{user?.sub}</div>
</div>
{user?.name && (
<div>
<div className="text-xs text-zinc-500 uppercase tracking-wider mb-1">Username</div>
<div className="text-white">{user.name}</div>
</div>
)}
{user?.email && (
<div>
<div className="text-xs text-zinc-500 uppercase tracking-wider mb-1">Email</div>
<div className="text-white">{user.email}</div>
</div>
)}
<div>
<div className="text-xs text-zinc-500 uppercase tracking-wider mb-1">Organization</div>
<div className="text-white">{branding.orgName}</div>
</div>
</div>
</div>
{/* Billing */}
<div className="p-6 rounded-xl border border-zinc-800 bg-zinc-900/30">
<h2 className="text-lg font-semibold text-white mb-4">Billing & Usage</h2>
<p className="text-zinc-400 text-sm mb-6">
Manage your subscription, payment methods, and usage.
</p>
<a
href={billingUrl}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg font-medium text-sm transition-opacity hover:opacity-90"
style={{ backgroundColor: branding.colors.primary, color: branding.colors.primaryText }}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
</svg>
Manage Billing
</a>
</div>
</div>
{/* Apps */}
<div className="mt-8">
<h2 className="text-lg font-semibold text-white mb-4">{branding.orgName} Apps</h2>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
{apps.map((app) => (
<a
key={app.name}
href={app.href}
className="p-4 rounded-xl border border-zinc-800 bg-zinc-900/30 hover:bg-zinc-900/60 transition-colors group"
>
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-white">{app.name}</span>
<svg className="w-4 h-4 text-zinc-600 group-hover:text-zinc-400 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</div>
<p className="text-sm text-zinc-500">{app.description}</p>
</a>
))}
</div>
</div>
{/* Actions */}
<div className="mt-12 pt-8 border-t border-zinc-800 flex items-center justify-between">
<a
href="/"
className="text-sm text-zinc-500 hover:text-white transition-colors"
>
&larr; Back to {branding.orgName}
</a>
<button
onClick={handleLogout}
className="px-4 py-2 rounded-lg border border-zinc-700 text-zinc-400 hover:text-white hover:border-zinc-500 transition-colors text-sm"
>
Sign out
</button>
</div>
</div>
</div>
)
}
-157
View File
@@ -1,157 +0,0 @@
/**
* OAuth bridge callback for downstream apps (Platform, MPC, etc.)
*
* Handles the code exchange on behalf of apps that need IAM tokens.
* Decodes the state param to determine the redirect target.
*
* Usage:
* GET /api/auth/bridge?code=...&state=base64({redirect,clientId,app})
*
* The state is a base64-encoded JSON object:
* { redirect: "https://platform.hanzo.ai/login", clientId: "...", app: "platform" }
*/
import { NextRequest, NextResponse } from 'next/server'
import { getIamUrl } from '@/lib/iam'
export const runtime = 'edge'
// Allowed redirect origins for security
const ALLOWED_ORIGINS = [
'https://platform.hanzo.ai',
'https://console.hanzo.ai',
'https://cloud.hanzo.ai',
'https://mpc.hanzo.ai',
'https://mpc.lux.network',
'https://mpc.zoo.network',
'https://mpc.pars.network',
'https://commerce.hanzo.ai',
'https://billing.hanzo.ai',
'https://analytics.hanzo.ai',
'https://insights.hanzo.ai',
'https://hanzo.ai',
'https://lux.id',
'https://zoo.id',
'https://pars.id',
'https://hanzo.id',
...(process.env.NODE_ENV !== 'production' ? [
'http://localhost:3000',
'http://localhost:3001',
'http://localhost:4000',
'http://localhost:5173',
] : []),
]
function validateRedirectOrigin(redirect: string, fallback: string): string {
try {
const url = new URL(redirect)
if (ALLOWED_ORIGINS.some(o => url.origin === new URL(o).origin)) {
return redirect
}
} catch {}
return fallback
}
export async function GET(request: NextRequest) {
const url = new URL(request.url)
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
const directAccessToken = url.searchParams.get('access_token')
const directRefreshToken = url.searchParams.get('refresh_token')
const host = url.hostname
const iamOrigin = getIamUrl(host)
const iamHost = new URL(iamOrigin).host
const defaultRedirect = `${url.origin}/login`
// Reject oversized state
if (state && state.length > 4096) {
return NextResponse.redirect(`${url.origin}/login?error=invalid_state`)
}
// Decode state for redirect target and client info
let redirect = defaultRedirect
let clientId = process.env.NEXT_PUBLIC_CLIENT_ID || 'hanzo-id'
let codeVerifier = ''
try {
const decoded = JSON.parse(atob(state || ''))
if (decoded.redirect) {
redirect = validateRedirectOrigin(decoded.redirect, defaultRedirect)
}
if (decoded.clientId) {
clientId = decoded.clientId
}
if (decoded.code_verifier) {
codeVerifier = decoded.code_verifier
}
} catch {}
const redirectUrl = new URL(redirect)
// Direct token passthrough (from implicit flow / password login)
if (directAccessToken) {
redirectUrl.searchParams.set('access_token', directAccessToken)
redirectUrl.searchParams.set('refresh_token', directRefreshToken || '')
redirectUrl.searchParams.set('provider', 'hanzo')
redirectUrl.searchParams.set('status', '200')
return NextResponse.redirect(redirectUrl.toString())
}
// Authorization code exchange
if (!code) {
redirectUrl.searchParams.set('error', 'no_code')
return NextResponse.redirect(redirectUrl.toString())
}
const callbackUri = `${url.origin}/api/auth/bridge`
const tokenPayload: Record<string, string> = {
grant_type: 'authorization_code',
code,
client_id: clientId,
redirect_uri: callbackUri,
}
// Forward PKCE code_verifier if provided (prevents authorization code interception)
if (codeVerifier) {
tokenPayload.code_verifier = codeVerifier
}
const clientSecret = process.env.IAM_CLIENT_SECRET || process.env.HANZO_IAM_CLIENT_SECRET
if (clientSecret) {
tokenPayload.client_secret = clientSecret
}
const tokenRes = await fetch(`${iamOrigin}/oauth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Host': iamHost,
},
body: JSON.stringify(tokenPayload),
})
const tokens = await tokenRes.json().catch(() => ({} as Record<string, unknown>))
if (tokens.access_token) {
redirectUrl.searchParams.set('access_token', tokens.access_token as string)
redirectUrl.searchParams.set('refresh_token', (tokens.refresh_token as string) || '')
redirectUrl.searchParams.set(
'expires_at',
tokens.expires_in
? String(Math.floor(Date.now() / 1000) + Number(tokens.expires_in))
: '0',
)
redirectUrl.searchParams.set('provider', 'hanzo')
redirectUrl.searchParams.set('status', '200')
} else {
redirectUrl.searchParams.set('error', (tokens.error as string) || 'token_exchange_failed')
redirectUrl.searchParams.set(
'error_description',
(tokens.error_description as string) || (tokens.message as string) || 'Failed to exchange code',
)
}
return NextResponse.redirect(redirectUrl.toString())
}
@@ -1,193 +0,0 @@
/**
* Server-side social OAuth callback handler.
*
* When a user logs in via Google/GitHub/etc, the social provider redirects
* back to /callback with ?code=&state=. The IAM SPA callback relies on
* sessionStorage which breaks through our proxy layer, so we handle the
* full exchange server-side.
*
* Flow:
* 1. Decode state (base64 query string or JSON) to extract app/org/provider
* 2. Read _oauth_ctx cookie as fallback context
* 3. POST to IAM /api/login with type:'token' to complete the login
* 4. Redirect to the original redirect_uri with tokens
*/
import { NextRequest, NextResponse } from 'next/server'
import { resolveClient } from '@/lib/clients'
import { getIamUrl } from '@/lib/iam'
export const runtime = 'edge'
// Allowed redirect origins — must match bridge handler allowlist
const ALLOWED_ORIGINS = [
'https://platform.hanzo.ai',
'https://console.hanzo.ai',
'https://cloud.hanzo.ai',
'https://mpc.hanzo.ai',
'https://mpc.lux.network',
'https://mpc.zoo.network',
'https://mpc.pars.network',
'https://commerce.hanzo.ai',
'https://billing.hanzo.ai',
'https://analytics.hanzo.ai',
'https://insights.hanzo.ai',
'https://hanzo.ai',
'https://lux.id',
'https://zoo.id',
'https://pars.id',
'https://hanzo.id',
...(process.env.NODE_ENV !== 'production' ? [
'http://localhost:3000',
'http://localhost:3001',
'http://localhost:4000',
'http://localhost:5173',
] : []),
]
function validateRedirectOrigin(redirect: string, fallback: string): string {
try {
const url = new URL(redirect)
if (ALLOWED_ORIGINS.some(o => url.origin === new URL(o).origin)) {
return redirect
}
} catch {}
return fallback
}
export async function GET(request: NextRequest) {
const url = new URL(request.url)
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
const host = url.hostname
const iamOrigin = getIamUrl(host)
const iamHost = new URL(iamOrigin).host
if (!code || !state) {
return NextResponse.redirect(new URL('/login?error=missing_code_or_state', url.origin))
}
// Reject oversized state to prevent abuse
if (state.length > 4096) {
return NextResponse.redirect(new URL('/login?error=invalid_state', url.origin))
}
// Decode state — IAM encodes as base64 query string or JSON
let stateParams = new URLSearchParams()
let stateObj: Record<string, string> = {}
try {
const decoded = atob(state)
if (decoded.startsWith('?') || decoded.includes('=')) {
stateParams = new URLSearchParams(decoded)
} else {
stateObj = JSON.parse(decoded)
}
} catch {
return NextResponse.redirect(new URL('/login?error=invalid_state', url.origin))
}
// Read _oauth_ctx cookie as fallback
const cookieHeader = request.headers.get('cookie') || ''
let oauthCtx: Record<string, string> = {}
const ctxMatch = cookieHeader.match(/_oauth_ctx=([^;]+)/)
if (ctxMatch) {
try {
oauthCtx = JSON.parse(atob(decodeURIComponent(ctxMatch[1])))
} catch {}
}
// Resolve context from state (primary), cookie (secondary), JSON (tertiary)
const application = stateParams.get('application') || oauthCtx.application || stateObj.application || ''
const provider = stateParams.get('provider') || oauthCtx.provider || stateObj.provider || ''
const method = stateParams.get('method') || stateObj.method || 'link'
const stateClientId = stateParams.get('client_id') || oauthCtx.clientId || ''
const originalRedirectUri = stateParams.get('redirect_uri') || oauthCtx.redirectUri || stateObj.redirectUri || ''
// Resolve organization from client map — ALWAYS prefer client map over untrusted sources
// to prevent cross-tenant org bypass attacks
let organization = ''
if (stateClientId) {
const client = resolveClient(stateClientId)
if (client) organization = client.organization
}
// Only fall back to cookie/state if no client map match, and validate it's a known org
if (!organization) {
const KNOWN_ORGS = ['hanzo', 'lux', 'zoo', 'pars', 'zen', 'adnexus']
const candidateOrg = oauthCtx.organization || stateObj.organization || ''
if (KNOWN_ORGS.includes(candidateOrg)) {
organization = candidateOrg
}
}
// Call IAM to complete the social login
// type:'token' because our IAM version has a bug where type:'code'
// maps to an empty grant_type and fails
const loginRes = await fetch(`${iamOrigin}/api/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Cookie': cookieHeader,
'Host': iamHost,
},
body: JSON.stringify({
type: 'token',
code,
state: 'hanzo',
redirectUri: `${url.origin}/callback`,
application,
organization,
provider,
method,
}),
})
const loginData = await loginRes.json().catch(() => ({} as Record<string, unknown>))
// Clear the oauth context cookie
const clearCookie = '_oauth_ctx=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0'
if (loginData.status === 'ok' && loginData.data) {
// Validate redirect URI against allowlist to prevent open redirect
const candidateRedirect = originalRedirectUri
? originalRedirectUri.replaceAll(iamHost, host)
: `${url.origin}/login`
const targetRedirectUri = validateRedirectOrigin(candidateRedirect, `${url.origin}/login`)
const targetUrl = new URL(targetRedirectUri)
targetUrl.searchParams.set('access_token', loginData.data as string)
targetUrl.searchParams.set('refresh_token', (loginData.data2 as string) || '')
targetUrl.searchParams.set('provider', 'hanzo')
targetUrl.searchParams.set('status', '200')
return new NextResponse(null, {
status: 302,
headers: {
'Location': targetUrl.toString(),
'Set-Cookie': clearCookie,
},
})
}
// Error path — never include sensitive debug info in redirect params
if (originalRedirectUri) {
const candidateError = originalRedirectUri.replaceAll(iamHost, host)
const safeErrorRedirect = validateRedirectOrigin(candidateError, `${url.origin}/login`)
const errorUrl = new URL(safeErrorRedirect)
errorUrl.searchParams.set('error', (loginData.msg as string) || 'social_login_failed')
return new NextResponse(null, {
status: 302,
headers: {
'Location': errorUrl.toString(),
'Set-Cookie': clearCookie,
},
})
}
return new NextResponse(null, {
status: 302,
headers: {
'Location': `${url.origin}/login?error=${encodeURIComponent((loginData.msg as string) || 'social_login_failed')}`,
'Set-Cookie': clearCookie,
},
})
}
-50
View File
@@ -1,50 +0,0 @@
/**
* Server-side logout handler.
*
* Calls IAM to invalidate the session, clears cookies,
* and redirects to the login page.
*/
import { NextRequest, NextResponse } from 'next/server'
import { getIamUrl } from '@/lib/iam'
export const runtime = 'edge'
export async function GET(request: NextRequest) {
const url = new URL(request.url)
const host = url.hostname
const iamOrigin = getIamUrl(host)
const iamHost = new URL(iamOrigin).host
const idTokenHint = url.searchParams.get('id_token_hint') || ''
const postLogoutRedirectUri = url.searchParams.get('post_logout_redirect_uri') || `${url.origin}/login?prompt=login`
const state = url.searchParams.get('state') || ''
// Call IAM logout
const logoutUrl = new URL('/v1/iam/logout', iamOrigin)
logoutUrl.searchParams.set('id_token_hint', idTokenHint)
logoutUrl.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri)
logoutUrl.searchParams.set('state', state)
try {
await fetch(logoutUrl.toString(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Host': iamHost,
},
})
} catch {}
return new NextResponse(null, {
status: 302,
headers: {
Location: '/login?prompt=login',
'Set-Cookie': 'iam_session_id=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax',
},
})
}
export async function POST(request: NextRequest) {
return GET(request)
}
-177
View File
@@ -1,177 +0,0 @@
'use client'
export const runtime = 'edge'
import { Suspense, useEffect, useState } from 'react'
import { useSearchParams } from 'next/navigation'
import { exchangeCode } from '@/lib/oauth'
import { getIamUrl, getDefaultClientId } from '@/lib/iam'
/**
* Claim a referral code after successful login/signup.
* Fire-and-forget: never blocks redirect on failure.
*/
function claimReferral(accessToken: string, userId: string, email: string) {
const refCode = sessionStorage.getItem('hanzo_ref_code')
if (!refCode) return
sessionStorage.removeItem('hanzo_ref_code')
fetch('https://commerce.hanzo.ai/api/v1/referral/claim', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ code: refCode, userId, email }),
}).catch(() => {})
}
function CallbackHandler() {
const searchParams = useSearchParams()
const [error, setError] = useState<string | null>(null)
useEffect(() => {
handleCallback()
}, [])
async function handleCallback() {
const errorParam = searchParams.get('error')
if (errorParam) {
setError(searchParams.get('error_description') || errorParam)
return
}
// Token passthrough from social login / bridge callback
const accessToken = searchParams.get('access_token')
if (accessToken) {
localStorage.setItem('hanzo_access_token', accessToken)
const refreshToken = searchParams.get('refresh_token')
if (refreshToken) {
localStorage.setItem('hanzo_refresh_token', refreshToken)
}
const idToken = searchParams.get('id_token')
if (idToken) {
localStorage.setItem('hanzo_id_token', idToken)
}
// Extract user info: prefer id_token (has full claims), fall back to access_token
try {
const idPayload = idToken
? JSON.parse(atob(idToken.split('.')[1]))
: null
const atPayload = JSON.parse(atob(accessToken.split('.')[1]))
const p = idPayload || atPayload
localStorage.setItem('hanzo_user', JSON.stringify({
sub: p.sub || atPayload.sub || atPayload.name,
name: p.name || p.preferred_username || atPayload.name,
displayName: p.displayName || p.name || p.preferred_username,
email: p.email || atPayload.email,
avatar: p.avatar || p.picture || p.permanentAvatar,
}))
claimReferral(accessToken, p.sub || atPayload.sub || atPayload.name, p.email || atPayload.email)
} catch {}
const postLoginRedirect = sessionStorage.getItem('hanzo_auth_post_login_redirect')
if (postLoginRedirect) {
sessionStorage.removeItem('hanzo_auth_post_login_redirect')
window.location.href = postLoginRedirect
} else {
window.location.href = '/account'
}
return
}
// PKCE authorization code flow
const code = searchParams.get('code')
const state = searchParams.get('state')
if (!code || !state) {
setError('Missing authorization code or state')
return
}
try {
const host = window.location.hostname
const iamUrl = getIamUrl(host)
const clientId = getDefaultClientId(host)
const redirectUri = `${window.location.origin}/callback`
const tokens = await exchangeCode({
iamUrl,
code,
state,
clientId,
redirectUri,
})
localStorage.setItem('hanzo_access_token', tokens.access_token)
if (tokens.refresh_token) {
localStorage.setItem('hanzo_refresh_token', tokens.refresh_token)
}
if (tokens.id_token) {
localStorage.setItem('hanzo_id_token', tokens.id_token)
}
// Extract user info: prefer id_token (has full claims), fall back to access_token
try {
const idPayload = tokens.id_token
? JSON.parse(atob(tokens.id_token.split('.')[1]))
: null
const atPayload = JSON.parse(atob(tokens.access_token.split('.')[1]))
const p = idPayload || atPayload
localStorage.setItem('hanzo_user', JSON.stringify({
sub: p.sub || atPayload.sub || atPayload.name,
name: p.name || p.preferred_username || atPayload.name,
displayName: p.displayName || p.name || p.preferred_username,
email: p.email || atPayload.email,
avatar: p.avatar || p.picture || p.permanentAvatar,
}))
claimReferral(tokens.access_token, p.sub || atPayload.sub || atPayload.name, p.email || atPayload.email)
} catch {}
const postLoginRedirect = sessionStorage.getItem('hanzo_auth_post_login_redirect')
if (postLoginRedirect) {
sessionStorage.removeItem('hanzo_auth_post_login_redirect')
window.location.href = postLoginRedirect
} else {
window.location.href = '/account'
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Authentication failed')
}
}
if (error) {
return (
<div className="login-card max-w-md w-full p-8 text-center">
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
{error}
</div>
<a href="/login" className="link text-sm">Back to login</a>
</div>
)
}
return (
<div className="text-center">
<div className="animate-spin w-8 h-8 border-2 border-zinc-700 border-t-white rounded-full mx-auto mb-4" />
<p className="text-zinc-400 text-sm">Completing sign in...</p>
</div>
)
}
export default function CallbackPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-black">
<Suspense fallback={
<div className="text-center">
<div className="animate-spin w-8 h-8 border-2 border-zinc-700 border-t-white rounded-full mx-auto mb-4" />
<p className="text-zinc-400 text-sm">Loading...</p>
</div>
}>
<CallbackHandler />
</Suspense>
</div>
)
}
-109
View File
@@ -1,109 +0,0 @@
'use client'
export const runtime = 'edge'
import { useState } from 'react'
import { getIamUrl, getOrg } from '@/lib/iam'
export default function ForgotPasswordPage() {
const [email, setEmail] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [sent, setSent] = useState(false)
const [error, setError] = useState<string | null>(null)
const host = typeof window !== 'undefined' ? window.location.hostname : 'hanzo.id'
const iamUrl = getIamUrl(host)
const org = getOrg(host)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setIsLoading(true)
try {
if (!email) throw new Error('Please enter your email address')
const res = await fetch(`${iamUrl}/api/send-verification-code`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
dest: email,
type: 'reset',
organization: org,
applicationId: `admin/${org}`,
}),
})
const data = await res.json()
if (data.status === 'error') throw new Error(data.msg || 'Failed to send reset email')
setSent(true)
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong')
} finally {
setIsLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-black p-8">
<div className="login-card w-full max-w-md p-8">
<h1 className="text-2xl font-bold text-white mb-2">Reset password</h1>
<p className="text-zinc-400 text-sm mb-6">
{sent
? 'Check your email for a password reset link.'
: 'Enter your email and we\'ll send you a reset link.'}
</p>
{error && (
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
{error}
</div>
)}
{sent ? (
<div className="space-y-4">
<div className="p-4 rounded-lg bg-green-500/10 border border-green-500/20 text-green-400 text-sm">
If an account exists for {email}, you will receive a password reset email shortly.
</div>
<a href="/login" className="block text-center link text-sm">
Back to login
</a>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<input
type="email"
placeholder="Email address"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="input w-full pl-10 py-3 rounded-lg"
autoComplete="email"
disabled={isLoading}
/>
</div>
<button
type="submit"
disabled={isLoading}
className="btn-primary w-full py-3 rounded-lg font-medium disabled:opacity-50"
>
{isLoading ? 'Sending...' : 'Send reset link'}
</button>
<p className="text-center text-sm text-zinc-500">
Remember your password?{' '}
<a href="/login" className="link">Sign in</a>
</p>
</form>
)}
</div>
</div>
)
}
-68
View File
@@ -1,68 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
/* Default Hanzo colors - overridden by branding config */
--color-primary: #e4e4e7;
--color-primary-text: #09090b;
--color-background: #000000;
--color-surface: #0a0a0a;
--color-text: #ffffff;
--color-text-muted: #a1a1aa;
--color-border: #27272a;
--color-error: #dc2626;
}
body {
background-color: var(--color-background);
color: var(--color-text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
/* Login card styling */
.login-card {
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
}
/* Button styling */
.btn-primary {
background-color: var(--color-primary);
color: var(--color-primary-text);
transition: opacity 0.2s;
}
.btn-primary:hover {
opacity: 0.9;
}
/* Input styling */
.input {
background-color: var(--color-background);
border: 1px solid var(--color-border);
color: var(--color-text);
}
.input:focus {
border-color: var(--color-primary);
outline: none;
box-shadow: 0 0 0 2px rgba(228, 228, 231, 0.2);
}
/* Link styling */
.link {
color: var(--color-primary);
}
.link:hover {
text-decoration: underline;
}
/* Quote card styling */
.quote-card {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.02) 100%);
border-radius: 12px;
padding: 24px;
}
-32
View File
@@ -1,32 +0,0 @@
export const runtime = 'edge'
import type { Metadata } from 'next'
import { headers } from 'next/headers'
import { staticBranding, defaultBranding, resolveBrandingDomain } from '@/lib/branding'
import './globals.css'
export async function generateMetadata(): Promise<Metadata> {
const headersList = await headers()
const host = headersList.get('host') || 'hanzo.id'
const domain = resolveBrandingDomain(host)
const staticConfig = staticBranding[domain]
const orgName = staticConfig?.orgName || defaultBranding.orgName
return {
title: `${orgName} ID`,
description: `Secure identity for ${orgName}. Sign in, manage your account, and access all ${orgName} services.`,
}
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className="antialiased">{children}</body>
</html>
)
}
-64
View File
@@ -1,64 +0,0 @@
import { Suspense } from 'react'
import { headers } from 'next/headers'
import { getBranding, staticBranding, defaultBranding, resolveBrandingDomain, BrandingConfig } from '@/lib/branding'
import LoginForm from '@/components/LoginForm'
import MarketingPanel from '@/components/MarketingPanel'
import LanguageDropdown from '@/components/LanguageDropdown'
export const runtime = 'edge'
async function getBrandingForDomain(): Promise<BrandingConfig> {
const headersList = await headers()
const host = headersList.get('host') || 'hanzo.id'
const domain = resolveBrandingDomain(host)
// First check static configs, then fetch from IAM
const staticConfig = staticBranding[domain]
if (staticConfig) {
return { ...defaultBranding, ...staticConfig, domain }
}
return getBranding(domain)
}
export default async function LoginPage() {
const branding = await getBrandingForDomain()
// Generate CSS variables from branding
const cssVars = {
'--color-primary': branding.colors.primary,
'--color-primary-text': branding.colors.primaryText,
'--color-background': branding.colors.background,
'--color-surface': branding.colors.surface,
'--color-text': branding.colors.text,
'--color-text-muted': branding.colors.textMuted,
'--color-border': branding.colors.border,
'--color-error': branding.colors.error,
} as React.CSSProperties
return (
<div className="min-h-screen flex" style={cssVars}>
{/* Left side - Login Form */}
<div className="w-full lg:w-1/2 flex items-center justify-center p-8">
<div className="login-card w-full max-w-md p-8">
{/* Logo */}
<div className="flex items-center justify-between mb-8">
<img
src={branding.logo}
alt={branding.orgName}
className="h-10"
/>
<Suspense><LanguageDropdown /></Suspense>
</div>
<Suspense><LoginForm branding={branding} /></Suspense>
</div>
</div>
{/* Right side - Marketing Panel */}
<div className="hidden lg:flex w-1/2 items-center justify-center p-12 bg-gradient-to-br from-black via-zinc-900 to-black">
<MarketingPanel branding={branding} />
</div>
</div>
)
}
-15
View File
@@ -1,15 +0,0 @@
export const runtime = 'edge'
export default function NotFound() {
return (
<div className="min-h-screen flex items-center justify-center bg-black">
<div className="text-center">
<h1 className="text-6xl font-bold text-white mb-4">404</h1>
<p className="text-zinc-400 mb-8">Page not found</p>
<a href="/login" className="text-sm text-zinc-500 hover:text-white transition-colors">
Go to login
</a>
</div>
</div>
)
}
-470
View File
@@ -1,470 +0,0 @@
import { headers } from 'next/headers'
import Link from 'next/link'
import { staticBranding, defaultBranding, resolveBrandingDomain, type BrandingConfig } from '@/lib/branding'
export const runtime = 'edge'
// Per-org landing page content
interface LandingContent {
headline: string
description: string
features: { title: string; description: string; icon: string }[]
standards: { label: string; value: string }[]
cta: string
secondaryCta?: { label: string; href: string }
}
const landingContent: Record<string, LandingContent> = {
lux: {
headline: 'Your Identity on Lux',
description: 'Decentralized identity anchored on high-performance blockchain infrastructure. Own your credentials, prove who you are without exposing what you are, and authenticate across the entire Lux ecosystem with one login.',
features: [
{
title: 'Decentralized Identifiers (DIDs)',
description: 'W3C-standard DIDs anchored on Lux Network. Your identity is portable, censorship-resistant, and fully under your control. No central authority can revoke or freeze your credentials.',
icon: 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z',
},
{
title: 'Verifiable Credentials',
description: 'Issue and present tamper-proof credentials — KYC attestations, membership proofs, reputation scores — without revealing unnecessary personal data. Selective disclosure by default.',
icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z',
},
{
title: 'Cross-Chain Single Sign-On',
description: 'One identity across Lux mainnet, subnets, the bridge, DEX, and every ecosystem dApp. OAuth2/OIDC compliant — works with traditional apps too.',
icon: 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1',
},
{
title: 'Post-Quantum Cryptography',
description: 'Forward-looking key management with lattice-based and hash-based signatures. Your identity stays secure against quantum computing threats — today and tomorrow.',
icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z',
},
{
title: 'Key Recovery & Social Recovery',
description: 'Lost your keys? Recover your identity through trusted guardians, multi-sig recovery, or hardware backup — no single point of failure.',
icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z',
},
{
title: 'Privacy-Preserving Auth',
description: 'Zero-knowledge proofs let you prove eligibility, age, membership, or accreditation without revealing the underlying data. Your privacy is non-negotiable.',
icon: 'M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
},
],
standards: [
{ label: 'W3C DID', value: 'Core v1.0' },
{ label: 'OAuth 2.0', value: 'RFC 6749' },
{ label: 'OIDC', value: 'Core 1.0' },
{ label: 'PKCE', value: 'RFC 7636' },
{ label: 'WebAuthn', value: 'L2' },
{ label: 'FIDO2', value: 'Passkeys' },
],
cta: 'Create Your Lux ID',
secondaryCta: { label: 'Explore Lux Network', href: 'https://lux.network' },
},
pars: {
headline: 'Your Identity on Pars',
description: 'Self-sovereign identity for the next generation of decentralized infrastructure. Own your data, control your credentials, and authenticate across the Pars ecosystem with confidence.',
features: [
{
title: 'Self-Sovereign Identity',
description: 'Your identity belongs to you — not a corporation, not a government. W3C DID-compliant identifiers give you full ownership and portability.',
icon: 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z',
},
{
title: 'Verifiable Credentials',
description: 'Carry tamper-proof digital credentials — academic degrees, professional certifications, KYC attestations — verified on-chain, shared on your terms.',
icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z',
},
{
title: 'Ecosystem-Wide Access',
description: 'One account for all Pars applications, governance, staking, and partner integrations. Standards-based SSO that just works.',
icon: 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
},
{
title: 'Privacy by Design',
description: 'Zero-knowledge proofs and selective disclosure — share only what you choose. Prove you\'re eligible without revealing why.',
icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z',
},
{
title: 'Multi-Factor Security',
description: 'Hardware keys, biometrics, TOTP, passkeys — layer security however you need. Enterprise-grade protection for every user.',
icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z',
},
{
title: 'Open Standards',
description: 'Built on OAuth 2.0, OpenID Connect, W3C DIDs, and Verifiable Credentials. No vendor lock-in, interoperable with any standards-compliant system.',
icon: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4',
},
],
standards: [
{ label: 'W3C DID', value: 'Core v1.0' },
{ label: 'OAuth 2.0', value: 'RFC 6749' },
{ label: 'OIDC', value: 'Core 1.0' },
{ label: 'PKCE', value: 'RFC 7636' },
{ label: 'WebAuthn', value: 'L2' },
{ label: 'VC', value: 'Data Model' },
],
cta: 'Create Your Pars ID',
secondaryCta: { label: 'Explore Pars Network', href: 'https://pars.network' },
},
zoo: {
headline: 'Your Identity on Zoo',
description: 'Verifiable research identity for the open AI research network. Collaborate on decentralized science, participate in governance, and build reputation across the Zoo ecosystem.',
features: [
{
title: 'Research Identity',
description: 'A verifiable, portable identity for researchers, contributors, and AI practitioners. Link your publications, models, and contributions to a cryptographic identity you own.',
icon: 'M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z',
},
{
title: 'Governance & ZIPs',
description: 'Participate in Zoo Improvement Proposals (ZIPs) with a verified identity. Vote on protocol upgrades, fund allocation, and research priorities.',
icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10',
},
{
title: 'Cross-Network Reputation',
description: 'Build reputation that travels with you. Contributions to Zoo, Hanzo, and partner networks all feed into a unified, verifiable reputation graph.',
icon: 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1',
},
{
title: 'Decentralized Science (DeSci)',
description: 'Credential your research contributions on-chain. Peer review, data sharing, and reproducibility — all backed by verifiable credentials.',
icon: 'M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z',
},
{
title: 'Privacy-First',
description: 'Selective disclosure lets you prove qualifications without exposing personal data. Research anonymously when you need to.',
icon: 'M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
},
{
title: 'Open & Interoperable',
description: 'W3C DID, OAuth 2.0, OIDC — standards-based identity that works with ORCID, institutional logins, and any research platform.',
icon: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4',
},
],
standards: [
{ label: 'W3C DID', value: 'Core v1.0' },
{ label: 'OAuth 2.0', value: 'RFC 6749' },
{ label: 'OIDC', value: 'Core 1.0' },
{ label: 'PKCE', value: 'RFC 7636' },
{ label: 'VC', value: 'Data Model' },
{ label: 'DeSci', value: 'ZIPs' },
],
cta: 'Create Your Zoo ID',
secondaryCta: { label: 'Explore Zoo Network', href: 'https://zoo.ngo' },
},
hanzo: {
headline: 'Your AI Identity',
description: 'One identity across the entire Hanzo AI ecosystem. Secure, standards-based authentication for developers building the future of AI.',
features: [
{
title: 'Unified AI Access',
description: 'Single sign-in to Console, Chat, Cloud, Gateway, and every Hanzo service. One identity, one API key namespace, one billing account.',
icon: 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1',
},
{
title: 'Developer-First Auth',
description: 'OAuth 2.0, OpenID Connect, PKCE, API keys, service tokens — all RFC-standard. SDKs in Python, TypeScript, Go, and Rust. No vendor lock-in.',
icon: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4',
},
{
title: 'Enterprise Security',
description: 'SSO with SAML/OIDC, hardware-backed MFA, fine-grained RBAC, audit logs, and SOC 2 compliance. Built for teams that ship.',
icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z',
},
{
title: 'Multi-Tenant Organizations',
description: 'Create organizations, invite team members, assign roles, and scope API keys — all from a single identity. White-label ready for your own domains.',
icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z',
},
{
title: 'Passkeys & Biometrics',
description: 'FIDO2 passkeys, Face ID, Touch ID, hardware security keys — passwordless authentication that\'s both more secure and more convenient.',
icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z',
},
{
title: 'Web3 + Traditional',
description: 'Connect with MetaMask, WalletConnect, or hardware wallets alongside traditional email/password and social login. Bridge Web2 and Web3 seamlessly.',
icon: 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
},
],
standards: [
{ label: 'OAuth 2.0', value: 'RFC 6749' },
{ label: 'OIDC', value: 'Core 1.0' },
{ label: 'PKCE', value: 'RFC 7636' },
{ label: 'WebAuthn', value: 'L2' },
{ label: 'SAML', value: '2.0' },
{ label: 'FIDO2', value: 'Passkeys' },
],
cta: 'Get Started',
secondaryCta: { label: 'Read the Docs', href: 'https://docs.hanzo.ai' },
},
zen: {
headline: 'Your Zen Identity',
description: 'Access frontier AI models with a single identity. Zen LM powers the next generation of language models — your identity unlocks them all.',
features: [
{
title: 'Model Access',
description: 'Authenticate once to access all Zen LM models — from 600M to 480B parameters. Inference, fine-tuning, and evaluation with one API key.',
icon: 'M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z',
},
{
title: 'Usage & Billing',
description: 'Track model usage, manage API keys, set spending limits, and control team access — all from your Zen ID dashboard.',
icon: 'M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z',
},
{
title: 'Open Standards',
description: 'OAuth 2.0 / OIDC compliant — integrate with any platform, CI/CD pipeline, or workflow. SDKs for every major language.',
icon: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4',
},
{
title: 'Developer Experience',
description: 'CLI login, API key management, scoped tokens, and seamless integration with development tools. Built for AI engineers.',
icon: 'M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z',
},
{
title: 'Team Management',
description: 'Create organizations, invite collaborators, and share model access with fine-grained permissions.',
icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z',
},
{
title: 'Cross-Ecosystem',
description: 'Your Zen ID works across Hanzo, Lux, Zoo, and partner platforms. One identity, every AI service.',
icon: 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
},
],
standards: [
{ label: 'OAuth 2.0', value: 'RFC 6749' },
{ label: 'OIDC', value: 'Core 1.0' },
{ label: 'PKCE', value: 'RFC 7636' },
{ label: 'WebAuthn', value: 'L2' },
{ label: 'FIDO2', value: 'Passkeys' },
{ label: 'JWT', value: 'RFC 7519' },
],
cta: 'Get Started with Zen',
secondaryCta: { label: 'Explore Models', href: 'https://zenlm.org' },
},
adnexus: {
headline: 'Your Ad Nexus Identity',
description: 'Secure identity for the programmatic advertising platform. Manage campaigns, analytics, and integrations with enterprise-grade authentication.',
features: [
{
title: 'Campaign Access',
description: 'Single sign-on to all Ad Nexus tools — campaign manager, analytics dashboard, creative studio, and billing.',
icon: 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1',
},
{
title: 'Team Permissions',
description: 'Role-based access control for agencies and brands. Scoped permissions for campaign managers, analysts, and billing admins.',
icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z',
},
{
title: 'Enterprise SSO',
description: 'SAML, OIDC, and OAuth 2.0 federation. Connect your existing identity provider for seamless onboarding.',
icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z',
},
],
standards: [
{ label: 'OAuth 2.0', value: 'RFC 6749' },
{ label: 'OIDC', value: 'Core 1.0' },
{ label: 'SAML', value: '2.0' },
{ label: 'PKCE', value: 'RFC 7636' },
],
cta: 'Get Started',
secondaryCta: { label: 'Learn More', href: 'https://ad.nexus' },
},
}
const defaultLanding = landingContent.hanzo
async function getBrandingForDomain() {
const headersList = await headers()
const host = headersList.get('host') || 'hanzo.id'
const domain = resolveBrandingDomain(host)
const staticConfig = staticBranding[domain]
const branding: BrandingConfig = staticConfig
? { ...defaultBranding, ...staticConfig, domain }
: { ...defaultBranding, domain }
return branding
}
export default async function Home() {
const branding = await getBrandingForDomain()
const content = landingContent[branding.orgId] || defaultLanding
const cssVars = {
'--color-primary': branding.colors.primary,
'--color-primary-text': branding.colors.primaryText,
'--color-background': branding.colors.background,
'--color-surface': branding.colors.surface,
'--color-text': branding.colors.text,
'--color-text-muted': branding.colors.textMuted,
'--color-border': branding.colors.border,
'--color-error': branding.colors.error,
} as React.CSSProperties
return (
<div className="min-h-screen flex flex-col" style={cssVars}>
{/* Nav */}
<nav className="flex items-center justify-between px-6 md:px-12 py-4 border-b border-zinc-800/50">
<div className="flex items-center gap-3">
<img src={branding.logo} alt={branding.orgName} className="h-8" />
</div>
<div className="flex items-center gap-4">
<Link href="/login" className="text-sm text-zinc-400 hover:text-white transition-colors">
Sign In
</Link>
<Link
href="/signup"
className="text-sm px-4 py-2 rounded-lg font-medium transition-opacity hover:opacity-90"
style={{ backgroundColor: branding.colors.primary, color: branding.colors.primaryText }}
>
Get Started
</Link>
</div>
</nav>
{/* Hero */}
<section className="px-6 md:px-12 py-20 md:py-32">
<div className="max-w-4xl mx-auto text-center">
<div
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full border text-sm mb-8"
style={{ borderColor: branding.colors.primary + '40', color: branding.colors.primary }}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
Decentralized Identity
</div>
<h1 className="text-5xl sm:text-6xl lg:text-7xl font-bold text-white mb-6 leading-tight tracking-tight">
{content.headline}
</h1>
<p className="text-lg md:text-xl text-zinc-400 max-w-2xl mx-auto mb-12 leading-relaxed">
{content.description}
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-8">
<Link
href="/signup"
className="px-8 py-3.5 rounded-lg font-medium text-lg transition-opacity hover:opacity-90 w-full sm:w-auto"
style={{ backgroundColor: branding.colors.primary, color: branding.colors.primaryText }}
>
{content.cta}
</Link>
{content.secondaryCta ? (
<a
href={content.secondaryCta.href}
className="px-8 py-3.5 rounded-lg font-medium text-lg border border-zinc-700 text-zinc-300 hover:text-white hover:border-zinc-500 transition-colors w-full sm:w-auto text-center"
>
{content.secondaryCta.label}
</a>
) : (
<Link
href="/login"
className="px-8 py-3.5 rounded-lg font-medium text-lg border border-zinc-700 text-zinc-300 hover:text-white hover:border-zinc-500 transition-colors w-full sm:w-auto text-center"
>
Sign In
</Link>
)}
</div>
</div>
</section>
{/* Standards bar */}
<section className="border-y border-zinc-800/50 px-6 md:px-12 py-6">
<div className="max-w-5xl mx-auto flex flex-wrap items-center justify-center gap-6 md:gap-10">
{content.standards.map((s, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<span className="text-zinc-500">{s.label}</span>
<span className="text-zinc-300 font-mono text-xs px-1.5 py-0.5 rounded bg-zinc-800">{s.value}</span>
</div>
))}
</div>
</section>
{/* Features */}
<section className="px-6 md:px-12 py-20 md:py-28">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-16">
<h2 className="text-3xl md:text-4xl font-bold text-white mb-4">
Built for the future of identity
</h2>
<p className="text-zinc-400 max-w-2xl mx-auto">
Standards-compliant, privacy-preserving, and designed for decentralized ecosystems.
</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{content.features.map((feature, i) => (
<div key={i} className="p-6 rounded-xl border border-zinc-800 bg-zinc-900/30 hover:bg-zinc-900/60 transition-colors">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center mb-4"
style={{ backgroundColor: branding.colors.primary + '15' }}
>
<svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
style={{ color: branding.colors.primary }}
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={feature.icon} />
</svg>
</div>
<h3 className="text-lg font-semibold text-white mb-2">{feature.title}</h3>
<p className="text-sm text-zinc-400 leading-relaxed">{feature.description}</p>
</div>
))}
</div>
</div>
</section>
{/* CTA */}
<section className="px-6 md:px-12 py-20">
<div className="max-w-4xl mx-auto text-center">
<div className="p-12 rounded-2xl border border-zinc-800 bg-gradient-to-br from-zinc-900/80 to-zinc-900/30">
<h2 className="text-3xl md:text-4xl font-bold text-white mb-4">
Ready to own your identity?
</h2>
<p className="text-zinc-400 mb-8 max-w-lg mx-auto">
Create your {branding.orgName} ID in seconds. Free, open, and yours forever.
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<Link
href="/signup"
className="px-8 py-3.5 rounded-lg font-medium text-lg transition-opacity hover:opacity-90"
style={{ backgroundColor: branding.colors.primary, color: branding.colors.primaryText }}
>
{content.cta}
</Link>
<Link
href="/login"
className="text-zinc-400 hover:text-white transition-colors"
>
Already have an account? Sign in
</Link>
</div>
</div>
</div>
</section>
{/* Footer */}
<footer className="border-t border-zinc-800/50 px-6 md:px-12 py-8">
<div className="max-w-6xl mx-auto flex flex-col md:flex-row items-center justify-between gap-4 text-sm text-zinc-500">
<div className="flex items-center gap-3">
<img src={branding.logo} alt={branding.orgName} className="h-5 opacity-50" />
<span>&copy; {new Date().getFullYear()} {branding.orgName}</span>
</div>
<div className="flex gap-6">
{branding.links.terms && <a href={branding.links.terms} className="hover:text-zinc-300 transition-colors">Terms</a>}
{branding.links.privacy && <a href={branding.links.privacy} className="hover:text-zinc-300 transition-colors">Privacy</a>}
{branding.links.docs && <a href={branding.links.docs} className="hover:text-zinc-300 transition-colors">Documentation</a>}
{branding.links.home && <a href={branding.links.home} className="hover:text-zinc-300 transition-colors">{branding.orgName}</a>}
</div>
</div>
</footer>
</div>
)
}
-56
View File
@@ -1,56 +0,0 @@
import { Suspense } from 'react'
import { headers } from 'next/headers'
import { getBranding, staticBranding, defaultBranding, resolveBrandingDomain, BrandingConfig } from '@/lib/branding'
import SignUpForm from '@/components/SignUpForm'
import MarketingPanel from '@/components/MarketingPanel'
export const runtime = 'edge'
async function getBrandingForDomain(): Promise<BrandingConfig> {
const headersList = await headers()
const host = headersList.get('host') || 'hanzo.id'
const domain = resolveBrandingDomain(host)
const staticConfig = staticBranding[domain]
if (staticConfig) {
return { ...defaultBranding, ...staticConfig, domain }
}
return getBranding(domain)
}
export default async function SignUpPage() {
const branding = await getBrandingForDomain()
const cssVars = {
'--color-primary': branding.colors.primary,
'--color-primary-text': branding.colors.primaryText,
'--color-background': branding.colors.background,
'--color-surface': branding.colors.surface,
'--color-text': branding.colors.text,
'--color-text-muted': branding.colors.textMuted,
'--color-border': branding.colors.border,
'--color-error': branding.colors.error,
} as React.CSSProperties
return (
<div className="min-h-screen flex" style={cssVars}>
<div className="w-full lg:w-1/2 flex items-center justify-center p-8">
<div className="login-card w-full max-w-md p-8">
<div className="flex items-center justify-between mb-8">
<img
src={branding.logo}
alt={branding.logoAlt || branding.orgName}
className="h-10"
/>
</div>
<Suspense><SignUpForm branding={branding} /></Suspense>
</div>
</div>
<div className="hidden lg:flex w-1/2 items-center justify-center p-12 bg-gradient-to-br from-black via-zinc-900 to-black">
<MarketingPanel branding={branding} />
</div>
</div>
)
}
@@ -1,148 +0,0 @@
'use client'
import { useState, useRef, useEffect } from 'react'
const LANGUAGES = [
{ code: 'en', label: 'English' },
{ code: 'zh', label: '中文' },
{ code: 'zh-TW', label: '繁體中文' },
{ code: 'ja', label: '日本語' },
{ code: 'ko', label: '한국어' },
{ code: 'es', label: 'Español' },
{ code: 'fr', label: 'Français' },
{ code: 'de', label: 'Deutsch' },
{ code: 'pt', label: 'Português' },
{ code: 'pt-BR', label: 'Português (BR)' },
{ code: 'it', label: 'Italiano' },
{ code: 'nl', label: 'Nederlands' },
{ code: 'pl', label: 'Polski' },
{ code: 'cs', label: 'Čeština' },
{ code: 'sk', label: 'Slovenčina' },
{ code: 'hu', label: 'Magyar' },
{ code: 'ro', label: 'Română' },
{ code: 'bg', label: 'Български' },
{ code: 'hr', label: 'Hrvatski' },
{ code: 'sr', label: 'Српски' },
{ code: 'sl', label: 'Slovenščina' },
{ code: 'uk', label: 'Українська' },
{ code: 'ru', label: 'Русский' },
{ code: 'el', label: 'Ελληνικά' },
{ code: 'tr', label: 'Türkçe' },
{ code: 'ar', label: 'العربية' },
{ code: 'fa', label: 'فارسی' },
{ code: 'he', label: 'עברית' },
{ code: 'hi', label: 'हिन्दी' },
{ code: 'bn', label: 'বাংলা' },
{ code: 'ta', label: 'தமிழ்' },
{ code: 'te', label: 'తెలుగు' },
{ code: 'mr', label: 'मराठी' },
{ code: 'gu', label: 'ગુજરાતી' },
{ code: 'kn', label: 'ಕನ್ನಡ' },
{ code: 'ml', label: 'മലയാളം' },
{ code: 'pa', label: 'ਪੰਜਾਬੀ' },
{ code: 'ur', label: 'اردو' },
{ code: 'th', label: 'ไทย' },
{ code: 'vi', label: 'Tiếng Việt' },
{ code: 'id', label: 'Bahasa Indonesia' },
{ code: 'ms', label: 'Bahasa Melayu' },
{ code: 'tl', label: 'Filipino' },
{ code: 'sw', label: 'Kiswahili' },
{ code: 'am', label: 'አማርኛ' },
{ code: 'ha', label: 'Hausa' },
{ code: 'yo', label: 'Yorùbá' },
{ code: 'ig', label: 'Igbo' },
{ code: 'zu', label: 'isiZulu' },
{ code: 'af', label: 'Afrikaans' },
{ code: 'sv', label: 'Svenska' },
{ code: 'da', label: 'Dansk' },
{ code: 'no', label: 'Norsk' },
{ code: 'fi', label: 'Suomi' },
{ code: 'et', label: 'Eesti' },
{ code: 'lv', label: 'Latviešu' },
{ code: 'lt', label: 'Lietuvių' },
{ code: 'ca', label: 'Català' },
{ code: 'eu', label: 'Euskara' },
{ code: 'gl', label: 'Galego' },
{ code: 'ka', label: 'ქართული' },
{ code: 'hy', label: 'Հայերեն' },
{ code: 'az', label: 'Azərbaycan' },
{ code: 'uz', label: 'Oʻzbek' },
{ code: 'kk', label: 'Қазақ' },
{ code: 'mn', label: 'Монгол' },
{ code: 'my', label: 'မြန်မာ' },
{ code: 'km', label: 'ភាសាខ្មែរ' },
{ code: 'lo', label: 'ລາວ' },
{ code: 'ne', label: 'नेपाली' },
{ code: 'si', label: 'සිංහල' },
]
export default function LanguageDropdown() {
const [open, setOpen] = useState(false)
const [lang, setLang] = useState('en')
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
// Read from localStorage or browser language
const saved = localStorage.getItem('hanzo_lang')
if (saved) {
setLang(saved)
} else {
const browserLang = navigator.language.split('-')[0]
const match = LANGUAGES.find(l => l.code === browserLang)
if (match) setLang(match.code)
}
}, [])
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const handleSelect = (code: string) => {
setLang(code)
localStorage.setItem('hanzo_lang', code)
setOpen(false)
// IAM uses ?lang= param for locale
const url = new URL(window.location.href)
url.searchParams.set('lang', code)
window.location.href = url.toString()
}
const current = LANGUAGES.find(l => l.code === lang) || LANGUAGES[0]
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-1.5 p-2 rounded-lg hover:bg-white/5 text-zinc-400 hover:text-white transition-colors"
aria-label="Language"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
<span className="text-xs">{current.label}</span>
</button>
{open && (
<div className="absolute right-0 top-full mt-1 w-40 bg-zinc-900 border border-zinc-700 rounded-lg shadow-xl z-50 py-1 max-h-64 overflow-y-auto">
{LANGUAGES.map((l) => (
<button
key={l.code}
onClick={() => handleSelect(l.code)}
className={`w-full text-left px-3 py-2 text-sm hover:bg-zinc-800 transition-colors ${
l.code === lang ? 'text-white' : 'text-zinc-400'
}`}
>
{l.label}
</button>
))}
</div>
)}
</div>
)
}
-431
View File
@@ -1,431 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import { useSearchParams } from 'next/navigation'
import type { BrandingConfig } from '@/lib/branding'
import { passwordLogin, startAuthorize } from '@/lib/oauth'
import { getIamUrl, getOrg, getDefaultClientId } from '@/lib/iam'
import { CLIENT_APP_MAP } from '@/lib/clients'
interface LoginFormProps {
branding: BrandingConfig
}
type AuthMethod = 'password' | 'code' | 'webauthn' | 'faceid'
export default function LoginForm({ branding }: LoginFormProps) {
const searchParams = useSearchParams()
const [authMethod, setAuthMethod] = useState<AuthMethod>('password')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [autoSignIn, setAutoSignIn] = useState(true)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const host = typeof window !== 'undefined' ? window.location.hostname : 'hanzo.id'
const iamUrl = getIamUrl(host)
const org = getOrg(host)
const defaultClientId = getDefaultClientId(host)
// OAuth params from query string (when redirected from a client app)
const clientId = searchParams.get('client_id') ?? searchParams.get('clientId') ?? defaultClientId
const redirectUri = searchParams.get('redirect_uri') ?? searchParams.get('redirectUri')
const responseType = searchParams.get('response_type') ?? searchParams.get('responseType')
const scope = searchParams.get('scope')
const state = searchParams.get('state')
const codeChallenge = searchParams.get('code_challenge')
const codeChallengeMethod = searchParams.get('code_challenge_method')
const isOAuthFlow = !!(redirectUri && responseType)
// Resolve the IAM application name from clientId.
// IAM's /api/login expects the application NAME (e.g. "app-hanzobot"),
// not the OAuth client_id (e.g. "hanzobot-client-id").
const [appName, setAppName] = useState<string | null>(null)
// Capture referral code from URL and persist through redirects
useEffect(() => {
if (typeof window === 'undefined') return
const ref = searchParams.get('ref')
if (ref) {
sessionStorage.setItem('hanzo_ref_code', ref)
}
}, [searchParams])
useEffect(() => {
if (!clientId) return
const params = new URLSearchParams({
clientId,
type: 'code',
responseType: responseType || 'code',
redirectUri: redirectUri || `${window.location.origin}/callback`,
scope: scope || 'openid profile email',
state: state || '',
})
fetch(`/v1/iam/get-app-login?${params}`)
.then(r => r.json())
.then(data => {
// IAM returns the app data even when status is "error"
// (e.g. redirect URI validation fails but app info is still present)
if (data?.data?.name) {
setAppName(data.data.name)
} else {
// Fallback to static client map
const client = CLIENT_APP_MAP[clientId]
if (client) setAppName(client.application)
}
})
.catch(() => {
// API unreachable — use static client map
const client = CLIENT_APP_MAP[clientId]
if (client) setAppName(client.application)
})
}, [clientId, responseType, redirectUri, scope, state])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setIsLoading(true)
try {
if (!email || !password) {
throw new Error('Please enter your email and password')
}
// Resolve application name: API result > static map > raw clientId
const resolvedApp = appName || CLIENT_APP_MAP[clientId]?.application || clientId
if (isOAuthFlow) {
// OAuth flow: direct code grant via /v1/iam/login with PKCE.
// Pass OAuth params (including code_challenge) as query params so IAM
// binds the authorization code to the PKCE challenge.
const loginParams = new URLSearchParams({
clientId,
responseType: responseType!,
redirectUri: redirectUri!,
...(scope ? { scope } : {}),
...(state ? { state } : {}),
...(codeChallenge ? { code_challenge: codeChallenge } : {}),
...(codeChallengeMethod ? { code_challenge_method: codeChallengeMethod } : {}),
})
const res = await fetch(`/v1/iam/login?${loginParams}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: responseType === 'token' ? 'token' : 'code',
organization: org,
username: email,
password,
application: resolvedApp,
clientId,
redirectUri: redirectUri!,
state: state || '',
// PKCE: pass in body too (Casdoor may read from body, not just query params)
...(codeChallenge ? { codeChallenge } : {}),
...(codeChallengeMethod ? { codeChallengeMethod } : {}),
}),
})
const data = await res.json()
if (data.status !== 'ok') throw new Error(data.msg || 'Login failed')
// Redirect to the client's callback with the authorization code
const redirect = new URL(redirectUri!)
redirect.searchParams.set('code', data.data)
if (state) redirect.searchParams.set('state', state)
window.location.href = redirect.toString()
} else {
// Direct login: get token, store, redirect to account
// Use origin (same-domain) so the request goes through middleware proxy
const result = await passwordLogin({
iamUrl: window.location.origin,
org,
username: email,
password,
application: resolvedApp,
})
// Store token
localStorage.setItem('hanzo_access_token', result.token)
// Fetch full user profile from userinfo endpoint (access token JWT has limited claims)
try {
const res = await fetch('/oauth/userinfo', {
headers: { Authorization: `Bearer ${result.token}` },
})
if (res.ok) {
const info = await res.json()
localStorage.setItem('hanzo_user', JSON.stringify({
sub: info.sub,
name: info.name || info.preferred_username,
displayName: info.displayName || info.name || info.preferred_username,
email: info.email,
avatar: info.avatar || info.picture || info.permanentAvatar,
}))
}
} catch {}
// Fallback: decode JWT for basic info
if (!localStorage.getItem('hanzo_user')) {
try {
const payload = JSON.parse(atob(result.token.split('.')[1]))
localStorage.setItem('hanzo_user', JSON.stringify({
sub: payload.sub || payload.name,
name: payload.name,
displayName: payload.displayName || payload.name,
email: payload.email || email,
avatar: payload.avatar,
}))
} catch {}
}
// Redirect to account or home
window.location.href = '/account'
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed')
} finally {
setIsLoading(false)
}
}
const handleSSOLogin = async () => {
const callbackUri = `${window.location.origin}/callback`
await startAuthorize({
iamUrl,
clientId,
redirectUri: callbackUri,
scope: scope ?? 'openid profile email',
})
}
const authTabs = [
{ key: 'password' as const, label: 'Password', enabled: branding.auth.passwordEnabled },
{ key: 'code' as const, label: 'Code', enabled: branding.auth.codeEnabled },
{ key: 'webauthn' as const, label: 'WebAuthn', enabled: branding.auth.webauthnEnabled },
{ key: 'faceid' as const, label: 'Face ID', enabled: branding.auth.faceIdEnabled },
].filter(t => t.enabled)
return (
<div>
{/* Auth method tabs */}
{authTabs.length > 1 && (
<div className="flex gap-4 mb-6 border-b border-zinc-800">
{authTabs.map((tab) => (
<button
key={tab.key}
onClick={() => { setAuthMethod(tab.key); setError(null) }}
className={`pb-3 text-sm font-medium transition-colors ${
authMethod === tab.key
? 'text-white border-b-2'
: 'text-zinc-500 hover:text-zinc-300'
}`}
style={{
borderColor: authMethod === tab.key ? branding.colors.primary : 'transparent'
}}
>
{tab.label}
</button>
))}
</div>
)}
{error && (
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
{/* Email/Username */}
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<input
type="text"
placeholder="Email or username"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="input w-full pl-10 py-3 rounded-lg"
autoComplete="email"
disabled={isLoading}
/>
</div>
{/* Password */}
{authMethod === 'password' && (
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<input
type={showPassword ? 'text' : 'password'}
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input w-full pl-10 pr-12 py-3 rounded-lg"
autoComplete="current-password"
disabled={isLoading}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-zinc-500 hover:text-zinc-300"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
{showPassword ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" />
) : (
<>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</>
)}
</svg>
</button>
</div>
)}
{/* Auto sign in & Forgot password */}
<div className="flex items-center justify-between text-sm">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={autoSignIn}
onChange={(e) => setAutoSignIn(e.target.checked)}
className="w-4 h-4 rounded"
style={{ accentColor: branding.colors.primary }}
/>
<span className="text-zinc-400">Remember me</span>
</label>
<a
href="/forgot-password"
className="link text-sm"
style={{ color: branding.colors.primary }}
>
Forgot password?
</a>
</div>
{/* Sign in button */}
<button
type="submit"
disabled={isLoading}
className="btn-primary w-full py-3 rounded-lg font-medium disabled:opacity-50 transition-opacity"
style={{ backgroundColor: branding.colors.primary }}
>
{isLoading ? 'Signing in...' : 'Sign In'}
</button>
{/* Sign up link */}
<p className="text-center text-sm text-zinc-500">
No account?{' '}
<a
href={`/signup${typeof window !== 'undefined' ? window.location.search : ''}`}
className="link"
style={{ color: branding.colors.primary }}
>
Sign up now
</a>
</p>
{/* Social providers / SSO */}
{branding.auth.socialProviders.length > 0 && (
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-zinc-800" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-zinc-900 text-zinc-500">Or continue with</span>
</div>
</div>
<div className="mt-4 space-y-3">
{/* Connect Wallet — always first */}
{branding.auth.socialProviders.includes('metamask') && (
<button
type="button"
onClick={() => window.location.href = `/oauth/authorize?provider=metamask&client_id=${clientId}&redirect_uri=${encodeURIComponent(window.location.origin + '/callback')}&scope=openid+email+profile&response_type=code`}
className="flex items-center justify-center gap-2 w-full py-3 px-4 border rounded-lg font-medium transition-colors"
style={{ borderColor: branding.colors.primary, color: branding.colors.primary }}
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="2" y="6" width="20" height="14" rx="2" />
<path d="M16 14h.01" />
<path d="M2 10h20" />
</svg>
Connect Wallet
</button>
)}
<div className="grid grid-cols-2 gap-3">
{branding.auth.socialProviders.includes('google') && (
<button
type="button"
onClick={() => window.location.href = `/oauth/authorize?provider=google&client_id=${clientId}&redirect_uri=${encodeURIComponent(window.location.origin + '/callback')}&scope=openid+email+profile&response_type=code`}
className="flex items-center justify-center gap-2 py-2 px-4 border border-zinc-700 rounded-lg hover:bg-zinc-800 transition-colors"
>
<svg className="w-5 h-5" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Google
</button>
)}
{branding.auth.socialProviders.includes('github') && (
<button
type="button"
onClick={() => window.location.href = `/oauth/authorize?provider=github&client_id=${clientId}&redirect_uri=${encodeURIComponent(window.location.origin + '/callback')}&scope=openid+email+profile&response_type=code`}
className="flex items-center justify-center gap-2 py-2 px-4 border border-zinc-700 rounded-lg hover:bg-zinc-800 transition-colors"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
GitHub
</button>
)}
{branding.auth.socialProviders.includes('apple') && (
<button
type="button"
onClick={() => window.location.href = `/oauth/authorize?provider=apple&client_id=${clientId}&redirect_uri=${encodeURIComponent(window.location.origin + '/callback')}&scope=openid+email+profile&response_type=code`}
className="flex items-center justify-center gap-2 py-2 px-4 border border-zinc-700 rounded-lg hover:bg-zinc-800 transition-colors"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M17.543 12.72c-.026-2.757 2.248-4.08 2.352-4.143-1.282-1.876-3.278-2.134-3.988-2.165-1.698-.173-3.315 1.001-4.179 1.001-.864 0-2.195-.976-3.608-.95-1.858.028-3.57 1.078-4.528 2.74-1.931 3.347-.493 8.293 1.388 11.008.918 1.328 2.012 2.821 3.445 2.77 1.38-.057 1.902-.894 3.57-.894 1.668 0 2.137.894 3.604.867 1.489-.027 2.434-1.355 3.343-2.686 1.054-1.547 1.489-3.044 1.515-3.122-.034-.015-2.908-1.117-2.914-4.426zM14.829 4.793c.76-.92 1.272-2.199 1.131-3.469-1.093.045-2.415.728-3.2 1.648-.706.818-1.323 2.119-1.157 3.362 1.214.093 2.454-.623 3.226-1.541z"/>
</svg>
Apple
</button>
)}
</div>
</div>
</div>
)}
</form>
{/* Footer links */}
<div className="mt-8 pt-6 border-t border-zinc-800 flex justify-center gap-4 text-xs text-zinc-500">
{branding.links.terms && (
<a href={branding.links.terms} className="hover:text-zinc-300">Terms</a>
)}
{branding.links.privacy && (
<a href={branding.links.privacy} className="hover:text-zinc-300">Privacy</a>
)}
{branding.links.support && (
<a href={branding.links.support} className="hover:text-zinc-300">Support</a>
)}
</div>
</div>
)
}
-142
View File
@@ -1,142 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import type { BrandingConfig } from '@/lib/branding'
interface MarketingPanelProps {
branding: BrandingConfig
}
export default function MarketingPanel({ branding }: MarketingPanelProps) {
const [currentQuote, setCurrentQuote] = useState(0)
const quotes = branding.content.quotes || []
// Auto-rotate quotes
useEffect(() => {
if (quotes.length <= 1) return
const interval = setInterval(() => {
setCurrentQuote((prev) => (prev + 1) % quotes.length)
}, 5000)
return () => clearInterval(interval)
}, [quotes.length])
return (
<div className="max-w-md space-y-8">
{/* Badge */}
{branding.content.tagline && (
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full border border-zinc-700 text-sm">
<span className="text-yellow-400"></span>
<span className="text-zinc-300">{branding.content.tagline}</span>
</div>
)}
{/* Title */}
{branding.content.title && (
<h2 className="text-4xl font-bold text-white">
{branding.content.title}
</h2>
)}
{/* Subtitle */}
{branding.content.subtitle && (
<p className="text-zinc-400 text-lg">
{branding.content.subtitle}
</p>
)}
{/* Interactive prompt (optional feature showcase) */}
{branding.content.features && branding.content.features.length > 0 && (
<div className="bg-zinc-900/50 rounded-xl p-4 border border-zinc-800">
<div className="text-xs text-zinc-500 mb-2 flex items-center gap-1">
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: branding.colors.primary }} />
TRY SOMETHING LIKE
</div>
<p className="text-white">
{branding.content.features[0].description}
</p>
<div className="mt-3 flex items-center gap-2">
<button className="p-2 rounded-lg bg-zinc-800 text-zinc-400 hover:text-white">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
</svg>
</button>
<button className="p-2 rounded-lg bg-zinc-800 text-zinc-400 hover:text-white">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
</svg>
</button>
<div className="flex-1" />
<button
className="flex items-center gap-2 px-4 py-2 rounded-lg font-medium"
style={{
backgroundColor: branding.colors.primary,
color: branding.colors.primaryText
}}
>
<span></span>
Generate
</button>
</div>
</div>
)}
{/* Testimonials/Quotes */}
{quotes.length > 0 && (
<div className="quote-card">
<blockquote className="text-white mb-4">
<span className="text-2xl text-zinc-600">"</span>
{quotes[currentQuote].text}
<span className="text-2xl text-zinc-600">"</span>
</blockquote>
<div className="flex items-center gap-3">
{quotes[currentQuote].avatar ? (
<img
src={quotes[currentQuote].avatar}
alt={quotes[currentQuote].author}
className="w-10 h-10 rounded-full"
/>
) : (
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-white font-medium"
style={{ backgroundColor: branding.colors.primary }}
>
{quotes[currentQuote].author.split(' ').map(n => n[0]).join('').slice(0, 2)}
</div>
)}
<div>
<div className="font-medium text-white">
{quotes[currentQuote].author}
</div>
{(quotes[currentQuote].role || quotes[currentQuote].company) && (
<div className="text-sm text-zinc-500">
{[quotes[currentQuote].role, quotes[currentQuote].company].filter(Boolean).join(', ')}
</div>
)}
</div>
</div>
{/* Quote indicators */}
{quotes.length > 1 && (
<div className="flex justify-center gap-2 mt-4">
{quotes.map((_, i) => (
<button
key={i}
onClick={() => setCurrentQuote(i)}
className={`w-2 h-2 rounded-full transition-colors ${
i === currentQuote ? 'bg-white' : 'bg-zinc-600'
}`}
style={{
backgroundColor: i === currentQuote ? branding.colors.primary : undefined
}}
/>
))}
</div>
)}
</div>
)}
</div>
)
}
-238
View File
@@ -1,238 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import { useSearchParams } from 'next/navigation'
import type { BrandingConfig } from '@/lib/branding'
import { getIamUrl, getOrg, getDefaultClientId } from '@/lib/iam'
interface SignUpFormProps {
branding: BrandingConfig
}
export default function SignUpForm({ branding }: SignUpFormProps) {
const searchParams = useSearchParams()
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const host = typeof window !== 'undefined' ? window.location.hostname : 'hanzo.id'
const iamUrl = getIamUrl(host)
const org = getOrg(host)
const clientId = searchParams.get('client_id') ?? searchParams.get('clientId') ?? getDefaultClientId(host)
// Resolve the IAM application name from clientId.
// IAM's /api/signup expects the application NAME (e.g. "app-hanzobot"),
// not the OAuth client_id (e.g. "hanzobot-client-id").
const [appName, setAppName] = useState<string | null>(null)
// Capture referral code from URL and persist through redirects
useEffect(() => {
if (typeof window === 'undefined') return
const ref = searchParams.get('ref')
if (ref) {
sessionStorage.setItem('hanzo_ref_code', ref)
}
}, [searchParams])
useEffect(() => {
if (!clientId) return
const params = new URLSearchParams({
clientId,
type: 'code',
responseType: 'code',
redirectUri: `${window.location.origin}/callback`,
scope: 'openid profile email',
state: '',
})
fetch(`/api/get-app-login?${params}`)
.then(r => r.json())
.then(data => {
// IAM returns the app data even when status is "error"
// (e.g. redirect URI validation fails but app info is still present)
if (data?.data?.name) {
setAppName(data.data.name)
}
})
.catch(() => {})
}, [clientId])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setIsLoading(true)
try {
if (!email || !password) {
throw new Error('Please fill in all required fields')
}
if (password.length < 8) {
throw new Error('Password must be at least 8 characters')
}
const username = email.split('@')[0]
const res = await fetch(`${iamUrl}/api/signup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
organization: org,
application: appName || clientId,
username,
name: username,
displayName: name || username,
email,
password,
}),
})
const data = await res.json()
if (data.status !== 'ok') {
throw new Error(data.msg || 'Sign up failed')
}
// Redirect to login with success message
const loginUrl = new URL('/login', window.location.origin)
// Preserve OAuth params and referral code
const params = ['client_id', 'clientId', 'redirect_uri', 'redirectUri', 'response_type', 'responseType', 'scope', 'state', 'ref']
for (const p of params) {
const v = searchParams.get(p)
if (v) loginUrl.searchParams.set(p, v)
}
loginUrl.searchParams.set('registered', '1')
window.location.href = loginUrl.toString()
} catch (err) {
setError(err instanceof Error ? err.message : 'Sign up failed')
} finally {
setIsLoading(false)
}
}
return (
<div>
<h2 className="text-2xl font-bold text-white mb-2">Create account</h2>
<p className="text-zinc-400 text-sm mb-6">
Sign up for {branding.orgName}
</p>
{error && (
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
{/* Name */}
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<input
type="text"
placeholder="Full name"
value={name}
onChange={(e) => setName(e.target.value)}
className="input w-full pl-10 py-3 rounded-lg"
autoComplete="name"
disabled={isLoading}
/>
</div>
{/* Email */}
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<input
type="email"
placeholder="Email address"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="input w-full pl-10 py-3 rounded-lg"
autoComplete="email"
disabled={isLoading}
required
/>
</div>
{/* Password */}
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<input
type={showPassword ? 'text' : 'password'}
placeholder="Password (min 8 characters)"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input w-full pl-10 pr-12 py-3 rounded-lg"
autoComplete="new-password"
disabled={isLoading}
required
minLength={8}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-zinc-500 hover:text-zinc-300"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
{showPassword ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" />
) : (
<>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</>
)}
</svg>
</button>
</div>
<button
type="submit"
disabled={isLoading}
className="btn-primary w-full py-3 rounded-lg font-medium disabled:opacity-50 transition-opacity"
style={{ backgroundColor: branding.colors.primary }}
>
{isLoading ? 'Creating account...' : 'Create Account'}
</button>
<p className="text-center text-sm text-zinc-500">
Already have an account?{' '}
<a
href={`/login${typeof window !== 'undefined' ? window.location.search : ''}`}
className="link"
style={{ color: branding.colors.primary }}
>
Sign in
</a>
</p>
{/* Terms */}
{(branding.links.terms || branding.links.privacy) && (
<p className="text-center text-xs text-zinc-500 mt-4">
By creating an account, you agree to our{' '}
{branding.links.terms && (
<a href={branding.links.terms} className="hover:text-zinc-300">Terms</a>
)}
{branding.links.terms && branding.links.privacy && ' and '}
{branding.links.privacy && (
<a href={branding.links.privacy} className="hover:text-zinc-300">Privacy Policy</a>
)}
</p>
)}
</form>
</div>
)
}
-838
View File
@@ -1,838 +0,0 @@
/**
* Branding configuration fetched from IAM backend based on domain
*
* Each organization can customize:
* - Logo (URL or base64)
* - Colors (primary, secondary, background, text)
* - Login page content (quotes, testimonials)
* - Links (terms, privacy, support)
* - Features (which auth methods to show)
*/
export interface BrandingConfig {
// Organization info
orgId: string
orgName: string
domain: string
// Visual branding
logo: string
logoAlt?: string
favicon?: string
// Color scheme
colors: {
primary: string // Button color, accents
primaryText: string // Text on primary color
background: string // Page background
surface: string // Card/form background
text: string // Primary text
textMuted: string // Secondary text
border: string // Borders
error: string // Error states
}
// Login page content
content: {
title?: string // Main heading
subtitle?: string // Subheading
tagline?: string // Marketing tagline
quotes?: Quote[] // Testimonials/quotes
features?: Feature[] // Feature highlights
}
// Links
links: {
terms?: string
privacy?: string
support?: string
docs?: string
home?: string
}
// Auth features
auth: {
passwordEnabled: boolean
codeEnabled: boolean // Email/SMS code
webauthnEnabled: boolean // Passkeys
faceIdEnabled: boolean
socialProviders: string[] // google, github, etc
}
}
export interface Quote {
text: string
author: string
role?: string
company?: string
avatar?: string
}
export interface Feature {
title: string
description: string
icon?: string
}
// Default Hanzo branding (fallback)
export const defaultBranding: BrandingConfig = {
orgId: 'hanzo',
orgName: 'Hanzo',
domain: 'hanzo.id',
logo: '/logos/hanzo.svg',
colors: {
primary: '#e4e4e7', // Zinc-200 (monochrome white)
primaryText: '#09090b', // Zinc-950 (dark text on light buttons)
background: '#000000', // Pure black
surface: '#0a0a0a', // Near black
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Start building in seconds',
subtitle: 'Describe your idea and watch AI bring it to life instantly',
tagline: 'AI-powered development',
quotes: [
{
text: 'Hanzo is amazing! It\'s revolutionizing how we build and deploy applications.',
author: 'Developer',
role: 'Software Engineer',
}
],
},
links: {
terms: 'https://hanzo.ai/terms',
privacy: 'https://hanzo.ai/privacy',
support: 'https://hanzo.ai/support',
docs: 'https://docs.hanzo.ai',
home: 'https://hanzo.ai',
},
auth: {
passwordEnabled: true,
codeEnabled: true,
webauthnEnabled: true,
faceIdEnabled: true,
socialProviders: ['metamask', 'google', 'github'],
},
}
// Fetch branding from IAM backend based on domain
export async function getBranding(domain: string): Promise<BrandingConfig> {
const iamUrl = process.env.HANZO_IAM_URL || 'https://api.hanzo.id'
try {
const res = await fetch(`${iamUrl}/api/branding?domain=${encodeURIComponent(domain)}`, {
next: { revalidate: 300 }, // Cache for 5 minutes
})
if (!res.ok) {
console.warn(`Failed to fetch branding for ${domain}, using defaults`)
return defaultBranding
}
const data = await res.json()
return { ...defaultBranding, ...data }
} catch (error) {
console.error(`Error fetching branding for ${domain}:`, error)
return defaultBranding
}
}
// Static branding configs for known domains (can be overridden by IAM)
// Supports both {org}.id format (e.g. lux.id) and id.{domain} format (e.g. id.ad.nexus)
export const staticBranding: Record<string, Partial<BrandingConfig>> = {
'hanzo.id': {
orgId: 'hanzo',
orgName: 'Hanzo',
logo: '/logos/hanzo.svg',
colors: {
primary: '#e4e4e7', // Zinc-200 (monochrome white)
primaryText: '#09090b', // Zinc-950 (dark text on light buttons)
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
},
'id.hanzo.ai': {
orgId: 'hanzo',
orgName: 'Hanzo',
logo: '/logos/hanzo.svg',
colors: {
primary: '#e4e4e7', // Zinc-200 (monochrome white)
primaryText: '#09090b', // Zinc-950 (dark text on light buttons)
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
},
'pars.id': {
orgId: 'pars',
orgName: 'Pars Network',
logo: '/logos/pars.svg',
colors: {
primary: '#3b82f6', // Blue
primaryText: '#ffffff',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Welcome to Pars',
subtitle: 'The decentralized network for the next generation',
},
links: {
terms: 'https://pars.network/terms',
privacy: 'https://pars.network/privacy',
support: 'https://pars.network/support',
docs: 'https://pars.network/docs',
home: 'https://pars.network',
},
},
'id.pars.network': {
orgId: 'pars',
orgName: 'Pars Network',
logo: '/logos/pars.svg',
colors: {
primary: '#3b82f6', // Blue
primaryText: '#ffffff',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Welcome to Pars',
subtitle: 'The decentralized network for the next generation',
},
links: {
terms: 'https://pars.network/terms',
privacy: 'https://pars.network/privacy',
support: 'https://pars.network/support',
docs: 'https://pars.network/docs',
home: 'https://pars.network',
},
},
'lux.id': {
orgId: 'lux',
orgName: 'Lux Network',
logo: '/logos/lux.svg',
colors: {
primary: '#e4e4e7', // Zinc-200 (clean white)
primaryText: '#09090b', // Zinc-950 (dark text on light buttons)
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Start deploying in seconds',
subtitle: 'High-performance blockchain infrastructure for the Lux ecosystem',
tagline: 'Lux-powered infrastructure',
quotes: [
{
text: "Lux is fast. We deploy chains in minutes, not weeks.",
author: 'Validator',
role: 'Node Operator',
}
],
},
auth: {
passwordEnabled: true,
codeEnabled: true,
webauthnEnabled: true,
faceIdEnabled: true,
socialProviders: ['metamask', 'google', 'github'],
},
links: {
terms: 'https://lux.network/terms',
privacy: 'https://lux.network/privacy',
support: 'https://lux.network/support',
docs: 'https://docs.lux.network',
home: 'https://lux.network',
},
},
'id.lux.cloud': {
orgId: 'lux',
orgName: 'Lux Network',
logo: '/logos/lux.svg',
colors: {
primary: '#e4e4e7',
primaryText: '#09090b',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Start deploying in seconds',
subtitle: 'High-performance blockchain infrastructure for the Lux ecosystem',
tagline: 'Lux-powered infrastructure',
quotes: [
{
text: "Lux is fast. We deploy chains in minutes, not weeks.",
author: 'Validator',
role: 'Node Operator',
}
],
},
auth: {
passwordEnabled: true,
codeEnabled: true,
webauthnEnabled: true,
faceIdEnabled: true,
socialProviders: ['metamask', 'google', 'github'],
},
links: {
terms: 'https://lux.network/terms',
privacy: 'https://lux.network/privacy',
support: 'https://lux.network/support',
docs: 'https://docs.lux.network',
home: 'https://lux.network',
},
},
'id.lux.network': {
orgId: 'lux',
orgName: 'Lux Network',
logo: '/logos/lux.svg',
colors: {
primary: '#e4e4e7', // Zinc-200 (clean white)
primaryText: '#09090b', // Zinc-950 (dark text on light buttons)
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Start deploying in seconds',
subtitle: 'High-performance blockchain infrastructure for the Lux ecosystem',
tagline: 'Lux-powered infrastructure',
quotes: [
{
text: "Lux is fast. We deploy chains in minutes, not weeks.",
author: 'Validator',
role: 'Node Operator',
}
],
},
auth: {
passwordEnabled: true,
codeEnabled: true,
webauthnEnabled: true,
faceIdEnabled: true,
socialProviders: ['metamask', 'google', 'github'],
},
links: {
terms: 'https://lux.network/terms',
privacy: 'https://lux.network/privacy',
support: 'https://lux.network/support',
docs: 'https://docs.lux.network',
home: 'https://lux.network',
},
},
'id.lux-dev.network': {
orgId: 'lux',
orgName: 'Lux Network (Devnet)',
logo: '/logos/lux.svg',
colors: {
primary: '#e4e4e7',
primaryText: '#09090b',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Lux Devnet',
subtitle: 'Development network — unstable, reset frequently',
tagline: 'Lux devnet infrastructure',
quotes: [
{
text: "Break things fast. Devnet resets nightly.",
author: 'Engineer',
role: 'Infra',
}
],
},
auth: {
passwordEnabled: true,
codeEnabled: true,
webauthnEnabled: true,
faceIdEnabled: true,
socialProviders: ['metamask', 'google', 'github'],
},
links: {
terms: 'https://lux.network/terms',
privacy: 'https://lux.network/privacy',
support: 'https://lux.network/support',
docs: 'https://docs.lux.network',
home: 'https://lux-dev.network',
},
},
'id.lux-test.network': {
orgId: 'lux',
orgName: 'Lux Network (Testnet)',
logo: '/logos/lux.svg',
colors: {
primary: '#e4e4e7',
primaryText: '#09090b',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Lux Testnet',
subtitle: 'Test the full stack before mainnet deploy',
tagline: 'Lux testnet infrastructure',
quotes: [
{
text: "Validator stability tested here for 48h before mainnet promotion.",
author: 'Validator',
role: 'Operator',
}
],
},
auth: {
passwordEnabled: true,
codeEnabled: true,
webauthnEnabled: true,
faceIdEnabled: true,
socialProviders: ['metamask', 'google', 'github'],
},
links: {
terms: 'https://lux.network/terms',
privacy: 'https://lux.network/privacy',
support: 'https://lux.network/support',
docs: 'https://docs.lux.network',
home: 'https://lux-test.network',
},
},
'id-dev.hanzo.ai': {
orgId: 'hanzo',
orgName: 'Hanzo (Dev)',
logo: '/logos/hanzo.svg',
colors: {
primary: '#e4e4e7',
primaryText: '#09090b',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Hanzo Dev',
subtitle: 'Development environment',
tagline: 'AI-powered development',
},
auth: {
passwordEnabled: true,
codeEnabled: true,
webauthnEnabled: true,
faceIdEnabled: true,
socialProviders: ['metamask', 'google', 'github'],
},
},
'id-test.hanzo.ai': {
orgId: 'hanzo',
orgName: 'Hanzo (Test)',
logo: '/logos/hanzo.svg',
colors: {
primary: '#e4e4e7',
primaryText: '#09090b',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Hanzo Test',
subtitle: 'Test environment',
tagline: 'AI-powered development',
},
auth: {
passwordEnabled: true,
codeEnabled: true,
webauthnEnabled: true,
faceIdEnabled: true,
socialProviders: ['metamask', 'google', 'github'],
},
},
'zoolabs.id': {
orgId: 'zoo',
orgName: 'Zoo Labs',
logo: '/logos/zoo.svg',
colors: {
primary: '#e4e4e7',
primaryText: '#09090b',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Build the future of DeAI',
subtitle: 'Open AI research + decentralized science for everyone',
tagline: 'Open AI research network',
quotes: [
{
text: "Zoo is where bleeding-edge DeAI experiments actually ship.",
author: 'Researcher',
role: 'ML Engineer',
}
],
},
auth: {
passwordEnabled: true,
codeEnabled: true,
webauthnEnabled: true,
faceIdEnabled: true,
socialProviders: ['metamask', 'google', 'github'],
},
links: {
terms: 'https://zoo.ngo/terms',
privacy: 'https://zoo.ngo/privacy',
support: 'https://zoo.ngo/support',
docs: 'https://zoo.ngo/docs',
home: 'https://zoo.ngo',
},
},
'id.zoo.network': {
orgId: 'zoo',
orgName: 'Zoo Labs',
logo: '/logos/zoo.svg',
colors: {
primary: '#22c55e', // Green
primaryText: '#ffffff',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Build the future of DeAI',
subtitle: 'Open AI research + decentralized science for everyone',
tagline: 'Open AI research network',
quotes: [
{
text: "Zoo is where bleeding-edge DeAI experiments actually ship.",
author: 'Researcher',
role: 'ML Engineer',
}
],
},
auth: {
passwordEnabled: true,
codeEnabled: true,
webauthnEnabled: true,
faceIdEnabled: true,
socialProviders: ['metamask', 'google', 'github'],
},
links: {
terms: 'https://zoo.ngo/terms',
privacy: 'https://zoo.ngo/privacy',
support: 'https://zoo.ngo/support',
docs: 'https://zoo.ngo/docs',
home: 'https://zoo.ngo',
},
},
'id.hanzo.network': {
orgId: 'hanzo',
orgName: 'Hanzo (Network)',
logo: '/logos/hanzo.svg',
colors: {
primary: '#e4e4e7',
primaryText: '#09090b',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Welcome to Hanzo Network',
subtitle: 'Hanzo network identity',
tagline: 'AI-powered infrastructure',
},
auth: {
passwordEnabled: true,
codeEnabled: true,
webauthnEnabled: true,
faceIdEnabled: true,
socialProviders: ['metamask', 'google', 'github'],
},
links: {
home: 'https://hanzo.network',
},
},
'id.hanzo-dev.network': {
orgId: 'hanzo',
orgName: 'Hanzo (Devnet)',
logo: '/logos/hanzo.svg',
colors: {
primary: '#e4e4e7',
primaryText: '#09090b',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Hanzo Devnet',
subtitle: 'Hanzo development network — resets nightly',
tagline: 'Development environment',
},
auth: {
passwordEnabled: true,
codeEnabled: true,
webauthnEnabled: true,
faceIdEnabled: true,
socialProviders: ['metamask', 'google', 'github'],
},
links: {
home: 'https://hanzo-dev.network',
},
},
'id.hanzo-test.network': {
orgId: 'hanzo',
orgName: 'Hanzo (Testnet)',
logo: '/logos/hanzo.svg',
colors: {
primary: '#e4e4e7',
primaryText: '#09090b',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Hanzo Testnet',
subtitle: 'Hanzo test network — staging before mainnet',
tagline: 'Test environment',
},
auth: {
passwordEnabled: true,
codeEnabled: true,
webauthnEnabled: true,
faceIdEnabled: true,
socialProviders: ['metamask', 'google', 'github'],
},
links: {
home: 'https://hanzo-test.network',
},
},
'id.zoo-dev.network': {
orgId: 'zoo',
orgName: 'Zoo Labs (Devnet)',
logo: '/logos/zoo.svg',
colors: {
primary: '#e4e4e7',
primaryText: '#09090b',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Zoo Devnet',
subtitle: 'Zoo development network',
tagline: 'Zoo devnet',
},
auth: {
passwordEnabled: true,
codeEnabled: true,
webauthnEnabled: true,
faceIdEnabled: true,
socialProviders: ['metamask', 'google', 'github'],
},
links: {
home: 'https://zoo-dev.network',
},
},
'id.zoo-test.network': {
orgId: 'zoo',
orgName: 'Zoo Labs (Testnet)',
logo: '/logos/zoo.svg',
colors: {
primary: '#e4e4e7',
primaryText: '#09090b',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Zoo Testnet',
subtitle: 'Zoo test network',
tagline: 'Zoo testnet',
},
auth: {
passwordEnabled: true,
codeEnabled: true,
webauthnEnabled: true,
faceIdEnabled: true,
socialProviders: ['metamask', 'google', 'github'],
},
links: {
home: 'https://zoo-test.network',
},
},
'zen.id': {
orgId: 'zen',
orgName: 'Zen LM',
logo: '/logos/zen.svg',
colors: {
primary: '#a855f7', // Purple (Zen violet)
primaryText: '#ffffff',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Welcome to Zen',
subtitle: 'Frontier AI models for everyone',
},
links: {
terms: 'https://zenlm.org/terms',
privacy: 'https://zenlm.org/privacy',
support: 'https://zenlm.org/support',
docs: 'https://zenlm.org/docs',
home: 'https://zenlm.org',
},
},
'id.ad.nexus': {
orgId: 'adnexus',
orgName: 'Ad Nexus',
logo: '/logos/adnexus.svg',
logoAlt: 'Ad Nexus',
colors: {
primary: '#8b5cf6', // Purple
primaryText: '#ffffff',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Welcome to Ad Nexus',
subtitle: 'Programmatic advertising platform',
},
links: {
terms: 'https://ad.nexus/terms',
privacy: 'https://ad.nexus/privacy',
support: 'https://ad.nexus/support',
home: 'https://ad.nexus',
},
},
'ad.nexus': {
orgId: 'adnexus',
orgName: 'Ad Nexus',
logo: '/logos/adnexus.svg',
logoAlt: 'Ad Nexus',
colors: {
primary: '#8b5cf6', // Purple
primaryText: '#ffffff',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Welcome to Ad Nexus',
subtitle: 'Programmatic advertising platform',
},
links: {
terms: 'https://ad.nexus/terms',
privacy: 'https://ad.nexus/privacy',
support: 'https://ad.nexus/support',
home: 'https://ad.nexus',
},
},
}
// Resolve domain to branding key
// Handles: exact match, id.{domain} → {domain}, {sub}.{domain} patterns
// Runtime-extensible tenants: deployments can ship additional tenant branding via
// TENANT_BRANDING_JSON env var (a JSON object: { domain: BrandingConfig, ... }).
// Downstream tenants and other white-label deployments can override/add tenants
// without modifying this source.
const ENV_TENANTS: Record<string, Partial<BrandingConfig>> = (() => {
try {
const raw = process.env.TENANT_BRANDING_JSON
if (!raw) return {}
const parsed = JSON.parse(raw)
return typeof parsed === 'object' && parsed !== null ? parsed : {}
} catch {
return {}
}
})()
// Merge static (compile-time) + env (runtime) tenants. Env wins.
Object.assign(staticBranding, ENV_TENANTS)
export function resolveBrandingDomain(host: string): string {
const domain = host.split(':')[0]
// Exact match first
if (staticBranding[domain]) return domain
// Try stripping 'id.' prefix: id.ad.nexus → ad.nexus
if (domain.startsWith('id.')) {
const stripped = domain.slice(3)
if (staticBranding[stripped]) return stripped
}
return domain
}
-57
View File
@@ -1,57 +0,0 @@
/**
* Client ID → application/organization map.
*
* Used for resolving the correct IAM application from a client_id,
* e.g. during social login callbacks where we need to know which
* app and org the login belongs to.
*/
export interface ClientInfo {
application: string
organization: string
}
export const CLIENT_APP_MAP: Record<string, ClientInfo> = {
// Hanzo org
'hanzo-platform-client-id': { application: 'app-platform', organization: 'hanzo' },
'hanzo-app-client-id': { application: 'hanzo-id', organization: 'hanzo' },
'hanzo-id': { application: 'hanzo-id', organization: 'hanzo' },
'hanzo-console-client-id': { application: 'app-console', organization: 'hanzo' },
'hanzo-cloud-client-id': { application: 'app-cloud', organization: 'hanzo' },
'kms-client': { application: 'app-kms', organization: 'hanzo' },
'hanzo-kms-client-id': { application: 'app-kms', organization: 'hanzo' },
'hanzo-commerce-client-id': { application: 'app-commerce', organization: 'hanzo' },
'hanzo-team-client-id': { application: 'app-team', organization: 'hanzo' },
'hanzobot-client-id': { application: 'app-hanzobot', organization: 'hanzo' },
'chat-app': { application: 'app-chat', organization: 'hanzo' },
'hanzo-chat-client-id': { application: 'app-hanzo-chat', organization: 'hanzo' },
'hanzo-web3': { application: 'app-hanzo-web3', organization: 'hanzo' },
'app-analytics': { application: 'app-analytics', organization: 'hanzo' },
'app-insights': { application: 'app-insights', organization: 'hanzo' },
'bootnode-web': { application: 'app-bootnode', organization: 'hanzo' },
'zt-console': { application: 'app-zt-console', organization: 'hanzo' },
'hanzo-storage-client-id': { application: 'app-storage', organization: 'hanzo' },
'hanzo-auto-client-id': { application: 'app-auto', organization: 'hanzo' },
'hanzo-flow-client-id': { application: 'app-flow', organization: 'hanzo' },
// Adnexus org
'adnexus-app-client-id': { application: 'app-adnexus', organization: 'adnexus' },
// Lux org
'lux-app-client-id': { application: 'app-lux', organization: 'lux' },
'lux-chat-client-id': { application: 'app-lux-chat', organization: 'lux' },
'lux-kms-client': { application: 'app-lux-kms', organization: 'lux' },
'lux-web3': { application: 'app-lux-web3', organization: 'lux' },
'lux-mpc': { application: 'app-lux-mpc', organization: 'lux' },
// Zoo org
'zoo-app-client-id': { application: 'app-zoo', organization: 'zoo' },
'zoo-web3': { application: 'app-zoo-web3', organization: 'zoo' },
'zoo-mpc': { application: 'app-zoo-mpc', organization: 'zoo' },
// Pars org
'pars-app-client-id': { application: 'app-pars', organization: 'pars' },
'pars-mpc': { application: 'app-pars-mpc', organization: 'pars' },
// Zen org
'zen-app-client-id': { application: 'app-zen', organization: 'zen' },
}
export function resolveClient(clientId: string): ClientInfo | undefined {
return CLIENT_APP_MAP[clientId]
}
-77
View File
@@ -1,77 +0,0 @@
/**
* IAM backend URL resolution.
*
* Maps the login portal domain to the correct IAM backend.
* Configurable via env vars for self-hosted deployments.
*/
// Default domain → IAM URL mapping
const IAM_URLS: Record<string, string> = {
'hanzo.id': 'https://iam.hanzo.ai',
'id.hanzo.ai': 'https://iam.hanzo.ai',
'lux.id': 'https://iam.lux.network',
'id.lux.network': 'https://iam.lux.network',
'zoo.id': 'https://iam.zoo.network',
'id.zoo.network': 'https://iam.zoo.network',
'pars.id': 'https://iam.pars.network',
'id.pars.network': 'https://iam.pars.network',
'zen.id': 'https://iam.hanzo.ai',
'id.ad.nexus': 'https://iam.hanzo.ai',
}
// Default domain → org mapping
const ORG_MAP: Record<string, string> = {
'hanzo.id': 'hanzo',
'id.hanzo.ai': 'hanzo',
'lux.id': 'lux',
'id.lux.network': 'lux',
'zoo.id': 'zoo',
'id.zoo.network': 'zoo',
'pars.id': 'pars',
'id.pars.network': 'pars',
'zen.id': 'zen',
'id.ad.nexus': 'adnexus',
}
// Default domain → default app clientId
const APP_MAP: Record<string, string> = {
'hanzo.id': 'hanzo-id',
'id.hanzo.ai': 'hanzo-id',
'lux.id': 'app-lux',
'id.lux.network': 'app-lux',
'zoo.id': 'app-zoo',
'id.zoo.network': 'app-zoo',
'pars.id': 'app-pars',
'id.pars.network': 'app-pars',
'zen.id': 'app-zen',
'id.ad.nexus': 'app-adnexus',
}
export function getIamUrl(host: string): string {
const domain = host.split(':')[0]
// 1. Check env override (for self-hosted / K8s)
if (typeof process !== 'undefined') {
const envUrl = process.env.NEXT_PUBLIC_IAM_URL || process.env.HANZO_IAM_URL
if (envUrl) return envUrl
}
// 2. Static map
return IAM_URLS[domain] ?? 'https://iam.hanzo.ai'
}
export function getOrg(host: string): string {
const domain = host.split(':')[0]
if (typeof process !== 'undefined' && process.env.NEXT_PUBLIC_ORG) {
return process.env.NEXT_PUBLIC_ORG
}
return ORG_MAP[domain] ?? 'hanzo'
}
export function getDefaultClientId(host: string): string {
const domain = host.split(':')[0]
if (typeof process !== 'undefined' && process.env.NEXT_PUBLIC_CLIENT_ID) {
return process.env.NEXT_PUBLIC_CLIENT_ID
}
return APP_MAP[domain] ?? 'hanzo-id'
}
-193
View File
@@ -1,193 +0,0 @@
/**
* OAuth2 / OIDC client with PKCE (RFC 7636)
*
* Works against Hanzo IAM backend via the tenant's iamOrigin.
*/
// --- PKCE helpers ---
function generateRandomString(length: number): string {
const array = new Uint8Array(length)
crypto.getRandomValues(array)
return Array.from(array, (b) => b.toString(16).padStart(2, '0')).join('').slice(0, length)
}
async function sha256(plain: string): Promise<ArrayBuffer> {
const encoder = new TextEncoder()
return crypto.subtle.digest('SHA-256', encoder.encode(plain))
}
function base64urlEncode(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
let str = ''
for (const b of bytes) str += String.fromCharCode(b)
return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
export async function generatePKCE() {
const verifier = generateRandomString(64)
const hashed = await sha256(verifier)
const challenge = base64urlEncode(hashed)
return { verifier, challenge }
}
// --- Types ---
export interface TokenResponse {
access_token: string
token_type: string
expires_in: number
refresh_token?: string
id_token?: string
scope?: string
}
export interface UserInfo {
sub: string
name?: string
displayName?: string
preferred_username?: string
email?: string
avatar?: string
permanentAvatar?: string
picture?: string
owner?: string
}
// --- Token storage (sessionStorage for PKCE, localStorage for session) ---
const PREFIX = 'hanzo_auth_'
export function storeSession(key: string, value: string) {
sessionStorage.setItem(PREFIX + key, value)
}
export function retrieveSession(key: string): string | null {
const val = sessionStorage.getItem(PREFIX + key)
sessionStorage.removeItem(PREFIX + key)
return val
}
// --- Core flows ---
/**
* Password login against IAM /api/login.
* Returns JWT token directly.
*/
export async function passwordLogin(params: {
iamUrl: string
org: string
username: string
password: string
application: string
clientId?: string
redirectUri?: string
}): Promise<{ token: string; code?: string }> {
const url = new URL('/v1/iam/login', params.iamUrl)
// If OAuth params provided, pass as query params (camelCase — IAM convention)
if (params.clientId && params.redirectUri) {
url.searchParams.set('clientId', params.clientId)
url.searchParams.set('responseType', 'code')
url.searchParams.set('redirectUri', params.redirectUri)
url.searchParams.set('scope', 'openid profile email')
}
const res = await fetch(url.toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'token',
organization: params.org,
username: params.username,
password: params.password,
application: params.application,
...(params.clientId ? { clientId: params.clientId } : {}),
...(params.redirectUri ? { redirectUri: params.redirectUri } : {}),
}),
})
const data = await res.json()
if (data.status !== 'ok') {
throw new Error(data.msg || 'Login failed')
}
return { token: data.data }
}
/**
* Start OAuth authorize redirect with PKCE.
*/
export async function startAuthorize(params: {
iamUrl: string
clientId: string
redirectUri: string
scope?: string
}) {
const { verifier, challenge } = await generatePKCE()
const state = generateRandomString(32)
storeSession('pkce_verifier', verifier)
storeSession('oauth_state', state)
const url = new URL('/oauth/authorize', params.iamUrl)
url.searchParams.set('client_id', params.clientId)
url.searchParams.set('response_type', 'code')
url.searchParams.set('redirect_uri', params.redirectUri)
url.searchParams.set('scope', params.scope ?? 'openid profile email')
url.searchParams.set('state', state)
url.searchParams.set('code_challenge', challenge)
url.searchParams.set('code_challenge_method', 'S256')
window.location.href = url.toString()
}
/**
* Exchange authorization code for tokens.
*/
export async function exchangeCode(params: {
iamUrl: string
code: string
state: string
clientId: string
redirectUri: string
}): Promise<TokenResponse> {
const savedState = retrieveSession('oauth_state')
if (savedState !== params.state) {
throw new Error('OAuth state mismatch')
}
const verifier = retrieveSession('pkce_verifier')
const body = new URLSearchParams({
grant_type: 'authorization_code',
code: params.code,
redirect_uri: params.redirectUri,
client_id: params.clientId,
...(verifier ? { code_verifier: verifier } : {}),
})
const res = await fetch(`${params.iamUrl}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
})
if (!res.ok) {
throw new Error(`Token exchange failed: ${res.status}`)
}
return res.json()
}
/**
* Fetch user info.
*/
export async function fetchUserInfo(iamUrl: string, accessToken: string): Promise<UserInfo> {
const res = await fetch(`${iamUrl}/oauth/userinfo`, {
headers: { Authorization: `Bearer ${accessToken}` },
})
if (!res.ok) throw new Error(`Userinfo failed: ${res.status}`)
return res.json()
}
-495
View File
@@ -1,495 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
/**
* Next.js middleware — the core proxy layer for Hanzo ID.
*
* Handles:
* 1. Multi-tenant hostname → org/IAM resolution
* 2. RFC 6749/OIDC path normalization (standard → IAM backend paths)
* 3. Social provider redirect with _oauth_ctx cookie
* 4. OIDC discovery body rewriting
* 5. Location header rewriting
*
* This is a white-label login portal. Any domain pointing here gets a
* working OIDC/OAuth2 provider experience. Configure via env vars for
* self-hosted deployments, or use the built-in tenant map.
*/
// --- Tenant configuration ---
interface TenantConfig {
org: string
iamOrigin: string
publicOrigin: string
}
const TENANTS: Record<string, TenantConfig> = {
'hanzo.id': {
org: 'hanzo',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://hanzo.id',
},
'id.hanzo.ai': {
org: 'hanzo',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.hanzo.ai',
},
'lux.id': {
org: 'lux',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://lux.id',
},
'iam.lux.network': {
org: 'lux',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://iam.lux.network',
},
'id.lux.cloud': {
org: 'lux',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.lux.cloud',
},
'id.lux.network': {
org: 'lux',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.lux.network',
},
'id.lux-dev.network': {
org: 'lux',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.lux-dev.network',
},
'id.lux-test.network': {
org: 'lux',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.lux-test.network',
},
'id-dev.hanzo.ai': {
org: 'hanzo',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id-dev.hanzo.ai',
},
'id-test.hanzo.ai': {
org: 'hanzo',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id-test.hanzo.ai',
},
'zoolabs.id': {
org: 'zoo',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://zoolabs.id',
},
'id.zoo.network': {
org: 'zoo',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.zoo.network',
},
'id.zoo-dev.network': {
org: 'zoo',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.zoo-dev.network',
},
'id.zoo-test.network': {
org: 'zoo',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.zoo-test.network',
},
'id.hanzo.network': {
org: 'hanzo',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.hanzo.network',
},
'id.hanzo-dev.network': {
org: 'hanzo',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.hanzo-dev.network',
},
'id.hanzo-test.network': {
org: 'hanzo',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.hanzo-test.network',
},
'pars.id': {
org: 'pars',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://pars.id',
},
'id.pars.network': {
org: 'pars',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.pars.network',
},
'zen.id': {
org: 'zen',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://zen.id',
},
'id.ad.nexus': {
org: 'adnexus',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.ad.nexus',
},
'auth.hanzo.ai': {
org: 'hanzo',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://auth.hanzo.ai',
},
}
function getTenant(hostname: string): TenantConfig {
const host = hostname.split(':')[0]
const tenant = TENANTS[host]
if (tenant) return tenant
// Env override for self-hosted / fork deployments
const envIamOrigin = process.env.IAM_ORIGIN || process.env.NEXT_PUBLIC_IAM_URL
return {
org: process.env.NEXT_PUBLIC_ORG || 'hanzo',
iamOrigin: envIamOrigin || 'https://iam.hanzo.ai',
publicOrigin: `https://${host}`,
}
}
// --- RFC path normalization ---
// PATH_REWRITES collapse RFC-standard OAuth/OIDC paths onto IAM's canonical
// surface. IAM exposes `/v1/iam/*` natively — `/api/*` is legacy and not
// part of the canonical surface. Every standard RFC alias funnels into a
// `/v1/iam/*` target, exactly one way.
const PATH_REWRITES: Record<string, string> = {
// NOTE: /oauth/authorize is handled explicitly in middleware() — NOT here.
// RFC 6749 — Token (exchange, refresh, client_credentials all use this)
'/oauth/token': '/v1/iam/login/oauth/access_token',
// RFC 7662 — Token Introspection
'/oauth/introspect': '/v1/iam/login/oauth/introspect',
// RFC 7009 — Token Revocation
'/oauth/revoke': '/v1/iam/login/oauth/revoke',
// OIDC Core — UserInfo
'/oauth/userinfo': '/v1/iam/userinfo',
// OIDC — Logout
'/oauth/logout': '/v1/iam/logout',
// RFC 8628 — Device Authorization
'/oauth/device': '/v1/iam/login/oauth/device',
// JWKS — standard /.well-known/jwks.json → IAM's /.well-known/jwks
'/.well-known/jwks.json': '/.well-known/jwks',
// RFC 8414 — OAuth metadata
'/.well-known/oauth-authorization-server': '/.well-known/openid-configuration',
}
// Paths to proxy to IAM backend (prefix match). `/v1/iam/` is the canonical
// IAM surface — everything else here is RFC-spec aliasing that lands at IAM
// after PATH_REWRITES normalization.
const IAM_PATH_PREFIXES = [
'/v1/iam/',
'/oauth/',
'/login/oauth/',
'/.well-known/',
'/cas/',
'/scim/',
]
// Paths handled by the Next.js app (login UI)
const APP_PATHS = [
'/login',
'/signup',
'/callback',
'/account',
'/forgot-password',
'/logout',
]
function shouldProxyToIAM(pathname: string): boolean {
// Don't proxy Next.js internal paths or our own API routes
if (pathname.startsWith('/_next/')) return false
if (pathname.startsWith('/api/auth/')) return false
if (pathname.startsWith('/api/logout')) return false
// Don't proxy paths handled by the Next.js app
// But DO proxy /login/oauth/* (IAM backend paths rewritten from /oauth/*)
if (pathname.startsWith('/login/oauth/')) return true
for (const p of APP_PATHS) {
if (pathname === p || pathname.startsWith(p + '/')) return false
}
return IAM_PATH_PREFIXES.some(p => pathname.startsWith(p))
}
// --- Social provider redirect handling ---
/**
* When /login/oauth/authorize or /oauth/authorize is called with a ?provider=
* param, we need to:
* 1. Resolve the app/org from the client_id
* 2. Set an _oauth_ctx cookie so the callback handler knows context
* 3. Proxy to IAM which redirects to the social provider
*/
async function handleSocialProviderRedirect(
request: NextRequest,
url: URL,
pathname: string,
tenant: TenantConfig,
): Promise<NextResponse | null> {
if (!url.searchParams.has('provider')) return null
const provider = url.searchParams.get('provider')!
const clientId = url.searchParams.get('client_id') || ''
const iamHost = new URL(tenant.iamOrigin).host
// Resolve app/org via IAM API, fall back to client map
let appName = ''
let appOwner = ''
if (clientId) {
// Dynamic import to keep middleware lean
const { resolveClient } = await import('@/lib/clients')
try {
const loginParams = new URLSearchParams({
clientId,
type: 'code',
responseType: url.searchParams.get('response_type') || 'code',
redirectUri: url.searchParams.get('redirect_uri') || `${url.origin}/callback`,
scope: url.searchParams.get('scope') || 'openid profile email',
state: url.searchParams.get('state') || '',
})
const appLoginRes = await fetch(`${tenant.iamOrigin}/v1/iam/get-app-login?${loginParams}`)
const appLoginData = await appLoginRes.json()
if (appLoginData?.status === 'ok' && appLoginData.data) {
appName = appLoginData.data.name || ''
appOwner = appLoginData.data.owner || appLoginData.data.organization || ''
}
} catch {}
if (!appName) {
const client = resolveClient(clientId)
if (client) {
appName = client.application
appOwner = client.organization
}
}
}
// Store OAuth context in cookie for the callback handler
const oauthContext = JSON.stringify({
application: appName,
organization: appOwner,
provider,
redirectUri: url.searchParams.get('redirect_uri') || `${url.origin}/callback`,
clientId,
})
const oauthContextCookie = `_oauth_ctx=${encodeURIComponent(btoa(oauthContext))}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=600`
// Proxy to IAM — it manages the full social OAuth flow
const iamUrl = new URL('/login/oauth/authorize' + url.search, tenant.iamOrigin)
const headers = new Headers(request.headers)
headers.set('Host', iamHost)
headers.delete('connection')
const iamResponse = await fetch(iamUrl.toString(), {
method: request.method,
headers,
body: request.body,
redirect: 'manual',
})
const responseHeaders = new Headers(iamResponse.headers)
responseHeaders.append('Set-Cookie', oauthContextCookie)
// Rewrite IAM redirects to our domain
const location = responseHeaders.get('location')
if (location) {
responseHeaders.set('location', location.replaceAll(tenant.iamOrigin, tenant.publicOrigin))
}
return new NextResponse(iamResponse.body, {
status: iamResponse.status,
statusText: iamResponse.statusText,
headers: responseHeaders,
})
}
// --- Callback routing ---
/**
* Route /callback to the right handler:
* - If ?code=&state= present and state looks like a social callback → server-side handler
* - Otherwise → let the Next.js callback page handle it (PKCE flow)
*/
function isSocialCallback(url: URL): boolean {
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
if (!code || !state) return false
// IAM social callbacks have base64-encoded state starting with "?"
try {
const decoded = atob(state)
if (decoded.startsWith('?') || decoded.includes('application=')) return true
} catch {}
return false
}
// --- Main middleware ---
export async function middleware(request: NextRequest) {
const url = new URL(request.url)
let pathname = url.pathname
const hostname = url.hostname
const tenant = getTenant(hostname)
// Route social callbacks to server-side handler
if (pathname === '/callback' && isSocialCallback(url)) {
const socialUrl = new URL('/api/auth/social-callback' + url.search, url.origin)
return NextResponse.rewrite(socialUrl)
}
// Handle /oauth/authorize and /login/oauth/authorize
if (pathname === '/oauth/authorize' || pathname === '/login/oauth/authorize') {
// Social login: proxy to IAM with ?provider= param
if (url.searchParams.has('provider')) {
const socialResponse = await handleSocialProviderRedirect(request, url, pathname, tenant)
if (socialResponse) return socialResponse
}
// If user already has an IAM session, try to proxy to IAM to complete OAuth authorize.
// IAM will auto-authorize and redirect (3xx) if the session is valid for this app.
// If IAM returns 200 (its built-in login page), fall through to our own login UI.
const sessionCookie = request.cookies.get('iam_session_id')?.value
if (sessionCookie) {
const iamUrl = new URL('/login/oauth/authorize' + url.search, tenant.iamOrigin)
const iamHost = new URL(tenant.iamOrigin).host
const headers = new Headers(request.headers)
headers.set('Host', iamHost)
headers.set('Cookie', `iam_session_id=${sessionCookie}`)
headers.delete('connection')
const iamResponse = await fetch(iamUrl.toString(), {
method: 'GET',
headers,
redirect: 'manual',
})
// Only use IAM's response if it's a redirect (auto-authorize succeeded).
// If IAM returns 200 (its login page), fall through to our own login UI.
if (iamResponse.status >= 300 && iamResponse.status < 400) {
const response = new NextResponse(iamResponse.body, {
status: iamResponse.status,
statusText: iamResponse.statusText,
headers: iamResponse.headers,
})
const location = response.headers.get('location')
if (location) {
response.headers.set(
'location',
location.replaceAll(tenant.iamOrigin, tenant.publicOrigin)
)
}
return response
}
// IAM didn't auto-authorize — show our own login UI below
}
// Show our own login UI with OAuth context
// (Don't proxy to IAM's built-in SPA — hanzo.id IS the login UI)
const loginUrl = new URL('/login' + url.search, url.origin)
return NextResponse.redirect(loginUrl)
}
// Apply RFC path normalization
const rewrittenPath = PATH_REWRITES[pathname]
if (rewrittenPath) {
pathname = rewrittenPath
}
// Proxy IAM paths to backend
if (shouldProxyToIAM(pathname)) {
const iamUrl = new URL(pathname + url.search, tenant.iamOrigin)
const iamHost = new URL(tenant.iamOrigin).host
const headers = new Headers(request.headers)
headers.set('Host', iamHost)
headers.delete('connection')
const iamResponse = await fetch(iamUrl.toString(), {
method: request.method,
headers,
body: request.body,
redirect: 'manual',
})
// Rewrite OIDC discovery documents.
//
// IAM advertises a mix of canonical (`/v1/iam/login/oauth/*`, `/v1/iam/userinfo`)
// and OAuth2-spec (`/login/oauth/*`, `/oauth/*`) endpoints. The public RFC
// shape on this domain is `/oauth/*` — collapse both legacy `/api/*` and
// canonical `/v1/iam/*` rewrites onto `/oauth/*` so OIDC clients see the
// standard surface. PATH_REWRITES handles the inbound direction
// (RFC → canonical `/v1/iam/*` for proxying).
const isDiscovery = pathname === '/.well-known/openid-configuration'
|| pathname === '/.well-known/oauth-authorization-server'
if (isDiscovery && iamResponse.ok) {
const contentType = iamResponse.headers.get('content-type') || ''
if (contentType.includes('json')) {
try {
let body = await iamResponse.text()
// Rewrite IAM backend origin to public tenant origin
body = body.replaceAll(tenant.iamOrigin, tenant.publicOrigin)
// Normalize IAM backend paths (both canonical /v1/iam/* and
// legacy /api/*) to RFC standard /oauth/* surface.
body = body.replaceAll('/v1/iam/login/oauth/authorize', '/oauth/authorize')
body = body.replaceAll('/v1/iam/login/oauth/access_token', '/oauth/token')
body = body.replaceAll('/v1/iam/login/oauth/refresh_token', '/oauth/token')
body = body.replaceAll('/v1/iam/login/oauth/introspect', '/oauth/introspect')
body = body.replaceAll('/v1/iam/login/oauth/revoke', '/oauth/revoke')
body = body.replaceAll('/v1/iam/login/oauth/device', '/oauth/device')
body = body.replaceAll('/v1/iam/userinfo', '/oauth/userinfo')
body = body.replaceAll('/v1/iam/logout', '/oauth/logout')
body = body.replaceAll('/login/oauth/authorize', '/oauth/authorize')
body = body.replaceAll('/login/oauth/logout', '/oauth/logout')
return new NextResponse(body, {
status: iamResponse.status,
headers: iamResponse.headers,
})
} catch {}
}
}
// Clone response and rewrite redirect Location headers
const response = new NextResponse(iamResponse.body, {
status: iamResponse.status,
statusText: iamResponse.statusText,
headers: iamResponse.headers,
})
const location = response.headers.get('location')
if (location) {
response.headers.set(
'location',
location.replaceAll(tenant.iamOrigin, tenant.publicOrigin)
)
}
return response
}
return NextResponse.next()
}
export const config = {
matcher: [
// /v1/iam/* is the canonical IAM surface — this must match so the
// middleware proxies it to IAM_ORIGIN. Without this, CF Pages returns
// 405 for POST /v1/iam/login because the static SPA has no POST handler.
'/v1/iam/:path*',
'/oauth/:path*',
'/login/oauth/:path*',
'/.well-known/:path*',
'/callback',
'/cas/:path*',
'/scim/:path*',
],
}
-6
View File
@@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
-15
View File
@@ -1,15 +0,0 @@
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
output: 'standalone',
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '**',
},
],
},
}
export default nextConfig
-30
View File
@@ -1,30 +0,0 @@
{
"name": "@hanzo/id",
"version": "0.1.0",
"private": true,
"description": "White-label login portal for Hanzo IAM - forkable, multi-tenant, RFC-compliant OAuth2/OIDC",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"pages:build": "npx @cloudflare/next-on-pages 2>&1 || true && node scripts/patch-not-found.mjs && npx @cloudflare/next-on-pages --skip-build",
"deploy": "pnpm pages:build && wrangler pages deploy .vercel/output/static --project-name hanzo-id --commit-dirty=true",
"deploy:docker": "docker build -t hanzo-id . && docker push ghcr.io/hanzoai/id:latest"
},
"dependencies": {
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwindcss": "^3.4.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0"
},
"devDependencies": {
"@cloudflare/next-on-pages": "^1.13.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"typescript": "^5.0.0",
"wrangler": "^3.0.0"
}
}
-2453
View File
File diff suppressed because it is too large Load Diff
-9
View File
@@ -1,9 +0,0 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
export default config
-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 140 32" fill="none">
<text x="0" y="24" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="24" font-weight="700" fill="#8b5cf6">Ad Nexus</text>
</svg>

Before

Width:  |  Height:  |  Size: 226 B

-14
View File
@@ -1,14 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 32" fill="none">
<!-- H mark -->
<g transform="translate(0,0) scale(0.478)">
<path d="M22.21 67V44.6369H0V67H22.21Z" fill="#ffffff"/>
<path d="M0 44.6369L22.21 46.8285V44.6369H0Z" fill="#DDDDDD"/>
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#ffffff"/>
<path d="M22.21 0H0V22.3184H22.21V0Z" fill="#ffffff"/>
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#ffffff"/>
<path d="M66.6753 22.3185L44.5098 20.0822V22.3185H66.6753Z" fill="#DDDDDD"/>
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#ffffff"/>
</g>
<!-- Wordmark -->
<text x="42" y="24" font-family="-apple-system, BlinkMacSystemFont, 'Inter', sans-serif" font-size="24" font-weight="600" fill="white">Hanzo</text>
</svg>

Before

Width:  |  Height:  |  Size: 829 B

-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 32" fill="none">
<text x="0" y="24" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="24" font-weight="700" fill="#e4e4e7">Lux</text>
</svg>

Before

Width:  |  Height:  |  Size: 221 B

-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 32" fill="none">
<text x="0" y="24" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="24" font-weight="700" fill="#3b82f6">Pars</text>
</svg>

Before

Width:  |  Height:  |  Size: 222 B

-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 32" fill="none">
<text x="0" y="24" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="24" font-weight="700" fill="#a855f7">Zen</text>
</svg>

Before

Width:  |  Height:  |  Size: 221 B

-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 32" fill="none">
<text x="0" y="24" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="24" font-weight="700" fill="#22c55e">Zoo</text>
</svg>

Before

Width:  |  Height:  |  Size: 221 B

-31
View File
@@ -1,31 +0,0 @@
/**
* Workaround for @cloudflare/next-on-pages + Next.js 15
*
* Next.js 15 generates /_not-found as a Node.js function even when
* the root layout exports `runtime = 'edge'`. This script removes
* the _not-found function and its route from the Vercel build output
* so next-on-pages can proceed. The middleware handles 404s anyway.
*/
import { rmSync, readFileSync, writeFileSync } from 'fs'
import { join } from 'path'
const outDir = join(process.cwd(), '.vercel', 'output')
const funcDir = join(outDir, 'functions')
// Remove _not-found function directories
for (const name of ['_not-found.func', '_not-found.rsc.func']) {
try {
rmSync(join(funcDir, name), { recursive: true, force: true })
console.log(`Removed ${name}`)
} catch {}
}
// Remove _not-found route from config.json
const configPath = join(outDir, 'config.json')
const config = JSON.parse(readFileSync(configPath, 'utf8'))
config.routes = (config.routes || []).filter(
(r) => !String(r.dest || '').includes('/_not-found')
)
writeFileSync(configPath, JSON.stringify(config, null, 2))
console.log('Patched config.json — removed _not-found routes')
-20
View File
@@ -1,20 +0,0 @@
import type { Config } from 'tailwindcss'
const config: Config = {
content: [
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
primary: 'var(--color-primary)',
background: 'var(--color-background)',
surface: 'var(--color-surface)',
},
},
},
plugins: [],
}
export default config
-40
View File
@@ -1,40 +0,0 @@
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
},
"target": "ES2017"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@hanzo/id",
"private": true,
"version": "0.1.1",
"version": "0.1.29",
"description": "Hanzo ID — white-label login + identity verification portal (Vite + @hanzo/gui)",
"scripts": {
"build": "pnpm -r build",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzo/id-auth",
"version": "0.1.0",
"version": "0.1.1",
"description": "Composable login / signup / OTP / OAuth-PKCE flows on top of @hanzo/iam. UI primitives in @hanzo/gui.",
"license": "BSD-3-Clause",
"type": "module",
@@ -19,7 +19,8 @@
},
"dependencies": {
"@hanzo/id-shared": "workspace:*",
"@hanzo/iam": "^0.11.0"
"@hanzo/iam": "^0.11.0",
"@paulmillr/qr": "^0.3.0"
},
"peerDependencies": {
"react": ">=19",
+150
View File
@@ -0,0 +1,150 @@
/**
* MFA wiring tests — pure, no network (fetch is mocked via `fetchImpl`).
* Run with: pnpm --filter @hanzo/id-auth test
*
* Locks the wire contract verified live against iam.hanzo.ai:
* - login answers a forced-MFA org with `data:"RequiredMfa"` (enroll) or
* `data:"NextMfa"` + `data2` (challenge) — STRINGS, never a boolean.
* - the `/v1/iam/mfa/setup/*` calls carry EVERY param on the query string with
* an EMPTY body (the one shape IAM's authz self-match + controller accept).
* - the challenge re-POSTs `/v1/iam/login` with `{mfaType,passcode}` and NO
* username, riding the MFA session cookie.
*/
import { test } from 'node:test'
import assert from 'node:assert/strict'
import type { TenantConfig } from '@hanzo/id-shared'
import { createAuthClient, mfaChannelOf, MFA_TOTP } from './client.ts'
const TENANT: TenantConfig = {
orgId: 'hanzo',
iamUrl: 'https://hanzo.id',
iamIssuer: 'https://hanzo.id',
clientId: 'hanzo-id',
appName: 'hanzo-id',
publicOrigin: 'https://hanzo.id',
brandPackage: '@hanzo/brand',
}
type Call = { url: string; init: RequestInit }
function mockFetch(body: unknown, calls: Call[]): typeof fetch {
return (async (input: string | URL, init?: RequestInit) => {
calls.push({ url: String(input), init: init ?? {} })
return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } })
}) as unknown as typeof fetch
}
test('login → RequiredMfa maps to an enroll signal (not a redirect)', async () => {
const calls: Call[] = []
const client = createAuthClient({ tenant: TENANT, fetchImpl: mockFetch({ status: 'ok', data: 'RequiredMfa' }, calls) })
const res = await client.login({
identifier: 'davelorenzini@gmail.com',
password: 'x',
clientId: 'hanzo-id',
application: 'hanzo-id',
organization: 'hanzo',
})
assert.equal(res.mfaRequired, true)
assert.equal(res.mfaStage, 'enroll')
assert.equal(res.redirectUrl, undefined, 'must NOT short-circuit to /onboarding')
})
test('login → NextMfa maps to a challenge signal and carries the allowed types', async () => {
const calls: Call[] = []
const body = {
status: 'ok',
data: 'NextMfa',
data2: [{ mfaType: 'app', enabled: true }, { mfaType: 'sms', enabled: true }],
}
const client = createAuthClient({ tenant: TENANT, fetchImpl: mockFetch(body, calls) })
const res = await client.login({
identifier: 'davelorenzini@gmail.com',
password: 'x',
clientId: 'hanzo-id',
application: 'hanzo-id',
organization: 'hanzo',
})
assert.equal(res.mfaStage, 'challenge')
assert.deepEqual(res.mfaTypes, ['app', 'sms'])
})
test('mfaInitiate puts owner/name/mfaType on the query string with an empty body', async () => {
const calls: Call[] = []
const data = { secret: 'BOUYRUSHJCEDDB33', url: 'otpauth://totp/Hanzo:x?secret=BOUYRUSHJCEDDB33', recoveryCodes: ['rc-1'] }
const client = createAuthClient({ tenant: TENANT, fetchImpl: mockFetch({ status: 'ok', data }, calls) })
const setup = await client.mfaInitiate({ owner: 'hanzo', name: 'davelorenzini@gmail.com' })
assert.equal(setup.secret, 'BOUYRUSHJCEDDB33')
assert.equal(setup.mfaType, MFA_TOTP)
assert.deepEqual(setup.recoveryCodes, ['rc-1'])
const u = new URL(calls[0].url)
assert.equal(u.pathname, '/v1/iam/mfa/setup/initiate')
assert.equal(u.searchParams.get('owner'), 'hanzo')
assert.equal(u.searchParams.get('name'), 'davelorenzini@gmail.com')
assert.equal(u.searchParams.get('mfaType'), 'app')
assert.equal(calls[0].init.method, 'POST')
assert.equal(calls[0].init.body, undefined, 'body must be empty for authz self-match')
assert.equal(calls[0].init.credentials, 'include')
})
test('mfaVerify carries owner/name (for authz) + secret + passcode on the query', async () => {
const calls: Call[] = []
const client = createAuthClient({ tenant: TENANT, fetchImpl: mockFetch({ status: 'ok', data: 'OK' }, calls) })
const r = await client.mfaVerify({ owner: 'hanzo', name: 'dave@x', secret: 'SEC', passcode: '123456' })
assert.equal(r.ok, true)
const u = new URL(calls[0].url)
assert.equal(u.pathname, '/v1/iam/mfa/setup/verify')
assert.equal(u.searchParams.get('owner'), 'hanzo')
assert.equal(u.searchParams.get('secret'), 'SEC')
assert.equal(u.searchParams.get('passcode'), '123456')
assert.equal(u.searchParams.get('mfaType'), 'app')
})
test('mfaVerify surfaces an IAM error instead of throwing', async () => {
const calls: Call[] = []
const client = createAuthClient({ tenant: TENANT, fetchImpl: mockFetch({ status: 'error', msg: 'wrong passcode' }, calls) })
const r = await client.mfaVerify({ owner: 'hanzo', name: 'dave@x', secret: 'SEC', passcode: '000000' })
assert.equal(r.ok, false)
assert.equal(r.error, 'wrong passcode')
})
test('mfaEnable echoes the recovery code back on the query', async () => {
const calls: Call[] = []
const client = createAuthClient({ tenant: TENANT, fetchImpl: mockFetch({ status: 'ok', data: 'OK' }, calls) })
const r = await client.mfaEnable({ owner: 'hanzo', name: 'dave@x', secret: 'SEC', recoveryCode: 'rc-1' })
assert.equal(r.ok, true)
const u = new URL(calls[0].url)
assert.equal(u.pathname, '/v1/iam/mfa/setup/enable')
assert.equal(u.searchParams.get('recoveryCodes'), 'rc-1')
assert.equal(u.searchParams.get('secret'), 'SEC')
})
test('mfaChallenge re-POSTs /v1/iam/login with mfaType/passcode and NO username', async () => {
const calls: Call[] = []
// code flow: data is the freshly minted auth code
const client = createAuthClient({ tenant: TENANT, fetchImpl: mockFetch({ status: 'ok', data: 'AUTHCODE' }, calls) })
const res = await client.mfaChallenge({
mfaType: 'app',
passcode: '654321',
clientId: 'hanzo-id',
application: 'hanzo-id',
organization: 'hanzo',
redirectUri: 'https://app.example/cb',
state: 'st',
})
const sent = JSON.parse(String(calls[0].init.body)) as Record<string, unknown>
assert.equal(new URL(calls[0].url).pathname, '/v1/iam/login')
assert.equal(sent.mfaType, 'app')
assert.equal(sent.passcode, '654321')
assert.equal(sent.username, undefined, 'challenge must not send a username')
assert.equal(calls[0].init.credentials, 'include')
assert.equal(res.redirectUrl, 'https://app.example/cb?code=AUTHCODE&state=st')
})
test('mfaChannelOf maps IAM types to UI channels', () => {
assert.equal(mfaChannelOf('app'), 'totp')
assert.equal(mfaChannelOf('sms'), 'sms')
assert.equal(mfaChannelOf('email'), 'email')
assert.equal(mfaChannelOf('anything-else'), 'totp')
})
+177 -3
View File
@@ -5,11 +5,23 @@ import type {
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.
*
@@ -57,6 +69,31 @@ export interface AuthClient {
* 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. */
@@ -271,7 +308,129 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
return data ? { redirectUrl: data } : { error: 'provider login returned no redirect' }
}
return { tenant, login, signup, forgot, authorize, exchange, logout, getAppLogin, providerLogin }
async function getAccount(): Promise<MfaIdentity | null> {
const url = new URL('/v1/iam/get-account', tenant.iamUrl)
let body: Record<string, unknown>
try {
const res = await f(url.toString(), { headers: { Accept: 'application/json' }, credentials: 'include' })
if (!res.ok) return null
body = (await res.json()) as Record<string, unknown>
} catch {
return null
}
const d = (typeof body.data === 'object' && body.data ? body.data : {}) as Record<string, unknown>
if (typeof d.owner !== 'string' || typeof d.name !== 'string' || !d.owner || !d.name) return null
return { owner: d.owner, name: d.name }
}
/**
* Build a `/v1/iam/mfa/setup/*` POST URL with EVERY param on the query string
* and send an EMPTY body. This is the one wire shape IAM's authz filter and
* the MFA controller both accept: the controller reads `owner`/`name`/… from
* the merged form (query + body), while the authz filter only extracts the
* `{owner,name}` object from the query when the body is empty (a non-empty
* body is JSON-unmarshalled, and a urlencoded body fails that parse → empty
* object → the self-access match `sub==obj` fails → "Unauthorized operation").
* `owner`/`name` ride the query on EVERY call — including `verify`, which
* otherwise carries no identity — purely so that self-access check passes.
*/
async function mfaSetupPost(path: string, params: Record<string, string>): Promise<Record<string, unknown>> {
const url = new URL(`/v1/iam/mfa/setup/${path}`, tenant.iamUrl)
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v)
const res = await f(url.toString(), { method: 'POST', credentials: 'include' })
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>
if (typeof body.status === 'string' && body.status === 'error') {
throw new Error(typeof body.msg === 'string' && body.msg ? body.msg : `HTTP ${res.status}`)
}
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return body
}
async function mfaInitiate(id: MfaIdentity): Promise<MfaSetup> {
const body = await mfaSetupPost('initiate', { owner: id.owner, name: id.name, mfaType: MFA_TOTP })
const d = (typeof body.data === 'object' && body.data ? body.data : {}) as Record<string, unknown>
const secret = typeof d.secret === 'string' ? d.secret : ''
const url = typeof d.url === 'string' ? d.url : ''
if (!secret || !url) throw new Error('IAM returned no TOTP secret')
return {
mfaType: MFA_TOTP,
secret,
url,
recoveryCodes: Array.isArray(d.recoveryCodes) ? d.recoveryCodes.filter((c): c is string => typeof c === 'string') : [],
}
}
async function mfaVerify(req: MfaIdentity & { secret: string; passcode: string }): Promise<{ ok: boolean; error?: string }> {
try {
await mfaSetupPost('verify', { owner: req.owner, name: req.name, mfaType: MFA_TOTP, secret: req.secret, passcode: req.passcode })
return { ok: true }
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : String(e) }
}
}
async function mfaEnable(req: MfaIdentity & { secret: string; recoveryCode: string }): Promise<{ ok: boolean; error?: string }> {
try {
await mfaSetupPost('enable', {
owner: req.owner,
name: req.name,
mfaType: MFA_TOTP,
secret: req.secret,
recoveryCodes: req.recoveryCode,
})
return { ok: true }
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : String(e) }
}
}
async function mfaChallenge(req: MfaChallengeRequest): Promise<LoginResponse> {
const type = req.redirectUri ? 'code' : 'login'
const url = new URL('/v1/iam/login', tenant.iamUrl)
url.searchParams.set('clientId', req.clientId)
url.searchParams.set('responseType', 'code')
if (req.redirectUri) url.searchParams.set('redirectUri', req.redirectUri)
url.searchParams.set('scope', 'openid profile email')
if (req.state) url.searchParams.set('state', req.state)
if (req.codeChallenge) {
url.searchParams.set('code_challenge', req.codeChallenge)
url.searchParams.set('code_challenge_method', req.codeChallengeMethod ?? 'S256')
}
url.searchParams.set('type', type)
const res = await f(url.toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
type,
// No username: IAM resolves the user from the MFA session cookie it set
// when it answered NextMfa.
mfaType: req.mfaType,
passcode: req.passcode,
application: req.application,
organization: req.organization,
enableMfaRemember: req.rememberDevice ?? false,
}),
})
return parseLoginResponse(res, req)
}
return {
tenant,
login,
signup,
forgot,
authorize,
exchange,
logout,
getAppLogin,
providerLogin,
getAccount,
mfaInitiate,
mfaVerify,
mfaEnable,
mfaChallenge,
}
}
/**
@@ -352,6 +511,23 @@ 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) {
@@ -376,7 +552,5 @@ 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,
}
}
+11 -1
View File
@@ -1,4 +1,10 @@
export { createAuthClient, type AuthClient, type AuthClientOptions } from './client'
export {
createAuthClient,
mfaChannelOf,
MFA_TOTP,
type AuthClient,
type AuthClientOptions,
} from './client'
export { createIam } from './iam'
export {
startProviderLogin,
@@ -9,6 +15,10 @@ export {
export type {
LoginRequest,
LoginResponse,
MfaChannel,
MfaChallengeRequest,
MfaIdentity,
MfaSetup,
SignupRequest,
ForgotRequest,
OAuthAuthorizeRequest,
+17 -1
View File
@@ -23,11 +23,27 @@ test('GitHub hop builds the correct endpoint, client_id, redirect_uri, and scope
)!
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' },
@@ -50,7 +66,7 @@ test('Google uses its own endpoint + scope; a custom provider scope overrides',
ORIGIN,
SEARCH,
)!
assert.ok(g.startsWith('https://accounts.google.com/signin/oauth?'))
assert.ok(g.startsWith('https://accounts.google.com/o/oauth2/v2/auth?'))
assert.ok(g.includes('scope=profile+email'))
const custom = buildProviderAuthUrl(
+34 -7
View File
@@ -32,7 +32,9 @@
/** 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' },
Google: { endpoint: 'https://accounts.google.com/signin/oauth', scope: 'profile+email' },
// 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 {
@@ -50,12 +52,32 @@ export interface ProviderLoginParams {
readonly method?: 'signin' | 'signup'
}
/** Build the provider authorize URL (pure; testable without navigating). */
export function buildProviderAuthUrl(p: ProviderLoginParams, origin: string, search: string): string | null {
/**
* 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 = `${origin}/callback`
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.
@@ -68,9 +90,14 @@ 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. */
export function startProviderLogin(p: ProviderLoginParams): void {
/**
* 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)
const url = buildProviderAuthUrl(p, window.location.origin, window.location.search, callbackOrigin ?? window.location.origin)
if (url) window.location.assign(url)
}
+102 -1
View File
@@ -10,17 +10,75 @@ export interface LoginRequest {
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 mfaChannel?: 'totp' | 'sms' | 'email'
readonly mfaStage?: 'enroll' | 'challenge'
/**
* The IAM MFA types available for a `'challenge'` (from the login response's
* `data2`), in IAM's own vocabulary: `app` (TOTP), `sms`, `email`. Empty for
* enrollment.
*/
readonly mfaTypes?: readonly string[]
readonly error?: string
}
/**
* The TOTP enrollment material minted by `/v1/iam/mfa/setup/initiate`. The
* secret + `url` (an `otpauth://` URI) are rendered locally as a QR code — the
* secret never leaves the browser to a third party. `recoveryCodes[0]` must be
* echoed back to `/v1/iam/mfa/setup/enable`.
*/
export interface MfaSetup {
/** IAM MFA type — `app` for TOTP. */
readonly mfaType: string
/** Base32 TOTP secret. */
readonly secret: string
/** `otpauth://totp/...` provisioning URI for the authenticator app. */
readonly url: string
/** One-time recovery codes issued alongside the secret. */
readonly recoveryCodes: readonly string[]
}
/** The signed-in user's identity, resolved from the IAM session for MFA setup. */
export interface MfaIdentity {
readonly owner: string
readonly name: string
}
/** A TOTP challenge submission for a user who already enrolled (`NextMfa`). */
export interface MfaChallengeRequest {
/** IAM MFA type, e.g. `app` (TOTP), `sms`, `email`. */
readonly mfaType: string
readonly passcode: string
readonly clientId: string
readonly application: string
readonly organization: string
readonly redirectUri?: string
readonly state?: string
readonly codeChallenge?: string
readonly codeChallengeMethod?: 'S256' | 'plain'
/** Honor the org's "remember this device" window after a successful code. */
readonly rememberDevice?: boolean
}
export interface SignupRequest {
readonly email: string
readonly password: string
@@ -45,6 +103,49 @@ export interface OAuthAuthorizeRequest {
readonly responseType?: 'code' | 'token'
readonly codeChallenge?: string
readonly codeChallengeMethod?: 'S256' | 'plain'
/** Social provider name (e.g. "provider-github"); IAM initiates that provider's OAuth. */
readonly provider?: string
}
/** A third-party / wallet login provider attached to the application. */
export interface ProviderInfo {
readonly name: string
readonly displayName?: string
/** Casdoor provider type, e.g. GitHub, Google, Apple, Web3Onboard. */
readonly type?: string
/** Casdoor category, e.g. OAuth, Web3, SAML. */
readonly category?: string
readonly canSignIn?: boolean
readonly canSignUp?: boolean
}
/** A sign-in method offered by the application (Password, Verification code, WebAuthn, …). */
export interface SigninMethod {
readonly name: string
readonly rule?: string
}
/** The subset of the application's login config the portal renders from. */
export interface AppLoginInfo {
readonly name: string
readonly displayName?: string
readonly providers: ProviderInfo[]
readonly signinMethods: SigninMethod[]
readonly enablePassword: boolean
readonly enableCodeSignin: boolean
readonly enableSignUp: boolean
}
/** Passwordless login with an email/SMS verification code. */
export interface CodeLoginRequest {
/** Destination already sent a code: an email address or E.164 phone number. */
readonly dest: string
readonly code: string
readonly clientId: string
readonly application: string
readonly organization: string
readonly redirectUri?: string
readonly state?: string
}
export interface TokenResponse {
+132
View File
@@ -0,0 +1,132 @@
import { useEffect, useMemo, useState } from 'react'
import encodeQR from '@paulmillr/qr'
import type { AuthClient } from '../client'
import type { MfaIdentity, MfaSetup } from '../types'
import { OTPForm } from './OTPForm'
export interface MfaEnrollFormProps {
readonly client: AuthClient
/**
* Called once the user has verified a TOTP code AND the enrollment is
* persisted. The caller continues the session (onboarding or the OIDC
* code redirect).
*/
readonly onComplete: () => void
}
/**
* Forced TOTP enrollment, shown when IAM answers a login with `RequiredMfa`
* (org policy requires MFA and the user has none). There is intentionally NO
* skip / dismiss control — the only way past this screen is to enroll an
* authenticator. The QR is rendered locally from the `otpauth://` URI, so the
* TOTP secret never leaves the browser.
*
* Flow: `getAccount` (resolve identity from the session IAM set with
* `RequiredMfa`) → `mfaInitiate` (secret + QR) → user scans → `mfaVerify`
* (prove the code) → `mfaEnable` (persist) → `onComplete`.
*/
export function MfaEnrollForm({ client, onComplete }: MfaEnrollFormProps) {
const [identity, setIdentity] = useState<MfaIdentity | null>(null)
const [setup, setSetup] = useState<MfaSetup | null>(null)
const [fatal, setFatal] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
useEffect(() => {
let cancelled = false
async function begin() {
try {
const id = await client.getAccount()
if (!id) throw new Error('Your session could not be resolved. Please sign in again.')
const s = await client.mfaInitiate(id)
if (cancelled) return
setIdentity(id)
setSetup(s)
} catch (e) {
if (!cancelled) setFatal(e instanceof Error ? e.message : String(e))
}
}
void begin()
return () => {
cancelled = true
}
}, [client])
const qrSvg = useMemo(() => (setup ? encodeQR(setup.url, 'svg') : ''), [setup])
async function onCode(code: string) {
if (!identity || !setup || busy) return
setBusy(true)
setError(null)
try {
const verified = await client.mfaVerify({ owner: identity.owner, name: identity.name, secret: setup.secret, passcode: code })
if (!verified.ok) {
setError(verified.error ?? 'That code did not match. Try the current code from your app.')
return
}
const enabled = await client.mfaEnable({
owner: identity.owner,
name: identity.name,
secret: setup.secret,
recoveryCode: setup.recoveryCodes[0] ?? '',
})
if (!enabled.ok) {
setError(enabled.error ?? 'Could not enable two-factor authentication.')
return
}
onComplete()
} finally {
setBusy(false)
}
}
if (fatal) {
return (
<div className="hanzo-id-mfa-enroll">
<h2>Two-factor setup</h2>
<p role="alert" className="hanzo-id-error">{fatal}</p>
</div>
)
}
if (!setup) {
return (
<div className="hanzo-id-mfa-enroll">
<h2>Two-factor setup</h2>
<p className="lede">Preparing your authenticator</p>
</div>
)
}
const recoveryCode = setup.recoveryCodes[0]
return (
<div className="hanzo-id-mfa-enroll">
<h2>Set up two-factor authentication</h2>
<p className="lede">
Your organization requires two-factor authentication. Scan this QR code with an
authenticator app (Google Authenticator, 1Password, Authy), then enter the 6-digit code it
shows.
</p>
<div
className="hanzo-id-mfa-qr"
role="img"
aria-label="TOTP enrollment QR code"
// Local SVG from @paulmillr/qr — the otpauth secret never leaves the browser.
dangerouslySetInnerHTML={{ __html: qrSvg }}
/>
<details className="hanzo-id-mfa-manual">
<summary>Can't scan? Enter this key manually</summary>
<code className="hanzo-id-mfa-secret">{setup.secret}</code>
</details>
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
<OTPForm channel="totp" onSubmit={onCode} />
{recoveryCode ? (
<p className="hanzo-id-mfa-recovery">
Save this recovery code somewhere safe it lets you sign in if you lose your device:
<br />
<code>{recoveryCode}</code>
</p>
) : null}
</div>
)
}
+2
View File
@@ -1,4 +1,5 @@
import { useState, type FormEvent } from 'react'
import { SmsConsentNotice } from './SmsConsent'
export interface OTPFormProps {
readonly onSubmit: (code: string) => void | Promise<void>
@@ -39,6 +40,7 @@ export function OTPForm(props: OTPFormProps) {
required
/>
</label>
{channel === 'sms' ? <SmsConsentNotice /> : null}
<button type="submit" disabled={busy || code.length !== length}>{busy ? 'Verifying…' : 'Verify'}</button>
</form>
)
+80
View File
@@ -0,0 +1,80 @@
import type { AuthClient } from '../client'
import type { ProviderInfo } from '../types'
interface Meta {
readonly label: string
/** stable brand token used for the CSS class + data attribute (for icon styling) */
readonly brand: string
}
// Keyed by a normalized provider token (type or name, lowercased, alnum-only,
// "provider" prefix stripped). Falls back to a generic label for anything new.
const META: Record<string, Meta> = {
google: { label: 'Continue with Google', brand: 'google' },
github: { label: 'Continue with GitHub', brand: 'github' },
apple: { label: 'Continue with Apple', brand: 'apple' },
facebook: { label: 'Continue with Facebook', brand: 'facebook' },
web3: { label: 'Connect wallet', brand: 'web3' },
web3onboard: { label: 'Connect wallet', brand: 'web3' },
metamask: { label: 'Connect wallet', brand: 'web3' },
}
function metaFor(p: ProviderInfo): Meta {
const key = (p.type || p.name || '')
.toLowerCase()
.replace(/[^a-z0-9]/g, '')
.replace(/^provider/, '')
return META[key] ?? { label: `Continue with ${p.displayName || p.name}`, brand: 'generic' }
}
export interface ProviderButtonsProps {
readonly client: AuthClient
readonly providers: ProviderInfo[]
readonly mode: 'login' | 'signup'
readonly redirectUri?: string
readonly state?: string
readonly clientIdOverride?: string
}
/**
* Renders one button per social/wallet provider attached to the application.
* Each links to the IAM authorize endpoint with `provider=<name>`, which
* initiates that provider's OAuth and returns to `${publicOrigin}/callback`.
* The set is driven by the live app config (AuthClient.appLogin) — no
* hardcoded provider list — so enabling a provider in IAM surfaces it here.
*/
export function ProviderButtons(props: ProviderButtonsProps) {
const { client, providers, mode } = props
const usable = providers.filter((p) =>
mode === 'signup' ? p.canSignUp !== false : p.canSignIn !== false,
)
if (usable.length === 0) return null
const redirectUri = props.redirectUri ?? `${client.tenant.publicOrigin}/callback`
return (
<div className="hanzo-id-providers">
{usable.map((p) => {
const m = metaFor(p)
const href = client.authorize({
clientId: props.clientIdOverride ?? client.tenant.clientId,
redirectUri,
state: props.state ?? mode,
provider: p.name,
})
return (
<a
key={p.name}
className={`hanzo-id-provider-btn hanzo-id-provider-${m.brand}`}
href={href}
data-provider={m.brand}
>
{m.label}
</a>
)
})}
<div className="hanzo-id-or">
<span>or</span>
</div>
</div>
)
}
+38
View File
@@ -0,0 +1,38 @@
// Canonical A2P 10DLC consent copy. This EXACT disclosure is reused at every
// point where Hanzo collects or uses a phone number for messaging. It MUST stay
// verbatim-identical to the public opt-in page (hanzo.ai/sms-opt-in,
// `SMS_CONSENT_TEXT`) and to the IAM phone-login UI — Twilio / carrier campaign
// review compares the wording across surfaces. One string, reused everywhere.
export const SMS_CONSENT_TEXT =
'I agree to receive text messages (SMS) from Hanzo AI at the number provided, ' +
'including one-time passcodes and two-factor authentication, account and security ' +
'alerts, and transactional notifications. Message frequency varies. Message and data ' +
'rates may apply. Reply STOP to opt out at any time, or HELP for help. Consent is not ' +
'a condition of any purchase.'
const TERMS_URL = 'https://hanzo.ai/terms'
const PRIVACY_URL = 'https://hanzo.ai/privacy'
/**
* SMS consent disclosure shown beneath any phone/SMS surface (disclosure-only,
* no checkbox — the portal's SMS step is reached only after the user already
* provided/opted-in their number in IAM, and after a code was sent).
*
* For a phone-number COLLECTION surface that requires affirmative opt-in (A2P),
* gate the submit on a checkbox and reuse {@link SMS_CONSENT_TEXT} — see the IAM
* SignupPage `SmsConsentCheckbox`. The portal does not yet render its own phone
* field (collection happens in the IAM-hosted UI), so only the notice is used
* here today.
*/
export function SmsConsentNotice() {
return (
<div className="hanzo-id-sms-consent" role="note">
<p>{SMS_CONSENT_TEXT}</p>
<p className="hanzo-id-sms-consent-links">
By continuing, you agree to our{' '}
<a href={TERMS_URL} target="_blank" rel="noreferrer">Terms of Service</a> and{' '}
<a href={PRIVACY_URL} target="_blank" rel="noreferrer">Privacy Policy</a>.
</p>
</div>
)
}
+14 -8
View File
@@ -116,14 +116,20 @@ export function SocialButtons({
// 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,
})
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)
+2
View File
@@ -2,5 +2,7 @@ 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'
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzo/id-shared",
"version": "0.1.0",
"version": "0.1.1",
"description": "Shared types + tenant resolver for the Hanzo ID portal. No UI deps.",
"license": "BSD-3-Clause",
"type": "module",
+7 -2
View File
@@ -158,7 +158,7 @@ function hostSkeleton(host: string): TenantConfig {
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', 'brandPackage'] as const) {
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
}
@@ -183,11 +183,16 @@ function stripPort(h: string): string {
}
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: TRIM_TRAILING_SLASH(t.publicOrigin),
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),
}
}
+13
View File
@@ -18,8 +18,21 @@ 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
/** 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
}
/**
+9
View File
@@ -78,6 +78,9 @@ importers:
'@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
@@ -1460,6 +1463,10 @@ 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}
@@ -6159,6 +6166,8 @@ 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)':
+10
View File
@@ -1,3 +1,13 @@
packages:
- "apps/*"
- "pkgs/*"
allowBuilds:
esbuild: true
# Security overrides: force patched transitives (Dependabot alerts).
# esbuild <0.28.1 → GHSA-gv7w-rqvm-qjhr (high), GHSA-g7r4-m6w7-qqqr (low)
# uuid <11.1.1 → GHSA-w5hq-g745-h8pq (medium); only pulled by xcode@3.0.1
overrides:
esbuild: "^0.28.1"
uuid: "^11.1.1"