Commit Graph
19 Commits
Author SHA1 Message Date
zooqueenandHanzo Dev bed41f1865 password: an eye, so you can see what you typed
Every credential this portal takes was masked with no way to unmask. That is
the most common cause of a sign-in failure that looks like a wrong password,
and it is worst where typing is least reliable -- a phone keyboard, one glyph
at a time, with autocorrect and a shifted layout in the way. On SIGNUP it is
worse still: the account is created with whatever was actually typed, so a
typo behind the dots locks you out of an account you believe you just made.

ONE PasswordField, used by both forms. Login and signup had separate
hand-rolled <label>+<input> pairs; adding a toggle to each would have been two
answers to one question. Both now render the same primitive, so the field is
also the one place autoComplete is stated (current-password vs new-password --
getting that wrong is how a manager offers to "update" a saved credential
during a fresh signup).

Details that are the whole feature:
  - type="button". A bare <button> in a <form> defaults to submit, so the
    reveal would have submitted the form.
  - The button is OUTSIDE the <label>. A <button> inside one is activated
    twice -- as itself, and again by the label forwarding to its control -- so
    the toggle would fight itself. htmlFor keeps click-the-text-to-focus.
  - 44x44, the touch floor this stylesheet already holds everywhere else. An
    icon button only as big as its 20px glyph is a miss on a phone, which is
    the device this is for.
  - The input gives back 58px of right padding, so a long password never runs
    under the eye.
  - aria-pressed carries the state and the colour follows it, not :hover --
    on touch there is no hover, and "is my password on screen" must not
    depend on where a pointer is.
  - Icons live in ui/icons.tsx with the provider marks, inline and
    currentColor-driven. No icon dependency on the credential path.

Measured in a real browser on the built bundle, /login and /signup, at 390 /
768 / 1440 with the viewport actually resized between readings: button 44x44,
input 44 high, button inside the input box, no horizontal overflow, in all six.
Toggle across frames: password -> text -> password, aria-pressed false -> true
-> false. Suite 170/170, typecheck clean.

Default is masked. Nothing about the resting page changes.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-06 16:08:50 -07:00
zooqueenandHanzo Dev 7978f58a23 id: one focus ring, a CTA that answers the pointer, a lockup that stays 32px
@hanzo/design 0.4.5 -> 0.4.9 fixes the focus indicator at the root. Two rules in
its base layer both computed to (0,1,0), so source order decided, and the generic
ring overrode the `outline:none` the field rule stated to prevent it: every
focused input on hanzo.id drew BOTH the 2px ring and a halo. Measured on the
shipped bundle, before 2 indicators and after 1. Nothing in this repo could fix
that — app.css deliberately keeps no focus rule of its own — and the same bump
carries it to lux.id, zoo.id and pars.id.

The rest is this surface's own:

The filled button had no hover. It has declared `transition: background` since it
was written and --primary-hover has always existed, so Sign in / Continue /
Create account animated a property nothing changed. `:not(.ghost)` keeps it from
tying with the ghost hover at (0,4,0), where order would otherwise pick the
winner — the same defect class as the ring above, so it is not written that way
here. Measured: #fafafa -> #e5e5e5 on hover, ghost unchanged.

The brand lockup is pinned to 32px. BrandHeader writes `height={32}`, but a
presentational attribute loses to design's `:where(img,video){height:auto}`, so
each portal was sized by whatever its brand package shipped. @hanzo/brand's SVG
has a viewBox and no width/height, so Hanzo landed on 32 and looked correct;
@luxfi/brand's declares 1024x1024, so lux.id has been rendering a 342px mark over
its sign-in form. Now 32x32 on all three, Hanzo unchanged.

min-height moves to 100dvh with 100vh kept underneath as the fallback: on a phone
`vh` is the URL-bar-retracted viewport, so a full-height page overflows the space
it actually has.

Styling and one dependency only — no flow, field, CSRF or handler is touched.
160 tests pass, tsc clean, all four brands verified.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-05 06:42:46 -07:00
zeekayandHanzo Dev 6c6042648f onboarding: mint the bearer the writes need (0.2.26)
E2E caught the funnel dead-ending at consent: a password/form sign-in mints
a SESSION COOKIE and lands on /onboarding directly — the PKCE SDK holds no
token, update-user (rightly) refuses cookie-only writes, so every
saveOnboarding answered 401 and nothing ever persisted. The page now
bootstraps the token on mount: no SDK token → signinRedirect, which with a
live session is the silent-SSO authorize branch (no UI), /callback stores
the token and returns here. One bounce per session, guarded — a broken
mint degrades to the read-only state, never a redirect loop.

Also from the E2E: the plan step now SAYS when the catalog fetch failed
instead of silently rendering only pay-as-you-go ("there are no plans" is
false), and the popular plan wears a visible badge, not just a class.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-04 15:29:01 -07:00
zeekayandHanzo Dev d7ad6e02eb onboarding: consent + plan-or-payg steps, saved ONCE on the user (0.2.24)
The prepaid funnel's missing half. Two new steps close the flow:

- consent (required answer, either answer): the data-sharing agreement is
  asked exactly once and recorded on the USER (Properties, IAM row), so it
  never re-appears — on any browser.
- plan (LAST, required): pick a catalog plan or Pay as you go. Prices come
  from the pay catalog (GET <payUrl>/v1/billing/plans) — nothing hardcoded.
  The choice + completedAt persist BEFORE the redirect, so bouncing off the
  payment page never re-enters onboarding.

Completion gate: Onboarding.tsx reads onboarding.completedAt on mount and
skips straight to the portal for a user who already finished — the
DOES-NOT-REPEAT requirement, backed by the user row, not browser storage.

Handoff: plan slug → <payUrl>/cart?plan=<slug>; payg → <payUrl>/onboard
($5-minimum top-up). payUrl is a new optional OrgConfig field (catalog
override for white-label brands; defaults to https://pay.hanzo.ai).

Fixed underneath: every self-write now goes through ONE read-merge-write
(updateSelf). IAM's update-user is a FULL-ROW write that ignores the v1
columns= param, so the wallet step's minimal body was silently blanking
displayName/email on every link. Tests lock the round trip.

id-shared 0.1.3, id-onboarding 0.1.3, id-web 0.1.33, root 0.2.24.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-04 14:41:56 -07:00
zeekayandHanzo Dev 0122f0ac4d merge origin/main
Co-authored-by: Hanzo Dev <[email protected]>
2026-08-04 11:22:27 -07:00
zeekayandHanzo Dev a6e390bf9f web: the sign-in control takes the system's edge and radius
@hanzo/design 0.4.2 put control rungs back on the alpha ladder, so
.hanzo-id-input draws --border-control (.15) instead of --border-strong,
which is now hover and emphasis — decoration a control must not reach
for. Its comment argued the opposite in every clause; rewritten. The 3:1
budget did not vanish, it moved to --ring, where a keyboard user actually
reads position. Lines 183 and 374 keep --border-strong: both are hover.

Radius follows the same recut: the field and the button under it were
6px while the system moved every control to 8px. Changing only the field
would have left two radii on two stacked controls in one 432px card, so
both move.

tokens.test.ts had to change to stay true. It asserted each tokens/*.css
was reachable through @import; 0.4.x flattens all ten groups into
styles.css so a bundler never resolves those subpaths, and the check went
red with nothing wrong. It now asks whether every token a group declares
is actually served, which is the cherry-picking this gate exists to catch
and survives however the package assembles itself. base.css needed its
own assertion — it ships element defaults as rules, and the one token it
declares is also in colors.css, so token coverage cannot see it go
missing; `@layer base` is its exact marker. Both paths proven by
reintroducing the defect and watching the right message fire.

Pins ^0.4.5: 0.4.2 and 0.4.3 shipped a stray */ that PostCSS rejects and
are now deprecated on the registry.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-04 11:20:24 -07:00
zeekayandHanzo Dev f8955fb58f fix(web): mobile floors + a hover that painted a wireframe (0.2.23)
Styling audit of iam.hanzo.ai. The headline finding is a NEGATIVE and it is the
useful part: this surface does not have the "classes without rules" defect that
broke hanzo.app, and structurally cannot — it uses neither Tamagui atomic nor
Tailwind, only hand-written semantic .hanzo-id-*. Measured in Chromium against
the served bundle, 7 routes x 3 viewports: gui atomic used 0 | rules 0, tailwind
used 0 | rules 0, and every rendered .hanzo-id-* class resolves.

An audit claim was WRONG and is corrected in LLM.md rather than "fixed": a build
with @hanzo/design absent does NOT silently emit a bare @import. Tried it —
Vite's postcss-import hard-fails with ENOENT and exit 1. The token layer cannot
vanish from this build. What was true is narrower: the tree was uninstalled, so
tokens.test.ts could not run. It passes once deps are present.

Four real defects, all verified by before/after measurement of the BUILT bundle
in a real browser (a green build is exactly what let the original defect ship):

- Ghost hover set border-color:var(--foreground) — #ededed, 17.9:1 — a
  near-white wireframe on the two most-hovered controls. The edge no longer
  moves; the surface carries the state, which the existing transition already
  animated. NOT simply dimmed: every rung brighter than --neutral-500 is worse
  on white (--neutral-400 is 8.3:1 on black but 2.52:1 on white) and would fail
  the WCAG 1.4.11 floor the resting border holds in BOTH themes. No token gets
  brighter on dark and darker on light, so the edge must not encode state.
- viewport-fit=cover was declared and never consumed — zero env(safe-area-inset)
  rules. Now max(24px, env(...)) per side; 0 insets still compute to 24px.
- The iOS-zoom comment described a protection the code did not implement:
  --text-base is 14px, so both credential fields zoomed on focus. 16px scoped to
  @media (pointer: coarse); the desktop ramp is untouched.
- Three tap targets under 44px, including a logo link whose target was SMALLER
  than the logo inside it (inline <a> = 32x18 around a 32px mark). Fixed with
  padding/negative-margin pairs — zero layout shift, screenshots pixel-identical
  at rest.

Measured BEFORE -> AFTER, built bundle served like prod:
  tap targets <44px           3 -> 0        ghost hover  #ededed -> #737373
  safe-area rules             0 -> 1        @media rules       1 -> 2
  input font-size (touch)  14px -> 16px     overflow           0 -> 0
  console errors              0 -> 0        atomic|tailwind  0|0 -> 0|0
  CSS transferred        16,733 -> 17,070 bytes (+337)   JS unchanged

The 5 page-variant classes with no rule (hanzo-id-login/-signup/-forgot/
-onboarding-page/-callback) are left alone: BEM-style hooks paired with
.hanzo-id-page, which carries the layout — the same pattern as .hanzo-id-device.

Pre-existing and NOT touched: pkgs/shared/src/org.test.ts fails on origin/main
(hanzo.app vs hanzo.chat), verified on a pristine checkout; it belongs to the
in-flight callback work.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-04 10:19:14 -07:00
zooqueenandHanzo Dev 4ca3ff494e 0.2.15 — take the whole token layer, mount the ONE account control, gate on resolution
0.2.14 adopted @hanzo/design and worked around it in three places. Those
findings are fixed IN @hanzo/design 0.3.0, so the workarounds go and this
surface takes the system's answer.

- ONE import: `@hanzo/design/styles.css`. app.css cherry-picked four of nine
  token groups, so --z-*, --shadow-*, --space-*, --font-* and the element
  defaults did not exist here. Nothing broke visibly, because an unresolved
  var() paints nothing and reports no error. @hanzo/iam's account menu alone
  reaches for --z-popover, --shadow-floating and --space-1..3.

- Geist is self-hosted in 0.3.0, so the reason for cherry-picking is gone:
  fonts.css no longer requests fonts.googleapis.com. Measured on the built
  bundle: rendered face is Geist, zero third-party font requests.

- The :focus-visible rule is deleted from this file. tokens/base.css ships it
  to every consumer and --ring is now var(--neutral-500): measured 2px solid
  rgb(115,115,115), 4.43:1 on --background (WCAG 2.4.11 wants 3:1). The local
  --primary override existed only because --ring was 1.66:1.

- Control boundaries are --border-strong, not --white-40. 0.3.0 moved it to
  var(--neutral-500) — 4.43:1 in BOTH themes. --white-40 cleared 3:1 on black
  and measures 1.00:1 on white.

- @hanzo/iam 0.13.1 -> 0.21.1 in apps/web, pkgs/auth, pkgs/onboarding, and the
  signed-in portal mounts <UserMenu> in place of a hand-rolled Billing/Sign out
  link row. Identity comes from resolveIdentity, so the portal cannot disagree
  with the console about who you are. No `brand` prop: omitting markSvg would
  put the Hanzo mark on lux.id and zoo.id.

- Portal.tsx's inline style object is gone — rgba(255,255,255,0.14),
  borderRadius 12, fontSize 13, fontWeight 600 and two bare opacities, six
  invented values for facts the token layer already states.

- apps/web/src/tokens.test.ts gates RESOLUTION, not declaration. The reference
  is built at runtime from a string, so it is invisible to the compiler and to
  grep. Verified by reintroducing both original defects: it names the 5 dropped
  groups and the 8 unresolved iam tokens.

Measured in Chromium, fresh context, 1440 and 390, on the built bundle served
as hanzo.id / lux.id / zoolabs.id: 0 unresolved of 76 tokens referenced on every
host; input edge and focus ring both 4.43:1; the account menu paints #0a0a0a /
12px / --shadow-floating / z-index 700. Brand switches with zero per-brand code
and no Hanzo mark reaches the Lux menu.

Open, against layers below this surface: the menu's own panel edge is --border
at 1.27:1 on --popover (0.3.0 raised --ring and --border-strong, not --border),
and BrandHeader loads the mark from cdn.jsdelivr.net/@latest — a third-party
request on the sign-in path, which is what this file refuses for fonts.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 17:49:37 -07:00
zooqueenandHanzo Dev 7514c5b41a id: style from @hanzo/design tokens, and stop painting controls by ancestor
A component's surface must never depend on WHERE it is mounted. app.css painted
controls with the descendant selectors `form input {…}` and `.hanzo-id-btn, form
button {…}`. DeviceApproval — the screen a human hits to authorise the CLI — has
2 inputs and 0 <form> ancestors, so its device-code field fell out of the
stylesheet and rendered as raw UA chrome: 31px tall, #3b3b3b, a 2px inset bevel,
square corners, sitting beside correctly-styled 44px siblings. That is the mirror
image of a component library shipping utility class names with no CSS behind
them — bare elements instead of bare class names — and it was live on hanzo.id,
lux.id and pars.id at once. Every control now carries its own class
(.hanzo-id-input / .hanzo-id-btn / .hanzo-id-field / .hanzo-id-form) and no
element-descendant selector for surface is left in the file.

.hanzo-id-spinner had NO rule at all: the loading state measured 0px and was
invisible on every portal. It is a real 28px ring now.

Tokens come from @hanzo/design — the same token layer hanzoai/pay renders from,
so the two halves of one flow (sign in -> pay) agree on the page black (#000000,
--background; the page was painted with the --card value), the type ramp (h1 21px
on both), the radii and the greys. Nothing in this file invents a colour, radius
or size. @hanzo/gui was declared as a dependency and imported exactly zero times;
it is removed rather than left as decoration.

ONE button: .hanzo-id-social-btn and the .primary modifier are gone. .hanzo-id-btn
IS primary and .ghost is the secondary surface, so 13 treatments become 1
primitive with 1 modifier. ONE focus indicator: the file had a single focus rule
(`form input:focus`), so every button, link and social entry fell back to
Chrome's blue outline: auto on a monochrome surface. `font: inherit` on every
control: buttons and inputs rendered in Arial while headings rendered in the
platform face — two typefaces in one 432px card, including on the Sign in CTA.

Control borders are --white-40 on purpose. On --background the semantic --border
(#1f1f1f) measures 1.27:1 and --border-strong (#404040) 2.03:1; neither clears
the 3:1 a control boundary needs (WCAG 1.4.11), and --white-40 measures 3.66:1
and is on the ladder. Same reasoning for the focus ring: --ring (#333333)
measures 1.66:1 and cannot carry one, so the ring is --primary.

Fonts are deliberately NOT imported from the design package: tokens/fonts.css
pulls Geist from fonts.googleapis.com and the sign-in path loads no third-party
font. --font-sans resolves to the platform stack, the identical value hanzoai/pay
sets.

Measured in a real browser against the built bundle, fresh context and empty
storage: page #000000, every control 44px, one 6px radius, h1 21px, a white
2px focus ring, nothing under the touch floor at 390px, no horizontal overflow.
134 unit tests and every workspace typecheck green.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 09:25:33 -07:00
zeekay 52414cccbe feat(id/auth): merge EVM + Solana wallet buttons into one chain-agnostic Connect Wallet
SocialButtons rendered one wallet button per ENABLED chain (Continue with Ethereum / EVM + Continue with Solana). Merge the ENTRY into a single monochrome 'Connect Wallet' button that keeps BOTH flows.

- web3.ts: add detectWalletChains() — a pure window sniff (EVM=window.ethereum, Solana=window.solana/solflare/backpack) derived from ENABLED_WALLET_CHAINS; injectable window, no DOM/connect/I-O.
- SocialButtons.tsx: one Connect Wallet button (data-wallet-connect). onConnectWallet -> detect: exactly one injected chain connects straight; zero or many reveals an inline chooser (.hanzo-id-wallet-chains, data-chain=evm|solana) so either chain stays reachable. Per-chain startWallet -> loginWithWalletChain unchanged. Sits beside the GitLab provider (0.2.8).
- app.css: indented monochrome chooser matching the GitHub/GitLab/Google buttons.
- 3 new detectWalletChains unit tests (130 vitest pass). Frontend-only; IAM/casdoor untouched.
2026-07-23 01:38:36 -07:00
zeekayandHanzo Dev 1678b2bb70 fix(id): center auth card — pin .hanzo-id-page min-height:100vh so main can vertically center
Co-authored-by: Hanzo Dev <[email protected]>
2026-07-19 19:23:47 -07:00
zeekayandHanzo Dev dda2c7b376 fix(id): neutral "<Brand> ID" heading + title, WCAG-3:1 input border, centered auth card
- Login <h1> + document.title now use idBrandLabel(brand, orgId) → "Lux ID" /
  "Zoo ID" / "Hanzo ID" instead of the brand package's product name
  ("Lux Exchange" / "Zoo Exchange") leaking into the IAM portal heading + tab
  title. New helper in @hanzo/id-shared strips a trailing product word and
  appends " ID", preferring the tenant orgId.
- --border #262626 → #71717a: 1.25:1 → ~3.9:1 against the #111 input fill,
  clearing the WCAG 3:1 non-text-contrast threshold (inputs, social buttons,
  dividers, org rows).
- .hanzo-id-page main: justify-content center → the auth card is vertically
  centered instead of top-anchored (~15% → centered).

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-19 19:10:55 -07:00
zeekay 77cfde4cb9 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.
(cherry picked from commit 1b04366cc6)
2026-07-08 07:01:57 -07:00
zeekayandHanzo Dev 3c52303b70 fix(id/login): >=44px touch targets on sign-in + social buttons (cosmetic)
Sign-in button and the social/Web3 buttons rendered at 42px on mobile,
2px under the 44px minimum touch target. Bump sign-in vertical padding to
13px (44px) and add min-height:44px to the flex-centered social buttons.
CSS-only; no auth/OAuth logic touched. Branched off the deployed 0.2.3
(origin/main) so nothing but this cosmetic change ships. Version -> 0.2.4.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-05 07:29:01 -07:00
2460888022 feat: RFC 8628 device-authorization approval page (#16)
* feat(web): RFC 8628 device-authorization approval page

Add the /login/oauth/device approval page — the final gap in device login
(dev login --device-auth) on hanzo.id/lux.id/zoolabs.id.

- pkgs/auth: AuthClient.approveDevice(userCode) POSTs /v1/iam/login
  {type:device, userCode, application, organization} over the issuer session
  cookie (no credentials in body), mapping the consent-required and error
  branches. userCode normalized to IAM's [0-9a-z] alphabet.
- LoginForm: optional onAuthenticated to keep the caller on-page after sign-in.
- apps/web: DeviceApproval page — sign-in (reused) then anti-phishing confirm
  of app + code, success/error states. White-label via tenant. URL scrubbed of
  tokens. Route wired in App.tsx before the /login catch.
- Tests: 5 approveDevice cases (posting/normalization/empty/error/consent).

* fix(device): require explicit human affirmation before approval (H2)

H2 (HIGH): the ?user_code= prefill is now DISPLAY-ONLY. A signed-in victim
arriving from a crafted verification_uri_complete link can no longer one-click
approve an attacker's device — the Approve button stays disabled until the user
ticks an explicit checkbox affirming the code matches the one their OWN device
shows. Anti-phishing copy retained.

Aligns the client with the iam M1 change: user_codes are now UPPERCASE over an
unambiguous alphabet, so normalizeUserCode uppercases (was lowercase) for an
exact-match send, the code field renders uppercase, and the placeholder/tests
use a realistic code. Adds the .hanzo-id-device-confirm style.

tc + build green; device client tests green.

---------

Co-authored-by: hanzo-dev <[email protected]>
2026-06-28 08:12:34 -07:00
zeekay 7263ced604 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
zeekay 33a34408ee 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 96cd0e0e68 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 93dd8c6308 rewrite: Vite + @hanzo/gui monorepo (drops CF Worker + Next.js)
Replaces the Cloudflare Worker (hanzo.id-worker) and the Next.js portal
with a pnpm monorepo following the a-monorepo/id pattern.

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

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

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

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

Cutover (separate ops PR — not in this commit):
  1. Tag + push image ghcr.io/hanzoai/id:0.1.0
  2. Apply k8s manifests, cert-manager issues TLS
  3. Remove CF Worker routes for hanzo.id/lux.id/zoo.id/pars.id
  4. CF A records → 129.212.164.5 (hanzo ingress LB)
  5. Archive hanzo.id-worker repo
2026-05-24 01:50:39 -07:00