Compare commits

..
42 Commits
Author SHA1 Message Date
zooqueen 0f6cec3315 playground: offer only what routes, gate frontier prices behind a plan
Hanzo CI/CD / cicd (push) Successful in 5m1s
CI/CD / cicd (push) Successful in 5m6s
Picking Claude Haiku 4.5 errored with 'model anthropic/claude-haiku-4.5
is not available' — the bundle's openrouter spelling reached the run
while the gateway routes the bare id. The catalog now resolves each
row's ROUTING id against the live set by tail alias, so the picker
submits exactly what the gateway serves and haiku 4.5 simply works;
rows live under no spelling stop being offered at all (outage degrades
to the full catalog so the picker never renders empty).

Premium is derived where the bundle omits it — input >= $5/Mtok or
output >= $25/Mtok, the Opus class at both its price points, Sonnet
and Haiku under both bars — and an org without a standing subscription
is not offered premium rows (usePlanGate over the org's own scoped
/billing proxy, failing open on billing outage). The gateway's 402
remains the enforcement point; the picker stops promising what it
would refuse. Seeding draws from the same offered pool, so a free org
never boots onto a premium default.
2026-08-05 11:19:05 -07:00
zooqueen 4d9aabb7bd release: 8.5.50 — the manifest says what the registry already carries
Hanzo CI/CD / cicd (push) Successful in 5m49s
CI/CD / cicd (push) Successful in 5m49s
The version in package.json had sat at 8.5.35 while CI published through
8.5.48 (the tag hanzoai/cloud pins today): a main push derives its number as
max(declared, published) + a patch, so the registry floor carried the series
alone and the manifest drifted fifteen patches behind it.

That matters for a hand-cut tag, because a tag build publishes the tag
VERBATIM with no monotonic check — cutting the next number after the last git
tag would have republished a semver that already exists, over different bytes,
and orphaned anything pinned to it. Saying the number here puts the human's
say-so back above the floor and makes the next release the one nobody is
holding.
2026-08-05 01:38:35 -07:00
zooqueen 30acbc52d0 assistant: the send carries a credential, and the card stops lying about why
Hanzo CI/CD / cicd (push) Successful in 7m52s
CI/CD / cicd (push) Successful in 7m55s
Four defects, all measured on the live console with wire captures.

The completions request carried NO Authorization header at all. `streamChat`
reached for a bare `fetch` because a stream cannot go through the parsing
helpers that consume the body — and in doing so it left the one file where
identity is attached, so every send answered 401 while the identical
non-streaming call succeeded. So the client grows the door that was missing,
`restStream`: same `authedFetch`, same `baseHeaders`, raw `Response` back. The
gateway stream, text-to-speech and the guide's SSE step-runner all take it, and
a test drives the real path and reads the Bearer off the wire.

Preferences saved to `/v1/ai/preferences`, which is the AI gateway's casibase
handler and authenticates on a casibase SESSION — no Bearer can satisfy it, so
every save was refused "Please sign in first" while the user was signed in. They
now ride `/v1/prefs`, the per-user plane on cloud, which reads exactly the
credential the console already sends. That plane also SERVES a read, which the
old one never did: preferences were recovered from a snapshot of them carried in
the identity token, and a merge had to guess from two timestamps which side was
newer. Reading the document removes the guess, and the ordering machinery with
it.

The 401 card said "Your session expired" to people whose session was live. The
shared classifier can only see the status; whether this session is live is a
fact only the app holds, so the assistant decides it — a refusal with an
unexpired token reads as a surface that refused a signed-in caller, and "sign in
again" is kept for when it is true.

On a desktop the composer could not be clicked. Docking opened the sheet AND the
column and hid one with CSS, but the sheet is a modal dialog and hiding its
content does not stop it being modal: it covered the viewport, the column
painted inert beneath it, and every click landed on the dialog. The two shapes
now meet in one fact — the dock choice, a viewport that can hold a column, and a
page that is not already a composer — and exactly one of them mounts.

Also: the cross-app launcher sat in the lg-only topbar group, so on a phone the
grid of other Hanzo apps was unreachable. It moves to the account drawer, whose
trigger is on every viewport — one launcher, reachable everywhere.
2026-08-05 01:33:28 -07:00
hanzo-dev c1a8c8495d the tenant is the org, and "Team" is that org's members — say both
Hanzo CI/CD / cicd (push) Successful in 8m35s
CI/CD / cicd (push) Successful in 8m36s
Nothing here was ever a team. The referral, store, affiliate and author copy
called a customer organization "a team" ("when a new team signs up", "every
time a team runs your project"). The onboarding step called the tenant "your
workspace" in its title and "your team" in its blurb, while the org picker one
control away said "organization" — three words for one thing.

And Settings > Team was not a team either: its own description reads
"Organization members and roles", its index label already said "Members", and
the sibling button already said "Manage members". Only the nav label held out,
sitting in brand-scope as "Team, organization, and profile" — as if the two
were different things.

So: the tenant is an organization everywhere a human reads it, and the page
listing the people in it is Members.

Left alone: the `/team` route id (it is a link people already have), the `team`
plan tier, the helpdesk's HD Team doctype, and the tracker's real Team entity
whose boards are keyed ENG-1 — none of those are tenancy. "Teammate" and
"invite your team" stay too; those are people.
2026-08-05 01:17:31 -07:00
zooqueen a6bc25943f lists: numbered pages, and one stale claim corrected
Hanzo CI/CD / cicd (push) Successful in 4m51s
CI/CD / cicd (push) Successful in 4m51s
The observability footer offered Prev and Next and nothing else, so a reader 40
pages into a trace list reached page 1 by pressing Prev forty times. Pagination
from @hanzo/ui/product gives numbered jumps with an ellipsis. What stays in
Pager is the sentence only this surface can write — the row range — because only
it knows the shape of the meta its endpoints return.

ModelSelector's header said the package's selector was "the shadcn/Tailwind
build" and that the console, running Tamagui, could not use it. That stopped
being true: at 8.0.56 @hanzo/ui/models imports @hanzo/gui and
@hanzogui/lucide-icons-2, with no Radix and no class strings. Two real things
still block the swap and the comment now names them — the entry shape
(CatalogEntry vs ModelCatalogEntry, which decides family grouping) and the row
logos (this one draws curated brand marks through ui/ProviderLogo; the package's
draws none). A stale reason is worse than no reason: it retires a question that
is actually still open.

playground/ModelSelect had its import placed above 'use client' by the sweep
that moved it onto the package. Next reads the directive only as the first
statement, so the file had quietly stopped being a client module.
2026-08-04 23:33:49 -07:00
zooqueen 70b99204ac secrets: a masked field that is actually masked, and one copy button
`secure` on a gui Input is React Native's spelling, and the WEB build drops it:
`secureTextEntry` becomes type="password" only in @hanzogui/core's native.cjs —
grep the esm/cjs builds and the prop is not there at all. So every field in this
console that asked to be masked rendered its value in plain text, and the prop
made it look handled.

Five of them were secrets:

  AdminModule       a user's initial password
  KmsModule         a KMS secret value
  ConnectionsModule a provider API key
  AccountsTab       an AI provider key
  platform-apps     an app env secret — on a panel whose own caption promises
                    "never stored or shown in plaintext"

All five now use SecretInput from @hanzo/ui/product, which sets BOTH spellings
(`masked()` — neither alone is safe) and adds a deliberate reveal. ApiKeysModule
joins them: it printed a freshly minted key as bare selectable monospace, and
SecretInput's read-only mode copies while still masked, which removes the reason
to unmask at all.

The copy control was written seven times — sentry, code, git, webhooks,
Kubernetes, api-keys, playground, verify — each with its own confirmation window
(1200, 1400, 1500, 2000ms) and its own error behavior, one of which logged the
clipboard failure with the value in it. They are one import now. git/parts
re-exports it so its four views keep a single import site, and CopyRef stays as
the wrapper that knows how to spell a `repo file:line` reference.

The shared control also reports what was copied by LENGTH, never by content.

Typecheck clean; 3210 passing, 8 skipped.
2026-08-04 23:30:50 -07:00
zooqueen b88389e91c ui: delete the shadow fork, and reach for the package that replaced it
src/components/ui/ was a ~30-file copy of components that were hoisted OUT of
this console into @hanzo/ui/product (hanzoai/ui#36). The hoist landed; the copy
never left. Both halves kept getting edits, so the console has been shipping the
half nobody else could see.

Twenty modules go back to the package, 278 call sites now import
'@hanzo/ui/product', and 2,311 lines leave the tree. Everything the package
gained while the copy sat here arrives with them — chiefly the interaction
instrumentation: DataTable, PrimaryButton, ConfirmDelete, EmptyState, Segmented
and SearchInput now report what a user did through @hanzogui/telemetry, which
console already depends on, with no wiring and no provider to mount.

BackendStateCard needs two effects a presentational layer cannot have — sign in
again (401) and add credits (402). The package asks its host for them, so
src/entry/host.tsx answers once at the dashboard root and all 106 cards below
render the right affordance unchanged. One answer, not 106.

The package's components name their CSS classes without the hz- prefix
(`skeleton`, `row`, `tnum`, `fade-up`, `drag`), so app/layout.tsx now imports
@hanzo/ui/styles/motion.css. Without it a DataTable's skeleton, row hover and
tabular figures render unstyled — the classes simply would not match. Console's
own hz- twins stay in globals.css; 250+ call sites in console markup still name
them.

Four things did NOT come back, because the package version is behind the copy
rather than ahead of it, and swapping them would ship a visible regression:

  ProviderLogo — draws curated per-family brand marks (BRAND_MARK, inline
    Slack/GitHub, brand-colored monograms). The package knows two providers and
    renders initials for everything else.
  ProductIcon — picks its glyph color with contrastText(). The package hardcodes
    #ffffff, which is unreadable on 3 of console's 19 product accents and
    invisible on #FAFAFA.
  Donut, Metric, Charts — read console's theme: var(--color4/9/12) rather than
    hardcoded dark hex, and the monochrome RAMP rather than the package's
    eight-color SERIES. Swapping repaints every chart in the console.
  SlideOver, Toast — take their layer from lib/z.ts. The package hardcodes
    zIndex 1000 and 100000, which are two of the exact literals that ladder was
    introduced to eliminate.

Field and Filters shrink to the part that is genuinely console's:
FieldOptionSelect (a value/label picker the package has no twin for) and the
Filters bar (it takes a List, so it is app-coupled by construction). color.ts
keeps only tileRadius, which encodes console's radius scale.

ThemeToggle and HanzoMark are deleted outright — nothing imported either; the
mark everything actually uses is ui/Loader's BrandMark.

Reorder.test.ts and combobox/filter.test.ts go with the code they tested: 3222
tests to 3210. Their assertions cannot follow the logic upstream, because
@hanzo/ui/product publishes no node-resolvable subpath for its pure functions —
importing the barrel drags @hanzogui/next-theme into vitest and fails on
next/script. A consumer therefore cannot guard the contract it now depends on.

Typecheck clean; 3210 passing, 8 skipped.
2026-08-04 23:23:59 -07:00
zooqueen d78a71e50d deps: @hanzo/ui 8.0.56 — the release that carries the product set
The console's own src/components/ui/ is a copy of components that were hoisted
into @hanzo/ui/product (hanzoai/ui#36) and never deleted here. 8.0.56 is the
release where every one of them lands published, so the copy has somewhere to
go. Typecheck clean and 3222 tests green on the bump alone — nothing in this
tree reaches an API that moved between 8.0.38 and 8.0.56.
2026-08-04 22:42:35 -07:00
zooqueen f8d83250f7 models: reach first, and the chrome carries only navigation
Hanzo CI/CD / cicd (push) Successful in 6m0s
CI/CD / cicd (push) Successful in 6m0s
The Models page listed every family in order but gave the user no way back to
the models they actually use: every visit started at the top of a long list.
A Recent + Suggested chip strip now sits above the families — recents are the
user's own trail (recorded where a model is genuinely exercised: a chat turn
sent, a catalog detail opened; account-persisted, so it follows them across
devices), suggestions are one live rung per pinned family, which puts the
house default first by construction. Both hide while a search owns the page,
and neither ever shows a fabricated chip.

The topbar slims to what navigation needs: status, docs, the cross-app
launcher, and the account drawer trigger — now on every viewport. The network
chip leaves the bar: production is the default environment, so switching is a
deliberate act you open the drawer for, not chrome you wear. The theme toggle
leaves too — the account menu already carries theme, and two controls for one
setting is one too many. Alerts stay at /alerts and in the drawer.

Chat's stale copy caught up with the Enso default: the surface no longer
greets you with the name of a family it stopped defaulting to.
2026-08-04 21:48:19 -07:00
hanzo-devandzooqueen a7a7fc9ef6 console: the cross-app launcher it never had
Console had no way to reach another Hanzo surface. The two "switchers" in its
topbar are about where you are INSIDE console — ContextSwitcher picks the org
and project, ScopeSwitcher picks the network — and the LayoutGrid in the sidebar
lists console's own products. A fullscreen launcher existed once and was removed
as a duplicate of that product filter, which it was; nothing replaced the part
it was actually for.

@hanzogui/shell has shipped HanzoAppLauncher the whole time, and chat already
wears it. It is inline-styled and React-only, so it drops into a Tamagui topbar
untouched — no wrapper, no theme bridge. quickSwitchKey={false} because
CommandPalette.tsx:806 already binds ⌘K; the shell's prop doc names Console as
the reason that opt-out exists, so this integration was designed for and then
never landed.

Also takes shell to 8.1.1, where the panel is portalled out of its host's
stacking context. This topbar does not clip it today, but the version that
cannot be clipped by a container the component never sees is the one to pin.
2026-08-04 21:48:19 -07:00
hanzo-devandzooqueen 7c6305c455 toast: memoize the context value so raising a toast cannot raise another
The provider built its context value fresh on every render and passed it
straight to Provider. Its own state changes on every toast, so each toast
handed every useToast() consumer a new identity. An effect that both
depends on the toast api and raises a toast therefore re-triggered itself:
raising one rendered the provider, which handed the effect a new api,
which raised another. The OAuth return did exactly that and stacked twelve
identical cards down the viewport.

Stripping the query params could not have stopped it. router.replace is
asynchronous, so the params are still readable on every render in between
-- and the effect calls load(), which causes exactly those renders. The
integrations return now latches once per mount; stripping the params
covers the NEXT mount, which is the case it can actually address.

The callbacks were already stable, so memoizing the object is the whole
fix, and it makes depending on the toast api safe everywhere, not just
here.

Proven by render, since no unit test can see a render loop: reverted, the
new spec counts 12 toasts; fixed, it counts 1.
2026-08-04 21:48:19 -07:00
zooqueen 15da85d0ac the assistant answers as Enso, and asks for anything
The console preselected zen5-flash and greeted you with 'Message
zen5-flash…' — the machine's model id where an invitation belongs, and
a stand-in family where Hanzo's own belongs. Enso left limited preview,
so the house family is what an unchosen caller should get: DEFAULT_MODEL
is enso-flash, the family's free rung, which keeps the property the old
default was chosen for (a trial or welcome balance still answers on the
first message). The placeholder now says what the box is for.

The preselect's fallback follows: the house family, then the first
model discovery offers.
2026-08-04 21:48:19 -07:00
hanzo-dev d20f0859f2 fix(entry): an anonymous console visitor starts the IAM hop, not a second landing
Hanzo CI/CD / cicd (push) Successful in 4m45s
CI/CD / cicd (push) Successful in 4m45s
Clicking "Sign in" on cloud.hanzo.ai appeared to do nothing. It was not a redirect
loop and nothing returned an error — every hop was HTTP 200, which is why the page
"looked fine".

console.hanzo.ai/ served a SECOND copy of the Hanzo Cloud marketing page, wearing the
byte-identical @hanzogui/shell header. So the journey was:

  cloud.hanzo.ai  [Sign in] -> console.hanzo.ai/   (same header, same "Sign in")
  console.hanzo.ai [Sign in] -> /signin            (one button, nothing else)
  /signin [Log in with Hanzo Cloud] -> hanzo.id

Three clicks, and the first landed on a page indistinguishable from the one it left.
That reads as a re-render, and users stopped there.

The console is the APPLICATION; the marketing face of Hanzo Cloud is cloud.hanzo.ai.
Serving a third copy of it on the app host is what created the illusion. So `/` is no
longer a special surface: everything except the two auth routes is `guarded`, and a
definitively-anonymous visitor STARTS the authorize hop instead of being parked on an
interstitial that only asks "did you mean it?".

  cloud.hanzo.ai [Sign in] -> hanzo.id

`startReauth()` is reused rather than `signinRedirect()`, so a deep link (/models)
returns to /models after login instead of dumping the user on the home.

SIGN-OUT IS PRESERVED, the one hazard here. Sign-out lands on /signin, which keeps its
button and never auto-authorizes -- IAM may still hold its own session, so authorizing
there would sign the user straight back in and make signing out impossible. A callback
failure lands on the same surface, so a broken hop cannot loop.

Removes PublicLanding + landing-surface, now unreachable (-269 lines net).

Tests: 258 files / 3211 assertions pass; tsc --noEmit clean.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 20:12:20 -07:00
hanzo-dev 53375e44ed test: make the rescued gui-8 guard true on this lineage
Hanzo CI/CD / cicd (push) Successful in 5m3s
CI/CD / cicd (push) Successful in 5m4s
The rescued gui8-props guard lands red here because origin fixed the same
line-height bug a different way and grew a shell the guard's fixtures predate.
Three corrections, none of them a weakened rule:

- gui8-props: blank out whole comment SPANS, not lines whose opener is `//`.
  The module already says a doc comment must be free to name the bug it
  documents; the fix comments at the call sites are multi-line `{/* … */}` JSX
  blocks, so the continuation lines were still scanned and PitchHero's own
  description of the bug counted as the bug.
- Charts: `lineHeight: 1.35` -> `'1.35'`. This one is a plain `<div>`, where
  React already emits it unitless, so the CSS is unchanged — but the string form
  is what the rule asks for and stays correct if it ever becomes a gui component.
- shell.test: `tracker` is a shell now (the rescued tracker pick), so the
  descriptor table's expected ids include it.

tsc --noEmit: 0 errors. vitest: 3217 passed, 8 skipped, 0 failed.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
(cherry picked from commit 6ca6cc8fe1)
2026-08-04 18:59:00 -07:00
hanzo-dev 87abc48add fix(gui): the gui 8 props that silently rendered nothing
The 8.x dependency convergence was already done and building. What was left is
the half it cannot catch: @hanzo/gui accepts any prop and drops the ones it does
not recognise, so a gui 7 spelling type-checks, builds, ships, and does nothing.

Asked the renderer instead of the type-checker (scripts/gui-prop-probe.mjs
renders a prop and reads the host element and emitted class back out), which
settled the open `tag` vs `render` question the interrupted session left and
found four live defects:

  tag="a"                -> <div tag="a">        an inert link
  style lineHeight: 1.1  -> line-height: 1.1px   a ratio is not a length

- CloudflareModule's pages.dev/workers.dev URL chip and every ContactModule
  channel card (mailto included) were <div>s. Both now render="a".
- PublicLanding's hero title, PitchHero's headline and the CodeSamples block
  shipped line-height 1.1px/1.12px/1.6px — a wrapped title, and every line of a
  code sample, drawn on one baseline. gui appends px to a bare number in `style`
  as much as in a prop, so the ratio is now spelled as a string.

Because the type system provably cannot gate this class, the gate is the source
text: src/lib/gui8-props.ts holds the four verified rules and its suite runs them
over every file that imports gui (335 of them). Scoping by import is what keeps
it precise — a `{ tag: 'v1' }` image tag in a pure-logic module is out of scope
by construction, as are the rule module and its own fixtures.

Also: typescript stays on 5.x, and that is correct rather than a shortfall.
typescript@7.0.2 is genuinely the native Go compiler (its tsc is a statically
linked ELF from typescript-go/cmd/tsgo), but it ships only
{version, versionMajorMinor} on the main entry, while `next build` calls
ts.parseJsonConfigFileContent / ts.JsxEmit / ts.ModuleKind /
ts.ModuleResolutionKind — all undefined there. TS7 breaks Next exactly the way it
breaks tsup, for the same reason. @typescript/native-preview (7.0.0-dev, behind
stable) stays removed.

Verified: next build ✓ compiled successfully in 25.8s, 20/20 pages;
tsc --noEmit exit 0; vitest 2903 passed / 8 skipped (the pre-existing enso-bench
parity self-skip); build:embed ✓ static export ready, 30 handlers restored.
Zero tailwind/radix/shadcn, not even transitively.

(cherry picked from commit c9a4d92554fa8b8274801218a2c14a0044cf17bf)

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
(cherry picked from commit 19e6d2e250)
2026-08-04 18:59:00 -07:00
hanzo-dev 8153606ac8 wip: preserve agent work interrupted by session limit
(cherry picked from commit bac7cc5bf68eb03973ab0bec5d28fcc23b2a2ee8)

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
(cherry picked from commit e077a2285a)
2026-08-04 18:59:00 -07:00
hanzo-dev c4ac4eff3f wip: preserve agent work interrupted by session limit
(cherry picked from commit c256eb39aab2280121bfec766fdea3ea92f71091)

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
(cherry picked from commit 775d9ab840)
2026-08-04 18:59:00 -07:00
hanzo-dev 922ba7d676 wip: preserve in-flight telemetry + event bump before 8.x convergence
(cherry picked from commit 0045bac1727d1251991f1106b8f2bccb1acb34ac)

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
(cherry picked from commit 909388da5e)
2026-08-04 18:59:00 -07:00
hanzo-dev 15b8cff948 refactor(commerce): render the shared CommerceResource, not a second copy
@hanzo/ui/product's CommerceResource documents itself as what BOTH the console's
Store category and the standalone Commerce admin render. That was false while
this directory kept its own copy — the console imported the local one, so the two
surfaces could drift silently and the shared component's docstring lied.

The local fork is deleted and the import repointed at @hanzo/ui/product, which is
what the Commerce admin already renders. One component, two surfaces, and the
docstring is now a fact.

tsc --noEmit: exit 0, 0 errors.

(cherry picked from commit e9524b5c4588ca6664ca23c341863faadaa48141)

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
(cherry picked from commit 4dbae2115d)
2026-08-04 18:59:00 -07:00
hanzo-dev bbff6b3a82 feat(tracker): Linear-grade standalone tracker at tracker.hanzo.ai
Rebuild the console Tracker into a SOTA, Linear-grade issue tracker over the
native cloud /v1/tracker surface, and give tracker.<brand> its own standalone
shell (the catalog chrome is stripped; the module's own views ARE the nav).

- Standalone shell: `tracker` ShellId + isTrackerHost + shellFromHost +
  PRODUCT_SHELLS.tracker (indexLabel "Issues", home "tracker"). Registry entry
  repointed to the native cloud backend with :view / :view/:sub routes and
  subpages (My Issues, Teams, Cycles, Roadmap). Host-detected exactly like
  billing./sentry./dns. — one image, one more face.
- Unified board: client-side cross-project merge (listAllIssues) → ONE filterable
  board across every team AND every mirrored GitHub repo (the App-webhook lane's
  GH mirror), with no second store. Group by status/priority/assignee/team,
  filter (status/priority/kind/source/assignee/label/team), live search,
  List <-> Board.
- Issue detail pane: one-click status, full edit, agent hand-off (assignee +
  `agent` label -> the cloud coding seam opens a linked PR), the issue<->branch
  <->PR chain (linkedPRs), git.hanzo.ai + upstream GitHub links, epic children.
- Keyboard-first: c=create, /=search, g-chord nav (g i/m/t/c/r), j/k/arrows/
  Enter/e, and — standalone only — a capture-phase Cmd-K command palette that
  never fights the console's global Cmd-K.
- Cycles (derived current iteration + progress) and Roadmap (epics + their
  ExtRef children), both from real data — honest, never fabricated.
- Richer Issue type (kind/source/repo/extRef) so GitHub-mirrored + agent-PR rows
  render distinctly; a "Sync GitHub" action triggers the org backfill.

Pure decisions decomplected to tracker/logic.ts (27 vitest green) + the shell
face test. Native @hanzo/gui v5 + React 19 — zero Svelte. Consumes the concurrent
App-webhook lane's GitHub mirror through the existing endpoints (no backend
duplication; the tracker data model + views + agent flow are owned here).

(cherry picked from commit 781db541e8476cb7e0bf1800c55f86f6d9162b50)

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
(cherry picked from commit 613abbabb5)
2026-08-04 18:59:00 -07:00
zeekayandClaude Fable 5 83e6eb4667 Read design tokens from the published package, not a vendored copy
Hanzo CI/CD / cicd (push) Successful in 5m16s
CI/CD / cicd (push) Successful in 5m16s
app/design/ carried a verbatim vendor of @hanzo/design's tokens, synced by hand
on 2026-07-24 because the package wasn't on npm. It is now (0.4.6), and the
vendored copy had already drifted a full border rework behind it — pure-black
ground, solid #1f1f1f borders, grey destructive, the pre-0.4.x palette the rest
of the fleet just moved off of. index.css now imports the real dependency and
the seven vendored token files are deleted; the two console-only tokens they
added (--border-card, --border-hairline) were unused. The Tamagui theme layer
in globals.css derives --colorN from the neutral ladder, which is unchanged, so
only the semantic surfaces adopt the rework — the intended unification.

tsc 0 errors, 3157 tests pass, next build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 15:53:28 -07:00
zeekayandhanzo-dev 32c88caa12 Merge branch 'lda' into mrg
Hanzo CI/CD / cicd (push) Successful in 6m35s
CI/CD / cicd (push) Successful in 6m36s
# Conflicts:
#	LICENSE

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 14:18:13 -07:00
hanzo-dev b4c71e1aac Merge branch 'fix/restore-upstream-license'
Hanzo CI/CD / cicd (push) Successful in 7m16s
CI/CD / cicd (push) Successful in 7m17s
Move vendored MIT copyright notices out of LICENSE into NOTICE, and add the
full MIT permission text alongside them. LICENSE is reserved for this
project's own BSD-3-Clause grant.
2026-08-04 12:16:05 -07:00
hanzo-dev c8295794fa Merge remote-tracking branch 'origin/ux/console-fab-apps-nav' into try-ux
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 12:08:51 -07:00
hanzo-dev 40da2fc17d o11y metrics: follow the read to its own name
cloud's per-product RED window and upstream o11y's metric-NAME CATALOG were both
answering GET /v1/o11y/metrics. Two different questions at one address, which is
why cloud had to suppress the module's real read to boot at all. cloud's moves to
/v1/o11y/product/metrics — the honest name for "one product's requests, error
rate and p95" — and the bare name goes back to the catalog it describes.

This is the read behind the platform-apps drawer, so it must land WITH the cloud
change (hanzoai/cloud fix/o11y-route-ownership): before it, this path 404s;
after it, the old path answers the catalog's shape instead of RED numbers.

`o11y` is still the allow-listed head in proxy-allow.ts, so the BFF is unchanged.
vitest 6/6 on the touched suite. tsc reports 3 errors in PublicLanding.tsx and
landing-surface.ts for a missing `@hanzogui/shell` — pre-existing here, present
on origin/main and untouched by this change, which edits only string literals.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 11:38:17 -07:00
hanzo-dev d09d3cae1b the assistant has one home, and the app directory is one you can walk
The assistant's way in was the only shape of it this module did not own: two
small buttons in the topbar, wedged between the search box and the account
chrome, putting it in a third place and squeezing a 390px header to five
controls. Both move into one floating control bottom-right — the same
openChat/startVoice, the same surface, in the corner it actually appears in.

All products was a directory you could not walk. Each app rendered as a plain
DIV with role=null and cursor:auto — measured, not read — so the one place the
whole catalog is browsable had exactly one live control per row, the pin. The
row opens its app now, through the shared openProduct, and closes the pane
behind it; pin stays a separate control that stops the press from bubbling.

And a pin made after sign-in was thrown away on the next reload. Preferences
are read off the identity token's claims — a snapshot taken when that token was
minted — and once a user has saved anything the token CARRIES one, so the merge
let an hour-old snapshot beat a newer write. It is now told the ordering it was
missing: the token's own iat against a stamp written only when the server
acknowledges a save. A save that never landed earns nothing, so this orders two
real writes rather than inventing durability in localStorage. There is still no
GET for the document; the smallest seam is named in preferences-core.

Five white-filled buttons competed on the home, counted by computed background
luminance. Now one: the getting-started card's active step.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 10:38:14 -07:00
hanzo-dev 0207df0759 fix(license): move vendored MIT notices out of LICENSE into NOTICE
console is NOT a fork -- its root commit is Hanzo's own and Hanzo is the
correct copyright holder. LICENSE is now a clean, canonical BSD-3-Clause
(verified against the SPDX text) naming Hanzo alone.

The seven retained MIT notices for vendored code (Tamagui, react-native-web,
Radix, Framer Motion, WorkOS et al) were attribution living in LICENSE. They
move to NOTICE, with the MIT permission notice reproduced so the obligation
travels with them.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 09:59:29 -07:00
zooqueen a0a489986d onboarding: Continue keeps its place, and no step can strand you
Hanzo CI/CD / cicd (push) Successful in 4m41s
CI/CD / cicd (push) Successful in 4m42s
StepActions was the last child of a flex column, so its y was whatever the
step's content happened to add up to — Continue sat at a different height on
every step and a user clicking through had to re-aim each time.

It is a slot on StepShell now, above a content area with a reserved height:
one placement, decided in one place. Taller content still grows.

The workspace step also offered no way past it without naming the workspace,
which is optional — it skips now. Consent deliberately still has none:
accepting the Terms is not optional, so an affordance that skipped them would
be dishonest, and Continue stays disabled until the box is ticked.

Proven by geometry in a browser, because the JSX move is invisible to a unit
test: both shapes render the same button with the same label. Removing the
reserved height fails the spec; restoring it passes.
2026-08-04 06:57:10 -07:00
hanzo-dev b6e35ee042 legal: console is MIT OR Apache-2.0 (HIP-0137)
Hanzo-original work; BSD-3 is out of scope for hanzoai under HIP-0137.
LICENSE becomes the dual pointer, LICENSE-MIT / LICENSE-APACHE carry the
texts, and the upstream MIT copyright notices previously kept in LICENSE
are retained verbatim in LICENSE-MIT. README, LLM.md, NOTICE, Dockerfile
and package.json follow.
2026-08-04 01:28:13 -07:00
hanzo-dev 06e416365b retire the ML Pipelines (Kubeflow) product — its whole backend is gone
Hanzo CI/CD / cicd (push) Successful in 5m28s
CI/CD / cicd (push) Successful in 5m29s
The `ml-pipelines` product (label "ML Pipelines", description "Orchestrated
training and evaluation pipelines (Kubeflow)", status enabled, slug aliases
`/kubeflow` and `/mlpipelines`) read nothing but Katib and Trainer: its three
sources were GET /v1/train/health, GET /v1/train/experiments (its "Pipelines")
and GET /v1/train/jobs (its "Runs"). Those CRDs are not served by the cluster and
cloud deleted the ops, so every one of them is now a 404 and the product's only
possible state is an error card. `KubeflowApi` had no other consumer.

There is nothing to repoint it at. Per-org model-shape SEARCH — the job Katib
was installed for — is /v1/risk/search, which runs natively in the org's own
sandbox and needs no CRD.

Also removed from the `/training` BFF allow-list: `train/jobs`,
`train/experiments`, `train/health`. An allow-list entry is a declaration that a
path exists; those three no longer do. `ml/models` (kserve, live and serving) and
the eight `finetune/*` broker heads stay exactly as they were.

STILL WIRED TO THE DELETED PATHS, deliberately left for its own change:
FinetuningModule's Jobs tab, its loss chart and NewTrainingPanel still call
TrainApi.listJobs/createJob/experiments. Gutting them would delete a product;
the right fix is to repoint them at the /v1/finetune/* broker — already
allow-listed two lines below in the same proxy, and a richer surface (presets, HF
pickers, cancel, deploy-to-serving). That is a payload-contract change with its
own verification, not a line to slip into a deletion.

Verified: match-core 37/37; the full suite is 3175/3175 on this tree, which is
pristine origin/main's 3177 minus exactly the two retired alias assertions, with
the same single pre-existing social.test.ts collection failure. tsc reports SIX
FEWER error files than pristine origin/main (413 vs 419 — the local
@hanzo/gui shorthand drift this box has either way) and not one error in a file
this change touches.
2026-08-04 00:32:39 -07:00
zooqueen da268976b8 auth: a refusal is not always a failure
Hanzo CI/CD / cicd (push) Successful in 4m59s
CI/CD / cicd (push) Successful in 4m59s
The callback screen said "Sign-in failed." to everyone. Cancel a Google consent
screen and the product told you it broke; return with a session the issuer would
not reuse and it said the same thing. Both are ordinary OIDC answers, not faults,
and the words were the only thing wrong with them.

The SDK is right to report them uniformly — it validates `state` before honouring
an error branch, because /callback?error=… is a plain GET anyone can hand a
victim, so nothing downstream may DECIDE from that code. But it may READ it: the
screen now classifies the code for WORDING only, with authority left where it was.
access_denied says the sign-in was cancelled; login_required / interaction_required
/ consent_required say the session ended and lead with Sign in — the action that
actually resolves them, rather than a Retry that repeats the same refusal. Anything
unrecognized prefers the issuer's own error_description, which names a real cause
far better than a generic line ever did.

Both strings arrive from a redirect, so both are bounded before they reach a
screen whose only job is to say what happened.

This also unblocks silent SSO: a top-level prompt=none attempt returns
error=login_required to the app, and until now that landed on "Sign-in failed."
— a wrong screen for the most ordinary outcome that flow has.

Pure classifier, tested apart from the browser: 9 cases covering each class, the
issuer's own words, the unbounded-description bound, and a malformed query, which
must not throw on the one screen a person cannot navigate away from.
2026-08-03 22:21:07 -07:00
zooqueen 2271c29297 profile: a photo you can change, instead of one you can only look at
Hanzo CI/CD / cicd (push) Successful in 5m4s
CI/CD / cicd (push) Successful in 5m5s
The Profile card rendered `avatar` read-only and offered "Edit in IAM" — which
links to an IAM that cannot set one either: its only writers are federation (a
GitHub avatar_url, an OIDC picture claim) and SCIM. A user who signed up with a
password had a monogram and no way out of it.

Now the card has "Add photo" / "Change photo". It posts to cloud's new
POST /v1/avatar, which stores the image in S3 and writes the URL onto the IAM
user row, so the change is visible to every product rather than this tab —
`reload()` re-reads the session so the rest of this tab agrees too.

The photo is downscaled to 512px in the browser first. A phone original is
several MB and would be served to every viewer of every page showing that face;
512 is larger than any surface renders it. The server still caps the body — this
is the courtesy, not the guard — and a source the canvas cannot decode is sent
verbatim so the server's format check stays the authority.

postForm is the ONE upload door, added because `request` JSON-encodes its body
so a file cannot travel that way. It shares everything else: the same
authedFetch (bearer + refresh) and the same baseHeaders tenant stamp, so an
upload is org/project-scoped exactly like a read. Content-Type is deliberately
deleted rather than set — the browser must write it itself to carry the
multipart boundary.

A refusal shows the SERVER'S reason: "a profile photo must be a PNG, JPEG, GIF
or WebP image" is actionable where "Request failed" is not.
2026-08-03 18:03:31 -07:00
zooqueenandhanzo-dev 9da398459e deps: iam 0.21.6 — the SDK whose storage default survives prerender
Hanzo CI/CD / cicd (push) Successful in 4m54s
CI/CD / cicd (push) Successful in 5m24s
The previous commit dropped console's explicit `storage: sessionStorage` so the
session would be shared across tabs, and the build died on the next push:

  Error occurred prerendering page "/auth/callback"
  ReferenceError: sessionStorage is not defined

`^0.21.2` resolved to a build whose default was the bare global
`config.storage ?? sessionStorage`, evaluated in the IAM constructor. In a
browser that is merely the wrong lifetime; under Next's static export it runs in
Node, where the identifier does not exist at all, so the constructor threw and
the export exited. Passing `sessionStorage` explicitly had been masking it —
the callback page constructs the SDK at module scope, and the value it passed
was the client-side one Next never evaluates on the server.

0.21.6 resolves storage through a guarded probe (`typeof localStorage`, then an
actual write, since privacy modes expose the object and throw on setItem) and
falls back to an in-memory Storage. So prerender gets a real object, the browser
gets localStorage, and neither needs a caller to know which one it is.

The caret already permitted 0.21.6; the lockfile is what pinned 0.21.2, which is
why CI installed the broken one while a local `pnpm install` would not have.
Verified by running the export that failed: 13/13 static pages, /auth/callback
among them.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 17:18:58 -07:00
zooqueen b9d31aa305 landing: the CTA starts the sign-in, instead of asking again
Hanzo CI/CD / cicd (push) Failing after 1m44s
CI/CD / cicd (push) Failing after 1m56s
Reaching a login form took three clicks across two hosts: "Sign in" on
cloud.hanzo.ai landed here, "Sign in" here routed to /signin, and /signin's
entire content was one button that starts the authorize redirect. The middle
step asked "did you mean it?" about an answer already given one click earlier.

Both CTAs now call signinRedirect() directly. /signin is unchanged and is still
the one sign-in surface — the guarded entry sends anon visitors there, sign-out
lands there, and deep links resolve to it. It deliberately does NOT auto-redirect
on load: after an explicit sign-out IAM may still hold a session, so an automatic
authorize would sign the user straight back in and there would be no way to leave.
The button there means something; the hop to reach it did not.
2026-08-03 17:07:58 -07:00
zooqueen 1b50c31a96 auth: one token store, and every tab can see it
The console passed `window.sessionStorage` to the IAM SDK explicitly, so the
session was scoped to the tab that established it. A middle-clicked link opened
signed out. Nothing here documented that as a security posture — the comment
merely restated the SDK's default — so this drops the override and lets the SDK
own the decision, where the other surfaces inherit the same one.

The hand-rolled `memoryStorage()` goes with it. It existed only because the SDK
used to touch a bare `sessionStorage` global that is undefined under SSR; the
SDK now falls back to memory itself, so the shim has no remaining job.

Worth naming: console already owns a stronger mechanism than either Web Storage
area — `src/lib/server/session.ts`'s AEAD-sealed httpOnly `hz_session`, written
precisely so a session "cannot lapse out from under a working tab". The client
half regressed off it onto the SDK's per-tab store. This change fixes the tab
bug; moving the credential itself out of script-readable storage is the separate,
larger piece of work that cookie was built for.

`hz_return_to` stays in sessionStorage on purpose: "come back to where I was" is
a property of the tab that navigated away, not of the session.

The e2e seeds move with the store. They forged tokens into sessionStorage, which
the SDK no longer reads — left alone, every primed spec would have started
signed out and the suite would have failed for a reason that had nothing to do
with what it was testing.
2026-08-03 17:05:27 -07:00
zooqueen 846069c98b onboarding: a refused create is not a complaint about the name
Hanzo CI/CD / cicd (push) Successful in 4m53s
CI/CD / cicd (push) Successful in 4m53s
Reported: signing in led to a create-account flow, an organization named
"coffee cups", and a refusal read as "organization name taken". The name was
free — there is no org matching it anywhere in the IAM store.

What actually fired is the FIRST-RUN GATE (iam internal/oidc/provision.go:156):
onboarding MOVES the caller into the org it founds, so founding a second would
strip this account from — and orphan — the org it already admins, along with that
org's billing account. IAM refuses with 409 "you already have an organization".
The console renders the server's message verbatim, so the customer was told about
organizations immediately after typing a name, and read it as being about the name.

This screen is only ever rendered when the client resolved an EMPTY owner
(entry/scope.tsx: `if (!owner) return <OrgOnboarding />`) — and an owner that is
empty because a read failed is indistinguishable from a brand-new account. So the
refusal is the first RELIABLE signal that the session was wrong, and the only
honest thing to do with it is recover, not apologise: read the account, and offer
the way into the org this identity is actually in.

THE STATUS ALONE CANNOT DECIDE THAT, which is the part worth naming. /v1/iam/onboard
answers 409 for two opposite reasons — the first-run gate above, and a name
genuinely held by another tenant (provision.go:190). What separates them is whether
this account is itself in an org: only the first has somewhere to go. readOnboardRefusal
takes that reading and returns recover-or-report, so a customer who really did pick
a taken name still sees the accurate message and is not bounced into someone
else's org.

Recovery is offered, never automatic — re-authenticating on our own would loop
forever against whatever left the session ownerless in the first place.

24 tests pass in onboarding.test.ts, 4 of them new and covering both 409s.
NOT yet browser-verified: this needs a real signed-in account whose session
resolves without an owner, which is the state I cannot manufacture locally.
2026-08-03 13:48:19 -07:00
hanzo-dev 06b7f5416a ia: one trigger for the command palette, not two
Hanzo CI/CD / cicd (push) Successful in 5m25s
CI/CD / cicd (push) Successful in 5m26s
The topbar carried a search box reading "Search or jump to… ⌘K" and, directly
beside it, an "Apps" button. Both called the SAME `useCommandPalette().open`.
Two adjacent triggers for one surface read as two different destinations, and
"Apps" in particular implied an app directory that does not exist — the
palette is what opens either way.

The mobile drawer had the identical pair, so it goes there too; otherwise the
rule would hold on a laptop and not on a phone.

Nothing is lost: the remaining control is the one that SAYS what it does and
shows its shortcut. This is the fifth of the five switchers, and the count on
the Overview at 1440x900 is now three — one per question:

  before  project (top-right) · account (bottom-left) · network (top-right) · apps
  after   context (top-left)  · account (bottom-left) · network (top-right)

Measured in a browser against the real shell, not counted by reading the JSX.

  tsc --noEmit  exit 0

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 12:37:06 -07:00
hanzo-dev 80cbfcbbb1 ia: org and project are one question, so they are one control
Five controls on one screen answered "who and where am I", and three of them
answered it from three different corners: the org mark top-left, the account
(which also switched tenant) bottom-left, and a project chip top-right beside
the network. Org and project are not two questions — they are "which tenant,
and which slice of it" — so they condense into ONE control at the top-left,
under the mark that already anchors the tenant.

Each question now has exactly one place:
  WHERE  ContextSwitcher, top-left     org + project
  WHO    AccountMenu, foot of the rail identity, team, settings, balance, exit
  MODE   ScopeSwitcher, top-right      network, with its tier dot

The network deliberately stays its own always-visible chip: it is a global
mode rather than a place, and the dot (mainnet green / testnet amber) is a
destructive-environment guard you must be able to READ without opening a menu.

There is still exactly ONE org switch. `org-scope.switchOrg` — the seam that
persists the scope and reloads so every module refetches under the new
`X-Org-Id`, and the seam tenant scoping and billing attribution hang off — is
imported, never reimplemented. The admin-gated, server-PAGED cross-tenant
search moved across whole rather than being reduced to a first page, so an
admin can still reach a tenant nobody is a member of. Verified against a
mocked cross-tenant list: typing "acme" narrows the org group to exactly
"Acme Industrial".

`adminOrgState` had no caller once the account menu stopped switching tenant,
so it is deleted rather than kept warm. The invariant it protected is now
pinned directly: `org-state.test.ts` scans every source file for `switchOrg(`
and asserts the caller set exactly. Mutation-tested — re-adding a switch to
AccountMenu turns it red, removing it turns it green.

One row, not two: the org/project/network menus rendered the same row shape
from two copies. That is `ui/MenuRow` now, so they cannot drift.

FINDING — a missing primitive, not worked around. A single-select list wants
ARIA `listbox`/`option`, and @hanzo/gui types `role` as React Native's
accessibility-role union: it admits `option` but NOT `listbox`, so an `option`
could never be given the parent ARIA requires. Used `radiogroup`/`radio`, the
single-select pair gui carries whole. @hanzo/gui should carry `listbox`.

`FieldText` grew an `ariaLabel`: a search field with no visible label had no
accessible name, and that is the console's own primitive to extend.

  tsc --noEmit  exit 0
  vitest        3156 passed, 8 skipped (255 files)

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 12:36:44 -07:00
hanzo-dev 4656c316f4 build: react-native-svg — the dependency gui 8 needs and the bump forgot
`deps: console onto gui 8.x` moved @hanzo/gui 7.3.0 -> ^8.0.0. gui 8 pulls
@hanzogui/lucide-icons-2@8.0.0, whose ESM build does

    import { Svg, Path } from "react-native-svg"

while declaring react-native-svg in NEITHER `dependencies` NOR
`peerDependencies`. Nothing installs it, so webpack cannot resolve it, and
`next build` dies with a wall of "Module not found: Can't resolve
'react-native-svg'" — once per icon. The `react-native$: react-native-web`
alias in next.config.mjs does not cover it: `$` is an EXACT-match alias and
react-native-svg is a different package.

So console main has been UNBUILDABLE since that bump. That is why
ghcr.io/hanzoai/console has no v8.5.33, v8.5.34 or v8.5.35 — the tags were
cut, the images were never published, and universe's bump to v8.5.35 had to
be reverted to v8.5.32 to keep a pullable image. The three fixes riding those
tags (the 1px display line-box, the missing 404, @hanzo/iam 0.21.2) have been
finished in main and dark in production the whole time.

The estate already had the answer: every app on gui 8 carries this dependency
explicitly (hanzo.ai, hanzo.sh-std, app-std, console-std all pin 15.15.5),
and every app still on gui 7.3.0 does not need it. Console took the bump
without the companion. This restores the one established pairing.

Measured, not assumed:
  before  next build -> exit 1, "Can't resolve 'react-native-svg'"
  after   next build -> exit 0, 42 routes emitted
  pnpm install --frozen-lockfile -> exit 0 (the Dockerfile's exact command)

Lockfile regenerated with the DECLARED pnpm 11.17.0 (node 22), not the pnpm 9
on PATH — pnpm 9 renormalizes peer-suffix keys and churns 1061 lines. This
diff is +96/-0, purely additive.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 12:11:49 -07:00
zeekay d9bccc9e8d console: 400+ models, and two comments that cannot keep a number current
Hanzo CI/CD / cicd (push) Failing after 1m2s
CI/CD / cicd (push) Failing after 1m2s
The guide headline said "100+ models. One OpenAI-compatible API." — wrong against
api.hanzo.ai, which serves 444 priced entries (and lists 107 callable ids). 400+
is true under the catalog reading and is what the rest of the estate now says.

Two comments carried counts they had no way to maintain — "85+ models across a
dozen families" and "~340 models" — each written when it was true and silently
wrong since. A comment that states a number the code does not compute is a comment
that will lie; both now describe the shape without asserting a size.
2026-08-03 09:45:29 -07:00
zooqueen ffd291fedf keys: two shapes — sk- authenticates, pk- does not
Hanzo CI/CD / cicd (push) Failing after 1m5s
CI/CD / cicd (push) Failing after 1m5s
The console spoke of three key prefixes. IAM resolves two: sk- (secret,
same-tenant pinned) and pk- (publishable, refused at the auth door). An
hk- string is not a key, so nothing in a user-facing surface may offer it.

The one behavioural change is the workbench Inspector's prefix filter,
which routed hk-/sk-/pk- to the account key status; it now recognizes
sk-/pk- and refuses anything else, with the error text to match. The
pasted value is never sent — the Inspector reads the session's own key
status — so this narrows what the UI calls a key without touching auth.

The rest is copy: placeholders, Bearer examples, product Auth facts and
the guide step now name sk-, the credential the /keys route actually
mints. The workbench legend had sk- as a "provider key" and implied all
three ride the Authorization header; it now separates the secret that
authenticates from the publishable value that never does.
2026-08-02 13:31:55 -07:00
hanzo-dev 4da48f9274 billing: call the route names the server registers, and stop minting our own credit
Commerce dropped the compound prefixes from its billing routes. The /v1/billing/
namespace already says "billing", so billing/payment-methods stuttered. Both
servers now register only the short names, and the live edge agrees:
/v1/billing/methods 401, /v1/billing/settings 403, /v1/billing/alerts 403, while
payment-methods, payment-config and spend-alerts are all 404.

The console never followed. Its card list, its card save, its card detach and its
Square-config read were all pointed at routes that no longer exist, which means a
new user could not add a card. This was the revenue path, broken in production.
Alerts had already been repointed, so the four dead call sites were the three
payment-methods ones and payment-config; they now build methods and settings.
No alias, no fallback — one name per concept.

The tests were part of the defect rather than the guard against it. Every suite
around payment methods stubbed a response body and asserted the normalization, so
a client aimed at a 404 stayed green; that is precisely how this survived. The URL
is now pinned where the request is made, including the two reads nothing had ever
asserted, and reverting any short name turns the suite red — checked, not assumed.
The two e2e specs that pinned dead URLs are corrected, and the isolation spec also
had the retired /billing/v1/ prefix.

POST /v1/billing/me/welcome is deleted rather than repointed, along with the type
and the module that fed it. Commerce removed that route deliberately: it was a
self-service mint, a browser could grant its own org $5, and commerce's own
api/billing/mint_gates_test.go calls it the TOCTOU double-mint. Credit is minted
only through the mint-gated POST /v1/billing/credit. The call was already failing
silently, so restoring it would have re-opened a closed money hole in exchange for
nothing. The trial credit still arrives — commerce grants it server-side when a
card is vaulted, and signup grants it server-side — and that path is untouched.

Scope was measured, not guessed. /v1/finance/payment-methods is still alive and
/v1/finance/methods is 404, so the finance ledger keeps the compound name; a
blanket repo-wide rename would have broken it. The Billing Center tab slugs are
console page URLs, not server routes, and are unchanged.

Two headlines blamed the wrong layer. "Card top-up isn't available on this
deployment yet" and "Adding a card isn't available on this deployment yet" both
fire when the organization has no Square applicationId or locationId — per-org
configuration, not a property of the deployment. Both now name the organization,
as does the onboarding step that had the same defect, and the stale endpoint hints
beneath them now read settings.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 22:07:39 -07:00
446 changed files with 19634 additions and 5761 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# console2 — Hanzo Cloud Console (Next.js 15 + @hanzo/gui). BSD-3-Clause.
# console2 — Hanzo Cloud Console (Next.js 15 + @hanzo/gui). MIT OR Apache-2.0.
# NEXT_PUBLIC_* are inlined at build time (browser config), so they are build args.
FROM public.ecr.aws/docker/library/node:24-alpine AS build
WORKDIR /app
+10 -38
View File
@@ -1,42 +1,14 @@
BSD 3-Clause License
Licensed under either of
Copyright (c) 2026-present, Hanzo AI, Inc.
* Apache License, Version 2.0 (LICENSE-APACHE or
https://www.apache.org/licenses/LICENSE-2.0)
* MIT license (LICENSE-MIT or https://opensource.org/licenses/MIT)
Portions of this software are derived from upstream code originally licensed under
the MIT License, with the following copyright notices retained per its terms:
at your option.
Copyright (c) 2020 Nate Wienert
Copyright (c) 2015-present, Nicolas Gallagher.
Copyright (c) 2015-present, Facebook, Inc.
Copyright (c) 2021 Radix
Copyright (c) 2017 Carmelo Pullara
Copyright (c) 2018 Framer B.V.
Copyright (c) 2022 WorkOS
Unless you explicitly state otherwise, any contribution intentionally
submitted for inclusion in the work by you, as defined in the Apache-2.0
license, shall be dual licensed as above, without any additional terms or
conditions.
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
See HIP-0137 (hanzoai/hips) for the standard this follows.
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+33
View File
@@ -0,0 +1,33 @@
MIT License
Copyright (c) 2026-present, Hanzo AI, Inc.
Portions of this software are derived from upstream code originally licensed
under the MIT License, with the following copyright notices retained per its
terms:
Copyright (c) 2020 Nate Wienert
Copyright (c) 2015-present, Nicolas Gallagher.
Copyright (c) 2015-present, Facebook, Inc.
Copyright (c) 2021 Radix
Copyright (c) 2017 Carmelo Pullara
Copyright (c) 2018 Framer B.V.
Copyright (c) 2022 WorkOS
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+135 -4
View File
@@ -1,7 +1,7 @@
# console2 — Hanzo Cloud Console
Unified admin console for **Hanzo Cloud** and all cloud products. Our code,
BSD-3-Clause, built on **@hanzo/gui** (the Tamagui-based cross-platform UI).
`MIT OR Apache-2.0` (HIP-0137), built on **@hanzo/gui** (the Tamagui-based cross-platform UI).
NOT an observability-console fork, NOT casibase — it is a clean client over the unified `/v1`
backend (`hanzoai/cloud`), reached at the ONE Hanzo API endpoint https://api.hanzo.ai/v1/*.
@@ -326,14 +326,14 @@ keep all credentials server-side (the browser only ever sends its session cookie
embeddings|rerank` (not a general tunnel). `playground.ts` now points at this
proxy (`<origin>/ai`), so Models/Playground/Chat/cmd+K all work with no key in
the browser and no rotation on a chat turn.
- **`app/keys/route.ts`** — per-user `hk-` Cloud API key. POST mint/rotate, DELETE
- **`app/keys/route.ts`** — per-user `sk-` Cloud API key. POST mint/rotate, DELETE
revoke, GET status (no secret). Same app-on-behalf pattern via
`/v1/iam/mint-user-keys` + `/v1/iam/revoke-user-keys`. The `hk-` secret is shown
`/v1/iam/mint-user-keys` + `/v1/iam/revoke-user-keys`. The `sk-` secret is shown
ONCE (POST). `ApiKeysModule` is now create/copy/rotate/revoke.
- Shared trust boundary: `src/lib/server/identity.ts` (server-only) — `resolveUser`
+ `mintUserKey`/`revokeUserKey`/`issueUserToken`. The `hanzo-console` client is
allow-listed in IAM `IAM_KEY_MINT_ALLOWED_APPS`; verified end-to-end that a
minted `hk-` key and an issued user JWT both 200 on `api.hanzo.ai/v1/chat/
minted `sk-` key and an issued user JWT both 200 on `api.hanzo.ai/v1/chat/
completions`.
- **Chat is interactive** (`chat/ChatConversation.tsx`): a real multi-turn
conversation over `AiApi.chat` (→ the `/ai` proxy), with a Zen default model,
@@ -3702,3 +3702,134 @@ and I will not type a password; one SuperAdmin session re-running this spec clos
that. Untouched and flagged for the caps pass: `MarketplaceModule`'s "CATEGORIES"
and the palette's own uppercased section labels are `textTransform` sites that
belong to that lane, not this one.
## Billing calls the route names the server actually registers
Commerce dropped the compound prefixes from its billing routes — the `/v1/billing/`
namespace already says "billing", so `billing/payment-methods` stuttered. Both servers
register only the short names, measured against the live edge: `/v1/billing/methods`
401, `/v1/billing/settings` 403, `/v1/billing/alerts` 403, while `payment-methods`,
`payment-config` and `spend-alerts` are all 404. The console never followed. Its card
reads, its card writes and its Square-config read were all addressed at routes that no
longer exist, so a new user could not add a card — the revenue path was broken in
production.
The client had already been repointed for alerts, so `payment-methods` (list, save,
detach) and `payment-config` were the ones still dead. They now build `methods` and
`settings`. There is deliberately no alias and no fallback: one name per concept.
The tests were part of the defect, not the safety net. Every suite around payment
methods stubbed a response body and asserted the normalization, so a client pointed at
a 404 stayed green — the exact reason this survived. The URL is now pinned where the
request is actually made, including the two reads nothing had ever asserted
(`methods`, `settings`) and `alerts` beside them. Reverting any of the four short names
turns the suite red, which was checked rather than assumed.
`POST /v1/billing/me/welcome` and everything feeding it is deleted, not repointed.
Commerce removed that route on purpose: it was a self-service mint, a browser could
grant its own org $5, and commerce's own `api/billing/mint_gates_test.go` names it the
TOCTOU double-mint. Credit is minted only through the mint-gated `POST
/v1/billing/credit`. The call had been failing silently, so restoring it would have
re-opened a closed money hole to fix nothing. The trial credit still lands — commerce
grants it server-side when a card is vaulted, and signup grants it server-side — and
that path is untouched. `src/lib/billing/welcome.ts` had no callers left at all.
Scope, checked rather than assumed: `/v1/finance/payment-methods` is still 401 (alive)
and `/v1/finance/methods` is 404, so the finance ledger keeps the compound name — a
blanket repo-wide rename would have broken it. The Billing Center's tab slugs
(`/billing/payment-methods`, `/billing/credits`) are console page URLs, not server
routes, and are unchanged.
Two headlines were lying about which layer failed. "Card top-up isn't available on this
deployment yet" and "Adding a card isn't available on this deployment yet" both fire
when the ORG has no Square `applicationId`/`locationId` — a per-organization
configuration, not a property of the deployment. Both now name the organization, as
does the onboarding step's "Payments aren't set up", which had the same defect. The
stale `GET /v1/billing/payment-config` endpoint hints under those cards now read
`settings`.
## The assistant has one home, and the app directory is one you can walk
Three fixes to the console's own chrome. Each root cause was measured in a browser
on computed style, geometry or where the browser lands — never inferred from source.
**The assistant lived in a third place.** `FloatingChat` owned every shape the
assistant can take (the sheet, the docked column, the dock state) except the way
in, which was two small buttons in the TOPBAR — a brand-H "Chat with Hanzo" and a
"Talk to Hanzo" mic — wedged between the search box and the org/theme/alert
cluster. On a 390px phone that put five controls in the header and squeezed the
search field to "Search or jump…". Both controls moved into `AssistantFab`, one
floating cluster fixed bottom-right, in the corner the assistant actually appears
in. Chat and voice are the same surface opened two ways, so they sit together.
Nothing about the assistant was rewritten: the FAB calls the same `openChat` /
`startVoice` the topbar called, and `open`/`toggle`/`ask` still drive it
programmatically (the Code hub's "Ask AI").
It is suppressed exactly where the assistant is already on screen — while the
sheet is open, on the pages that ARE a composer, and, at `lg+` only, while it is
docked as a column. That last half is a CSS media prop rather than a JS branch so
SSR and first paint agree, and it sits above the Developers dock (whose collapsed
bar is 44px and exists only at `lg+`).
**[BUG, measured] All products was a directory you could not walk.** The pane that
lists every Hanzo app — the sidebar's "All products", the one place the whole
catalog is browsable — rendered each app as an inert `XStack`: a plain `DIV` with
`role=null` and `cursor: auto`, no handler, no pointer affordance. Measured, not
read. The only live control in the row was the pin, so a user could curate the
sidebar but could not open anything from the list. The row now opens its app
through the shared `openProduct` — the ONE opener the sidebar, ⌘K and the category
pages already route through — and closes the pane behind it, because a directory is
not a destination. Pin stays a separate control on the same row and stops the press
from bubbling: curating never navigates, navigating never curates.
**[BUG] A pin made after sign-in was thrown away on the next reload.** Preferences
are read off `properties['hanzo.preferences']` in the IAM access token's claims — a
SNAPSHOT taken when that token was minted. An earlier lane fixed the case where the
snapshot is SILENT about a key. The other half was never closed: once a user has
saved anything, the next token CARRIES a snapshot, and the merge let it win over a
newer local write. So the second pin onward read as pinned and was gone after F5.
The merge is now told the ordering it was missing. `Account` carries the token's
own `iat`; the provider stamps `…prefs.<user>.writtenAt` when — and only when — the
SERVER acknowledges a write; `mergePrefs(cached, fromAccount, order)` lets the cache
win only when a confirmed write is newer than the snapshot. Last writer wins, and
both writers are now identifiable. A fresh device (no cache, no stamp) and a fresh
sign-in (token minted after the write) both still take the account wholesale, so
cross-device is preserved. Stamping only server-confirmed writes is what keeps this
from being localStorage impersonating a backend: a save that never landed earns
nothing and the account stays authoritative.
**Backend gap, named rather than papered over.** There is no READ for this
document. `PATCH /v1/ai/preferences` (hanzoai/ai `UpdatePreferences`) writes it to
the IAM user's `properties['hanzo.preferences']` and returns the merged result;
nothing serves a GET, so the token's snapshot is the only read the console has. The
smallest seam that removes the ordering problem entirely is `GET
/v1/ai/preferences` returning that property after the handler's existing
`refreshSessionUser` — the write path already does every part of it. Better still
is `GET/PATCH /v1/prefs` (hanzoai/cloud `apps/prefs`), the canonical cross-surface
plane, which answers 503 on api.hanzo.ai today.
**House rule: one filled CTA.** Counted by computed background luminance on the
rendered home, not by reading JSX: FIVE white-filled buttons competed — "Take the
tour", the getting-started card's active step, and all three `PrimaryActionTile`
CTAs (two of them saying "Get API key"). The same measurement now returns ONE: the
checklist's ACTIVE step, the thing to do next. The tour is a neutral aside beside its
dismiss, and the three tiles are neutral because they are PEERS — a menu of things
you can do, not a call to action, and three primaries are none.
**Verification.** `tsc --noEmit` clean; `vitest` 3175 passed / 8 skipped (256 files,
+6 ordering tests). RED→GREEN proven both ways: disabling `cacheIsNewer` turns the
two new ordering tests red and the browser test with it. `e2e/assistant-fab-and-apps.spec.ts`
(4 tests, 1440 and 390) asserts the control's BOX is in the bottom-right quadrant and
≥44px, that `.hz-topbar` carries no assistant control, that clicking an app in All
products LANDS on `/agents`, and that a pin survives a reload under a token whose
snapshot is an hour old. `e2e/chrome-brand-voice.spec.ts` was retargeted, not
deleted — every claim it made still holds, only the location moved.
Two spec gotchas worth keeping. `_session.ts`'s `b64` emitted plain base64; that is
fine while a forged payload is tiny, but `+`/`/` appear as soon as one grows (a
`properties` bag is enough) and a strict decoder rejects the token outright — the SDK
reports signed out and the app sits on its loader forever. It emits base64URL now,
which is what a JWT segment actually is. And the assistant's composer carries its own
mic with the same `Talk to Hanzo` label, mounted-but-hidden until the panel opens, so
a bare attribute locator matches that one first: scope to `getByTestId('assistant-fab')`.
+36 -1
View File
@@ -1,5 +1,5 @@
Hanzo Cloud Console (console2)
Copyright (c) Hanzo AI, Inc. Licensed BSD-3-Clause (see LICENSE).
Copyright (c) Hanzo AI, Inc. Licensed MIT OR Apache-2.0 (see LICENSE) per HIP-0137.
------------------------------------------------------------------------
Third-party attribution
@@ -39,3 +39,38 @@ Langfuse EE / commercial ("ee") code is neither used nor referenced.
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
------------------------------------------------------------------------
Vendored MIT-licensed code
------------------------------------------------------------------------
Portions of this software are derived from upstream MIT-licensed code. Those
copyright notices are retained here per the MIT License's terms; they were
previously carried in LICENSE, which is reserved for this project's own
BSD-3-Clause grant.
Copyright (c) 2020 Nate Wienert (Tamagui)
Copyright (c) 2015-present, Nicolas Gallagher. (react-native-web)
Copyright (c) 2015-present, Facebook, Inc. (react-native-web)
Copyright (c) 2021 Radix (Radix UI)
Copyright (c) 2017 Carmelo Pullara
Copyright (c) 2018 Framer B.V. (Framer Motion)
Copyright (c) 2022 WorkOS
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+3 -1
View File
@@ -48,4 +48,6 @@ the product-module registry, and the Providers surface). Endpoint reference in
## License
BSD-3-Clause. Copyright (c) 2026-present, Hanzo AI, Inc.
`MIT OR Apache-2.0` at your option — see [LICENSE](./LICENSE),
[LICENSE-MIT](./LICENSE-MIT), [LICENSE-APACHE](./LICENSE-APACHE).
Copyright (c) 2026-present, Hanzo AI, Inc. Estate-wide licensing standard: HIP-0137 (`hanzoai/hips`).
+6 -1
View File
@@ -3,11 +3,14 @@ import type { ReactNode } from 'react'
import { Preferences } from '~/lib/products/preferences'
import { Toast } from '~/components/ui/Toast'
import { Entry } from '~/entry/entry'
import { Host } from '~/entry/host'
/**
* The console entry, decomplected (see src/entry/). `Preferences` + `Toast` are the
* session-tier context: the stage RESOLVER reads the onboarding preference, and the
* onboard wizard + every module report through Toast — so they sit above the switch.
* `Host` answers the two effects `@hanzo/ui/product`'s state cards ask for (sign in,
* add credits), so every card below renders its affordance without being handed one.
* `Entry` computes ONE stage value from the session and renders EXACTLY one surface
* (sign-in · waitlist · org · onboard · dashboard).
*/
@@ -15,7 +18,9 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
return (
<Preferences>
<Toast>
<Entry>{children}</Entry>
<Host>
<Entry>{children}</Entry>
</Host>
</Toast>
</Preferences>
)
+10 -7
View File
@@ -21,15 +21,12 @@ import { ProductRoute } from '~/components/ProductRoute'
import { openProduct } from '~/lib/products/open'
import { useFavorites } from '~/lib/products/favorites'
import { useIsSuperAdmin } from '~/lib/auth/admin'
import { PageHeader } from '~/components/ui/PageHeader'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { ProductIcon } from '~/components/ui/ProductIcon'
import type { IconLike } from '~/components/ui/color'
import { useProductColors } from '~/lib/products/pins'
import { FadeIn } from '~/components/ui/FadeIn'
import { livingOverviewModule } from '~/components/products/overview/living/LivingOverviewModule'
import { ResourceOverview } from '~/components/products/overview/ResourceOverview'
import { ProductObservability } from '~/components/products/observability/ProductObservability'
import { FadeIn, PageHeader, type IconLike } from '@hanzo/ui/product'
// The home centerpiece is the reusable LivingOverview (count-up KPIs, live
// sparklines, streaming activity) — the SAME component every product overview uses.
@@ -148,13 +145,19 @@ function PrimaryActionTile({
{description}
</Text>
<XStack>
<PrimaryButton
{/* Neutral, not filled. These three tiles are PEERS — a menu of things you can
do, not a call to action — so three white buttons side by side gave the
screen three primaries and therefore none. The one filled action on this
page is the getting-started card's active step: the thing to do NEXT. */}
<Button
size="$3"
borderWidth={1}
borderColor="$borderColor"
iconAfter={external ? <ExternalLink size={15} /> : <ArrowRight size={15} />}
onPress={onPress}
>
{ctaLabel}
</PrimaryButton>
</Button>
</XStack>
</Card>
)
@@ -234,7 +237,7 @@ export default function DashboardHome() {
icon={HandCoins}
color={colorOf('authors')}
title="Earn from your OSS"
description="Earn 20% of the compute margin your open-source project drives when teams run it on Hanzo Cloud — paid to your Hanzo wallet."
description="Earn 20% of the compute margin your open-source project drives when organizations run it on Hanzo Cloud — paid to your Hanzo wallet."
ctaLabel="Start earning"
onPress={() => push('/authors')}
/>
+1 -1
View File
@@ -3,7 +3,7 @@
*
* `/v1/chat/completions` (and friends) REQUIRE an `Authorization: Bearer` token; a
* browser session cookie alone is rejected. Rather than ship the user's durable
* `hk-` key to the browser, the console calls its OWN origin at the canonical, prefix-free
* `sk-` key to the browser, the console calls its OWN origin at the canonical, prefix-free
* `/v1/<aihead>` (the /v1-first law); `next.config.mjs` dispatches those heads to THIS `/ai`
* proxy (re-rooting the upstream at `v1/` — invisible to the client). `forwardWithUserBearer`
* resolves the user, mints a SHORT-LIVED, user-bound IAM token (shared per-user cache in
-125
View File
@@ -1,125 +0,0 @@
/* Hanzo is monochrome. One hue rendered through an opacity ladder.
Base ladder = Tailwind neutral (tailwind.config.ts). Semantic names match
hanzo.ai's CSS variables exactly, so code copies over 1:1.
DARK IS THE DEFAULT THEME (hanzo.ai mounts ThemeProvider defaultTheme="dark"). */
:root{
/* ——— base neutral ladder ——— */
--neutral-50:#FAFAFA;
--neutral-100:#F5F5F5;
--neutral-200:#E5E5E5;
--neutral-300:#D4D4D4;
--neutral-400:#A3A3A3;
--neutral-500:#737373;
--neutral-600:#525252;
--neutral-700:#404040;
--neutral-800:#262626;
--neutral-900:#171717;
--neutral-950:#0A0A0A;
--pure-black:#000000;
--pure-white:#FFFFFF;
/* Press-kit brand constants (public/press/hanzo/README.md). */
--hanzo-black:#0A0A0B;
--hanzo-white:#FFFFFF;
/* ——— the opacity ladder: the real palette ——— */
--white-05:rgb(255 255 255 / .05);
--white-10:rgb(255 255 255 / .10);
--white-15:rgb(255 255 255 / .15);
--white-20:rgb(255 255 255 / .20);
--white-30:rgb(255 255 255 / .30);
--white-40:rgb(255 255 255 / .40);
--white-60:rgb(255 255 255 / .60);
--white-80:rgb(255 255 255 / .80);
/* ——— semantic aliases (dark, the default) ——— */
--background:#000000;
--foreground:#ededed;
--card:#0a0a0a;
--card-foreground:#f5f5f5;
--popover:#0a0a0a;
--popover-foreground:#f5f5f5;
--primary:#ffffff;
--primary-foreground:#000000;
--secondary:#1a1a1a;
--secondary-foreground:#f5f5f5;
--muted:#101010;
--muted-foreground:#888888;
--accent:#1a1a1a;
--accent-foreground:#f5f5f5;
--destructive:#666666;
--destructive-foreground:#f5f5f5;
--border:#1f1f1f;
--input:#1f1f1f;
--ring:#333333;
--brand:#e4e4e7;
--brand-foreground:#09090b;
--brand-muted:#a3a3a3;
--black:#000000;
--white:#f5f5f5;
/* ——— surface recipes (card fills used across hanzo.ai) ——— */
--surface-page:var(--background);
--surface-card:rgb(23 23 23 / .5); /* bg-neutral-900/50 — grid tiles */
--surface-card-emphasis:rgb(23 23 23 / .8);/* bg-neutral-900/80 — featured */
--surface-card-quiet:rgb(23 23 23 / .4); /* bg-neutral-900/40 — story cards */
--surface-overlay:rgb(10 10 10 / .95); /* dropdown / popover panels */
--surface-header:rgb(0 0 0 / .7); /* fixed nav, with backdrop blur */
--border-hairline:var(--neutral-800);
--border-card:var(--white-10);
--border-strong:var(--neutral-700);
/* ——— text ranks ——— */
--text-primary:var(--pure-white);
--text-secondary:var(--white-80);
--text-tertiary:var(--white-60);
--text-helper:var(--muted-foreground);
--text-disabled:var(--white-30);
/* ——— the ONLY permitted hues (DESIGN.md §2.4) ——— */
--state-error:#ef4444; /* red-500 — destructive / blocking error */
--state-error-text:#fca5a5; /* red-300 */
--state-error-bg:rgb(239 68 68 / .1);
--state-online:#4ade80; /* green-400 — live status dot */
--state-success:#22c55e; /* green-500 — "Free" / "Save N%" callouts */
--chrome-dot-red:rgb(239 68 68 / .6);
--chrome-dot-yellow:rgb(234 179 8 / .6);
--chrome-dot-green:rgb(34 197 94 / .6);
}
/* Light theme — the same tokens, inverted. Rare: only /brand-style docs pages. */
.light{
--background:#ffffff;
--foreground:#0a0a0a;
--card:#f5f5f5;
--card-foreground:#0a0a0a;
--popover:#ffffff;
--popover-foreground:#0a0a0a;
--primary:#0a0a0a;
--primary-foreground:#ffffff;
--secondary:#f5f5f5;
--secondary-foreground:#0a0a0a;
--muted:#f5f5f5;
--muted-foreground:#525252;
--accent:#f5f5f5;
--accent-foreground:#0a0a0a;
--destructive:#999999;
--destructive-foreground:#ffffff;
--border:#e5e5e5;
--input:#e5e5e5;
--ring:#d4d4d4;
--black:#0a0a0a;
--white:#ffffff;
--surface-card:#f5f5f5;
--surface-card-emphasis:#ffffff;
--surface-card-quiet:#fafafa;
--surface-overlay:rgb(255 255 255 / .95);
--surface-header:rgb(255 255 255 / .8);
--border-hairline:var(--neutral-200);
--border-card:rgb(0 0 0 / .1);
--border-strong:var(--neutral-300);
--text-primary:var(--neutral-950);
--text-secondary:rgb(10 10 10 / .8);
--text-tertiary:rgb(10 10 10 / .6);
--text-disabled:rgb(10 10 10 / .3);
}
-19
View File
@@ -1,19 +0,0 @@
/* Hanzo barely uses shadow: on black, elevation reads as a hairline border plus
a wide, very dark drop. Only two levels ship (Tailwind's shadow-2xl for
floating surfaces) plus the ambient radial glow used behind heroes. */
:root{
--shadow-none:none;
--shadow-floating:0 25px 50px -12px rgb(0 0 0 / .25); /* shadow-2xl: composer, dropdowns, mega panel */
--shadow-inset-hairline:inset 0 0 0 1px var(--white-10);
--ring-focus:0 0 0 2px var(--ring);
/* Ambient hero glow — a single white radial, blurred 120px, low opacity. */
--glow-hero:radial-gradient(circle,rgb(255 255 255 / .12) 0%,transparent 68%); /* @kind color */
--glow-hero-blur:120px;
/* Card top-corner sheen used on the story cards. */
--sheen-card:radial-gradient(120% 120% at 80% 0%,rgb(255 255 255 / .08) 0%,transparent 55%); /* @kind color */
/* Chrome text: the canonical headline gradient. Never a saturated rainbow. */
--gradient-chrome:linear-gradient(to right,#ffffff,var(--white-80),var(--white-60));
--gradient-chrome-2:linear-gradient(to right,#ffffff,var(--neutral-500));
/* Section-top protection gradient (hero overlays). */
--gradient-protect:linear-gradient(to bottom,var(--white-10),transparent);
}
+19 -21
View File
@@ -1,30 +1,28 @@
/* ─────────────────────────────────────────────────────────────────────────────
Hanzo Design System tokens — VENDORED from hanzoai/design (@hanzo/design).
Hanzo Design System tokens — the PUBLISHED @hanzo/design package.
This directory is the canonical Hanzo design language expressed as CSS custom
properties: the monochrome neutral ladder + opacity ladder, the ONLY permitted
semantic hues (live/success/warning/error), type scale, 4px spacing ramp,
radius, elevation and motion. It is the SINGLE SOURCE OF TRUTH for how every
Hanzo surface looks.
These were vendored under app/design/ (synced 2026-07-24) only because the
package was not yet on npm. It is now (@hanzo/design ≥ 0.4.6), so the console
reads the real dependency and can no longer drift a border rework behind the
rest of the fleet. The token subpaths are named one by one rather than pulling
`@hanzo/design/styles.css`: that entry chains relative `@import url(...)`s that
Next's CSS pipeline resolves as modules, not sibling files, so the explicit
published subpaths are the resolvable form of the same import.
@hanzo/design is not yet published to npm, so its token layer is vendored here
verbatim (the README's stated contract: "code copies over 1:1"). Do NOT edit
these files in the console — edit them in hanzoai/design and re-sync. Synced
from hanzoai/design tokens/ on 2026-07-24.
The console's Tamagui theme (app/globals.css) DERIVES its --colorN ladder and
borders from this neutral ladder, so the whole product reads from one palette.
Fonts are deliberately NOT imported from the package: the console loads the
Geist faces via app/fonts.css, and the `:root` shim below lets the vendored
typography roles resolve without a second copy.
───────────────────────────────────────────────────────────────────────────── */
@import './colors.css';
@import './typography.css';
@import './spacing.css';
@import './radius.css';
@import './elevation.css';
@import './motion.css';
@import './z.css';
@import '@hanzo/design/tokens/colors.css';
@import '@hanzo/design/tokens/typography.css';
@import '@hanzo/design/tokens/spacing.css';
@import '@hanzo/design/tokens/radius.css';
@import '@hanzo/design/tokens/elevation.css';
@import '@hanzo/design/tokens/motion.css';
@import '@hanzo/design/tokens/z.css';
/* Font families — the console loads the Geist faces via app/fonts.css; these vars
let the vendored typography roles (--type-*) resolve without re-importing fonts. */
let the typography roles (--type-*) resolve without re-importing fonts. */
:root {
--font-sans: 'Geist', 'Geist Sans', ui-sans-serif, system-ui, sans-serif;
--font-display: var(--font-sans);
-24
View File
@@ -1,24 +0,0 @@
/* Motion is restrained: fade + small rise, CSS-only hovers, one breathing glow.
No springs, no bounce, no parallax, no autoplay carousels. */
:root{
--duration-fast:150ms; /* @kind other */ /* dropdown / panel open */
--duration-base:300ms; /* @kind other */ /* slide-up-fade */
--duration-slow:400ms; /* @kind other */ /* hero element entry */
--duration-slower:500ms; /* @kind other */ /* section entry */
--duration-glow:9s; /* @kind other */ /* ambient radial breathe */
--ease-out:cubic-bezier(0,0,0.2,1); /* @kind other */
--ease-in-out:cubic-bezier(0.4,0,0.2,1); /* @kind other */
--stagger:60ms; /* @kind other */ /* per-element delay in a group */
--entry-rise:16px; /* hero y-offset */
--entry-rise-lg:24px; /* card y-offset */
}
@keyframes hanzo-fade-up{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
@keyframes hanzo-fade-down{from{opacity:0;transform:translateY(-10px)}to{opacity:1;transform:translateY(0)}}
@keyframes hanzo-slide-up-fade{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}
@keyframes hanzo-glow{0%,100%{transform:scale(1);opacity:.45}50%{transform:scale(1.08);opacity:.65}}
@keyframes hanzo-pulse-dot{0%,100%{opacity:1}50%{opacity:.35}}
@media (prefers-reduced-motion:reduce){
*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important}
}
-10
View File
@@ -1,10 +0,0 @@
:root{
--radius:0.5rem; /* the base token (globals.css) */
--radius-sm:0.375rem; /* rounded-md — buttons, inputs */
--radius-md:0.5rem;
--radius-lg:0.75rem; /* rounded-xl — cards */
--radius-xl:1rem; /* rounded-2xl — dropdown panels */
--radius-2xl:1.5rem; /* rounded-3xl — story / hero cards */
--radius-composer:28px; /* the chat composer, exactly 28px */
--radius-full:9999px; /* pills, CTAs, avatars, badges */
}
-45
View File
@@ -1,45 +0,0 @@
/* Spacing: the 4px Tailwind ramp is what ships. The golden-ratio ramp below is
declared in hanzo.ai's tailwind.config.ts (legacy v3 config, kept for
reference) — use it for editorial layouts, not for component padding. */
:root{
--space-0:0;
--space-1:0.25rem;
--space-2:0.5rem;
--space-3:0.75rem;
--space-4:1rem;
--space-5:1.25rem;
--space-6:1.5rem;
--space-8:2rem;
--space-10:2.5rem;
--space-12:3rem;
--space-14:3.5rem;
--space-16:4rem;
--space-20:5rem;
--space-24:6rem;
--space-32:8rem;
/* golden ramp (φ) — hanzo.ai tailwind.config.ts */
--golden-1:0.25rem;
--golden-2:0.405rem;
--golden-3:0.654rem;
--golden-4:1.059rem;
--golden-5:1.713rem;
--golden-6:2.772rem;
--golden-7:4.487rem;
--golden-8:7.26rem;
--golden-9:11.749rem;
--golden-split:38.2% 61.8%; /* @kind other */
/* layout rules (DESIGN.md §1.3) */
--container-max:80rem; /* max-w-7xl — grids */
--container-prose:48rem; /* max-w-3xl — centered text */
--container-wide:72rem; /* max-w-6xl — landing sections */
--gutter:1rem; /* px-4 */
--gutter-sm:1.5rem; /* sm:px-6 */
--gutter-lg:2rem; /* lg:px-8 */
--section-y:4rem; /* py-16 — content sections */
--section-y-lg:6rem; /* py-24 — landing sections */
--hero-y:5rem; /* py-20 … */
--hero-y-lg:8rem; /* … lg:py-32 */
--header-height:4rem;
}
-46
View File
@@ -1,46 +0,0 @@
/* TIGHT app-first type scale — the compact developer-app register (linear.app /
vercel.com / the Codex desktop look), the Hanzo default across chat / app /
desktop. Base is 14px, nav 13px, labels 11px; display sizes tightened. Kept in
lockstep with @hanzo/brand (styles/variables.css --font-size-* + typography.ts)
— the two are the SAME scale, mirrored. A surface/tenant overrides any --text-*
on :root to retune density on demand. */
:root{
--text-xs:0.6875rem; --leading-xs:1rem; /* 11px — eyebrows / section labels */
--text-sm:0.8125rem; --leading-sm:1.15rem; /* 13px — nav labels, dense body */
--text-base:0.875rem; --leading-base:1.35rem; /* 14px — base app text (was 16px) */
--text-lg:0.9375rem; --leading-lg:1.4rem; /* 15px */
--text-xl:1.0625rem; --leading-xl:1.55rem; /* 17px */
--text-2xl:1.3125rem; --leading-2xl:1.7rem; /* 21px */
--text-3xl:1.625rem; --leading-3xl:1.95rem; /* 26px */
--text-4xl:2rem; --leading-4xl:2.25rem; /* 32px */
--text-5xl:2.5rem; --leading-5xl:1.05; /* 40px */
--text-6xl:3.25rem; --leading-6xl:1; /* 52px */
--text-7xl:4rem; --leading-7xl:1; /* 64px */
--weight-normal:400;
--weight-medium:500;
--weight-semibold:600;
--weight-bold:700;
--tracking-tight:-0.025em;
--tracking-normal:0em;
--tracking-wide:0.025em;
--tracking-widest:0.1em; /* eyebrows / uppercase category labels */
--leading-none:1;
--leading-tight:1.25;
--leading-snug:1.375;
--leading-normal:1.5;
--leading-relaxed:1.625;
--leading-golden:1.618;
/* named roles */
--type-hero:600 var(--text-5xl)/1.05 var(--font-display);
--type-h2:700 var(--text-4xl)/var(--leading-4xl) var(--font-display);
--type-h3:600 var(--text-xl)/var(--leading-xl) var(--font-display);
--type-lead:400 var(--text-lg)/var(--leading-relaxed) var(--font-sans);
--type-body:400 var(--text-sm)/var(--leading-sm) var(--font-sans);
--type-caption:400 var(--text-xs)/var(--leading-xs) var(--font-sans);
--type-code:400 var(--text-sm)/var(--leading-relaxed) var(--font-mono);
--type-eyebrow:600 0.625rem/1 var(--font-sans);
}
-14
View File
@@ -1,14 +0,0 @@
/* Stacking order — the one z-index ladder. Layers are named by role, never by a
magic number, so a dropdown opened from the fixed header always sits above it
and nothing ever reaches for 9999. Below --z-raised is ordinary document flow. */
:root{
--z-base:0;
--z-raised:10; /* hover-lifted cards, sticky table headers */
--z-sticky:200; /* pinned section rails */
--z-header:300; /* the fixed site header */
--z-dropdown:400; /* menus, selects, comboboxes */
--z-overlay:500; /* dialog / sheet scrim */
--z-modal:600; /* dialogs, sheets, command palette */
--z-popover:700; /* popovers, tooltips (also when anchored in modals)*/
--z-toast:800; /* toasts / notifications — always on top */
}
+5 -5
View File
@@ -1,5 +1,5 @@
/**
* Per-user `hk-` Cloud API key — the SAME-ORIGIN console route (the fix for the
* Per-user `sk-` Cloud API key — the SAME-ORIGIN console route (the fix for the
* API-keys "sign in to manage API keys" / CORS crack).
*
* The browser calls this OWN-origin route (`/keys`) with just its first-party
@@ -7,19 +7,19 @@
* (`resolveUser`) and mints/reads/revokes the key through IAM as the confidential
* `hanzo-console` client (`identity.ts` `mintUserKey`/`getUserKey`/`revokeUserKey`,
* over IAM `mint-user-keys`/`get-user`/`revoke-user-keys` — the WORKING key path,
* verified live). No credential ever reaches the browser; the `hk-` secret is
* verified live). No credential ever reaches the browser; the `sk-` secret is
* returned ONLY by POST (show once).
*
* Why not `cloud.hanzo.ai/v1/iam/keys` (the old path): that is a DIFFERENT
* ORIGIN than console.hanzo.ai, so a browser `fetch` is blocked by CORS ("Failed to
* fetch") — and cloud-api's own keys handler 501s ("IAM client unset") on this
* deployment anyway. The IAM confidential-client mint the console already uses for
* `hk-` keys elsewhere (`app/ai` chat) is the ONE authoritative, same-origin,
* `sk-` keys elsewhere (`app/ai` chat) is the ONE authoritative, same-origin,
* always-working path — so the Org-Settings API-keys surface uses it too (DRY: the
* exact primitives from `identity.ts`, no new IAM plumbing).
*
* GET → { hasKey, keyPrefix, createdAt } (no secret)
* POST → { accessKey } (mint/rotate; full hk- shown ONCE)
* POST → { accessKey } (mint/rotate; full sk- shown ONCE)
* DELETE → { ok: true } (revoke; the old key stops working)
*/
import { type NextRequest, NextResponse } from 'next/server'
@@ -57,7 +57,7 @@ export async function GET(req: NextRequest) {
}
}
/** POST — mint (or rotate) the key. Returns the full `hk-` secret ONCE. */
/** POST — mint (or rotate) the key. Returns the full `sk-` secret ONCE. */
export async function POST(req: NextRequest) {
// CSRF: minting mutates (and is billable-adjacent) from the auto-sent cookie —
// refuse a cross-origin request before any work.
+5
View File
@@ -4,6 +4,11 @@ import '@hanzogui/core/reset.css'
// source of truth. Imported BEFORE globals.css so the console's Tamagui theme can
// derive its ladder from the design neutral/semantic tokens.
import './design/index.css'
// The motion/skeleton classes `@hanzo/ui/product` components emit (`skeleton`,
// `row`, `tnum`, `fade-up`, `drag`). Console's own markup still names the `hz-`
// prefixed twins in globals.css below; these are the package's, and without this
// import a DataTable's skeleton, row hover and tabular figures render unstyled.
import '@hanzo/ui/styles/motion.css'
import './globals.css'
import type { Metadata, Viewport } from 'next'
+11 -16
View File
@@ -1,6 +1,6 @@
/**
* Same-origin proxy to the cloud ML/training surface on hanzoai/ai (`/v1/train/*`,
* `/v1/ml/models`, and the fine-tuning broker `/v1/finetune/*`).
* Same-origin proxy to the cloud ML/training surface (`/v1/ml/models` and the
* fine-tuning broker `/v1/finetune/*`).
*
* The console's Training page calls its OWN origin (`/training/...`) with just the
* first-party session cookie; this server handler resolves the signed-in user from
@@ -11,16 +11,16 @@
* this is user-scoped (resolveUser), NOT the control-plane admin gate the `/paas`
* proxy uses. The cloud backend resolves the org from the token's `owner` claim (and
* the X-Org-Id the plain-REST train sub-service reads), so a caller can only ever
* touch their own org's jobs. `POST /v1/train/jobs` is billing-gated by the live
* ResourceMeter and returns 402 on an unfunded org — that status flows straight back
* so the UI can surface it honestly.
* touch their own org's jobs. `POST /v1/finetune/jobs` is billing-gated upstream and
* returns 402 on an unfunded org — that status flows straight back so the UI can
* surface it honestly.
*
* Why a Bearer and NOT the cookie (the fix for the "Not enabled" 403): cloud-api's
* `/v1/train/*` authorizes on a VALIDATED JWT principal and returns 403 "no validated
* `/v1/*` authorizes on a VALIDATED JWT principal and returns 403 "no validated
* principal" for a cookie-only call — the raw casibase session cookie is NOT a
* principal it accepts (only the sanitizer's cookie-token names or a Bearer). Minting
* the same user-bound token the `/v1` proxy uses is the ONE way a signed-in tenant
* reaches the train surface; the cookie is deliberately dropped upstream (it can't
* reaches this surface; the cookie is deliberately dropped upstream (it can't
* authenticate, and a cookie + JWT together risks the public-gateway 431).
*
* Least privilege: only the explicit ML/training sub-paths are forwarded; anything
@@ -44,14 +44,9 @@ const CLOUD_API_URL = trim(process.env.CLOUD_API_URL ?? 'http://cloud.hanzo.svc.
/** The exact `/v1/<...>` ML/training sub-paths the console is allowed to reach. */
const ALLOWED = new Set([
// mlsvc — the canonical training surface (task #40 ResourceMeter gates POST jobs).
'train/jobs',
'train/experiments',
// Model serving — the org's deployed kserve InferenceServices.
'ml/models',
// Real Kubeflow control-plane probe (which operators/CRDs are actually served).
// Read-only; 503 + body flows through so the UI can report a degraded plane.
'train/health',
// fine-tuning broker (custom-data runs, HF search) — sibling surface.
// fine-tuning broker (custom-data runs, HF search) — the ONE training door.
'finetune/jobs',
'finetune/job',
'finetune/cancel',
@@ -68,7 +63,7 @@ async function forward(req: NextRequest, path: string[]): Promise<NextResponse>
return NextResponse.json({ status: 'error', msg: 'Not found' }, { status: 404 })
}
// CSRF: `POST /train/jobs` mutates (and bills) from the auto-sent cookie — refuse a
// CSRF: `POST /finetune/jobs` mutates (and bills) from the auto-sent cookie — refuse a
// cross-origin one before any work (safe reads pass).
const csrf = csrfRefusal(req, 'casibase')
if (csrf) return csrf
@@ -82,7 +77,7 @@ async function forward(req: NextRequest, path: string[]): Promise<NextResponse>
}
// Mint a short-lived, user-bound Bearer (the SAME per-user cache the `/v1`
// proxy uses). cloud-api's `/v1/train/*` 403s a cookie-only call ("no validated
// proxy uses). cloud-api's `/v1/*` 403s a cookie-only call ("no validated
// principal"); a Bearer is the one credential it accepts. Fail CLOSED with 502 if
// the token can't be minted — never fall through to an unauthenticated forward.
let bearer: string
+1 -1
View File
@@ -10,7 +10,7 @@
* billing UI tab URLs (`/billing/reports`, `/billing/invoices`, …) — they differ at
* the FIRST path segment, so the tab slugs fall through to the SPA.
*
* Verbs: GET (reads: balance/usage/invoices/subscriptions/payment-methods, and the
* Verbs: GET (reads: balance/usage/invoices/subscriptions/methods, and the
* per-invoice PDF), POST (writes: top-up, alerts, save-a-method, cancel/
* reactivate a subscription), PATCH (edit a budget/spend-alert), DELETE (detach a
* saved payment method, remove a budget). Each is scoped to the caller's OWN org
Vendored
+5
View File
@@ -0,0 +1,5 @@
// Side-effect CSS imports (`import './globals.css'`, `import '@hanzogui/core/reset.css'`).
// The bundler owns them; TypeScript only needs to know the specifier resolves.
// TS7 (tsgo) errors on an unresolvable side-effect import (TS2882) where tsc stayed
// silent, so the declaration lives here — one place, every stylesheet.
declare module '*.css'
+24 -8
View File
@@ -27,12 +27,24 @@ export type SessionClaims = {
email?: string
displayName?: string
isAdmin?: boolean
/** The IAM user's property bag — where `hanzo.preferences` rides as a SNAPSHOT. */
properties?: Record<string, string>
/** When the token was minted (`iat`, seconds). Defaults to now; set it in the past
* to reproduce the production case where the snapshot predates a later write. */
issuedAt?: number
}
const b64 = (o: object): string => Buffer.from(JSON.stringify(o)).toString('base64')
/**
* base64URL — what a JWT segment actually is. Plain base64 was close enough while the
* payloads were tiny, but `+` and `/` appear as soon as one grows (a `properties` bag
* is enough), and a strict decoder rejects the token outright: the SDK reports signed
* out and the app sits on its loader forever.
*/
const b64 = (o: object): string =>
Buffer.from(JSON.stringify(o)).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
/** The default identity render specs run as — a hanzo-org admin. */
export const DEFAULT_CLAIMS: Required<SessionClaims> = {
export const DEFAULT_CLAIMS: Required<Omit<SessionClaims, 'properties' | 'issuedAt'>> = {
owner: 'hanzo',
name: 'z',
email: 'z@hanzo.ai',
@@ -40,20 +52,24 @@ export const DEFAULT_CLAIMS: Required<SessionClaims> = {
isAdmin: true,
}
/** An unsigned JWT whose payload carries the claims + a far-future `exp`. */
export function forgeToken(claims: Required<SessionClaims>): string {
const payload = { ...claims, sub: `${claims.owner}/${claims.name}`, exp: Math.floor(Date.now() / 1000) + 3600 }
/** An unsigned JWT whose payload carries the claims, an `iat` and a far-future `exp`. */
export function forgeToken(claims: SessionClaims): string {
const iat = claims.issuedAt ?? Math.floor(Date.now() / 1000)
const payload = { ...claims, sub: `${claims.owner}/${claims.name}`, iat, exp: iat + 86_400 }
return `${b64({ alg: 'none' })}.${b64(payload)}.x`
}
/** Seed tokens + gate keys and register the IAM endpoint mocks. */
export async function primeSession(page: Page, overrides: Partial<SessionClaims> = {}): Promise<void> {
const claims: Required<SessionClaims> = { ...DEFAULT_CLAIMS, ...overrides }
const claims: SessionClaims = { ...DEFAULT_CLAIMS, ...overrides }
await page.addInitScript(
({ org, token }: { org: string; token: string }) => {
try {
sessionStorage.setItem('hanzo_iam_access_token', token)
sessionStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3600_000))
// localStorage, not sessionStorage: the `@hanzo/iam` token store is shared
// across tabs (that IS the session), so seeding a per-tab area would leave
// the SDK reading an empty store and every primed spec signed out.
localStorage.setItem('hanzo_iam_access_token', token)
localStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3600_000))
localStorage.setItem('hanzo.console.org', org)
localStorage.setItem('hanzo.console.org.selected', '1')
localStorage.setItem(`hz_onboarding_done:${org}`, '1')
+25 -10
View File
@@ -1,10 +1,17 @@
/**
* The ONE account control, at the foot of the rail.
* The ONE account control, at the foot of the rail — and the ONE org switch.
*
* There used to be three: an org switcher at the top of the sidebar, an account
* popover at the bottom, and a third menu in the mobile drawer — with four ways
* to sign out between them. This spec pins the replacement: one control, both
* switchers, at the bottom, in a shell that ships no Tailwind.
* There used to be three account-ish menus: an org switcher at the top of the
* sidebar, an account popover at the bottom, and a third in the mobile drawer,
* with four ways to sign out between them. They became one control that answered
* BOTH "who am I" and "where am I".
*
* They have now been split again, but by QUESTION rather than by accident: the
* account control at the foot answers who you are (identity, team, personal
* settings, balance, the way out), and `ContextSwitcher` at the TOP-LEFT answers
* where you are (organization + project, together, beside the tenant's mark).
* So the cross-tenant reach is asserted against the context switcher below, and
* the account menu is asserted to no longer offer a tenant at all.
*
* Everything is asserted on computed style and geometry. The failure this guards
* against is a menu that is present in the DOM and unreadable — a library that
@@ -40,6 +47,9 @@ const accountTrigger = (page: Page) => page.getByTestId('nav-user').first()
/** The trigger inside the phone's account sheet — the last mount in the document. */
const drawerTrigger = (page: Page) => page.getByTestId('nav-user').last()
/** The org + project control at the top-left — the only thing that switches tenant. */
const contextTrigger = (page: Page) => page.getByTestId('switcher-context').first()
async function mountConsole(page: Page, seen: Scoped[]) {
// The standalone console reaches the cross-tenant list through its own gated
// `/admin/iam` proxy; the go:embed build reaches cloud's `/v1/iam` directly.
@@ -182,18 +192,23 @@ test.describe('account control', () => {
expect(typedInCaps).toEqual([])
})
test('the org switcher reaches a tenant the caller is not a member of', async ({ page }) => {
test('the context switcher reaches a tenant the caller is not a member of', async ({ page }) => {
const seen: Scoped[] = []
await mountConsole(page, seen)
await accountTrigger(page).click()
await page.locator('[role=menu]').waitFor()
// Tenancy is the TOP-LEFT control's job now, not the account menu's.
await contextTrigger(page).click()
// Acme is nobody's membership — it exists only in the cross-tenant list an
// admin may search. A memberships-only switcher could not offer it at all.
await page.getByLabel('Find an organization').fill('acme')
const acme = page.getByRole('option', { name: 'Acme Industrial' })
// `radiogroup`/`radio`, not `listbox`/`option`: @hanzo/gui's `role` union is
// React Native's a11y set, which carries `option` but NOT `listbox`.
const orgList = page.getByRole('radiogroup', { name: 'Organizations' })
const acme = orgList.getByRole('radio', { name: 'Acme Industrial' })
await acme.waitFor()
await expect(page.getByRole('option')).toHaveCount(1)
// Scoped to the ORG group — the same popover also lists projects, and a bare
// getByRole('radio') would silently count those too.
await expect(orgList.getByRole('radio')).toHaveCount(1)
await page.screenshot({ path: 'e2e-shots/account-menu-find-org.png', animations: 'disabled' })
+170
View File
@@ -0,0 +1,170 @@
/**
* e2e: the assistant's ONE entry point, and the All-products directory you can act in.
*
* Three claims, each measured in a real browser rather than inferred from source:
*
* 1. The assistant opens from a FLOATING bottom-right control, not from the header —
* asserted on GEOMETRY (the control's box is in the bottom-right quadrant of the
* viewport) and on the header carrying no assistant control at all.
* 2. Clicking an app in the All-products directory NAVIGATES to that app. This is the
* regression that matters: the rows rendered, hovered, and did nothing, so the
* directory looked interactive and was not. Asserted on where the browser LANDS.
* 3. A pin made in the directory survives a reload EVEN WHEN the identity token
* carries an older preferences snapshot — the exact production condition (the
* token is minted at sign-in; a pin made after it is not in it).
*
* Local dev server + mocked network; `primeSession` supplies the IAM-PKCE identity.
*
* Run: BASE_URL=http://localhost:4000 npx playwright test assistant-fab-and-apps
*/
import { test, expect, type Route, type Page } from '@playwright/test'
import { requireFixtureServer } from './_fixture'
import { primeSession } from './_session'
import { mkdirSync } from 'node:fs'
import { join } from 'node:path'
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
requireFixtureServer()
const SHOTS = join(process.cwd(), 'e2e-shots')
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations)(\/|$|\?)/
async function mock(route: Route) {
const req = route.request()
if (req.resourceType() === 'document') return route.continue()
const url = new URL(req.url())
const sameOrigin = url.origin === new URL(BASE_URL).origin
if (sameOrigin && !API_RE.test(url.pathname)) return route.continue()
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ status: 'ok', msg: '', data: [], data2: 0 }),
})
}
/** Sign in and land on `path`, waiting for the signed-in shell to have mounted. */
async function boot(page: Page, path = '/', claims?: Parameters<typeof primeSession>[1]) {
await page.route('**/*', mock)
await primeSession(page, claims)
await page.goto(`${BASE_URL}${path}`, { waitUntil: 'domcontentloaded' })
await expect(page.getByRole('button', { name: 'Ask Hanzo' })).toBeVisible({ timeout: 60_000 })
}
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
test('the assistant opens from the bottom-right, and the header carries no AI control', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await boot(page)
const fab = page.getByRole('button', { name: 'Ask Hanzo' })
const box = await fab.boundingBox()
expect(box).not.toBeNull()
// Bottom-right quadrant: the whole point of the relocation.
expect(box!.x).toBeGreaterThan(1440 / 2)
expect(box!.y).toBeGreaterThan(900 / 2)
// A comfortable target, not a hairline.
expect(box!.width).toBeGreaterThanOrEqual(44)
expect(box!.height).toBeGreaterThanOrEqual(44)
// The topbar itself holds no assistant control any more — it used to carry two
// (a brand-H "Chat with Hanzo" and a "Talk to Hanzo" mic) beside the search box.
const inTopbar = await page.evaluate(() =>
Array.from(document.querySelectorAll('.hz-topbar [aria-label]')).map((n) => n.getAttribute('aria-label') ?? ''),
)
expect(inTopbar).not.toHaveLength(0) // the topbar was found at all
expect(inTopbar.filter((l) => /Hanzo/i.test(l))).toHaveLength(0)
await page.screenshot({ path: join(SHOTS, 'assistant-fab-desktop.png') })
// It opens the SAME assistant surface.
await fab.click()
await expect(page.getByText('Assistant').first()).toBeVisible({ timeout: 15_000 })
await page.screenshot({ path: join(SHOTS, 'assistant-open-desktop.png') })
await ctx.close()
})
test('the assistant control is reachable on a phone and never scrolls the body sideways', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } })
const page = await ctx.newPage()
await boot(page)
const fab = page.getByRole('button', { name: 'Ask Hanzo' })
const box = await fab.boundingBox()
expect(box).not.toBeNull()
expect(box!.x + box!.width).toBeLessThanOrEqual(390)
expect(box!.y).toBeGreaterThan(844 / 2)
const [scrollW, clientW] = await page.evaluate(() => [
document.documentElement.scrollWidth,
document.documentElement.clientWidth,
])
expect(scrollW).toBe(clientW)
await page.screenshot({ path: join(SHOTS, 'assistant-fab-mobile.png') })
await ctx.close()
})
test('clicking an app in All products opens that app', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await boot(page)
await page.getByRole('button', { name: 'All products' }).first().click()
const row = page.getByRole('button', { name: 'Open Agents' })
await expect(row).toBeVisible({ timeout: 15_000 })
await page.screenshot({ path: join(SHOTS, 'all-products-desktop.png') })
await row.click()
// Where the browser LANDS is the claim — not that a handler fired.
await expect(page).toHaveURL(/\/agents$/, { timeout: 15_000 })
await ctx.close()
})
test('a pin made in All products survives a reload under a STALE token snapshot', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
// The production condition: the identity token was minted an hour ago and carries a
// preferences SNAPSHOT from then. Treating that snapshot as authoritative is what
// silently threw away every pin made since — the pin reads as pinned, and is gone
// after a reload.
const snapshot = { pins: [{ id: 'models', group: '' }], pinGroups: [] }
await boot(page, '/', {
properties: { 'hanzo.preferences': JSON.stringify(snapshot) },
issuedAt: Math.floor(Date.now() / 1000) - 3600,
})
const openDirectory = async () => {
await page.getByRole('button', { name: 'All products' }).first().click()
// "…to sidebar" / "…from sidebar" are the directory's own labels — the home page's
// Apps map carries a plain "Pin Agents", so the short form is ambiguous.
await expect(page.getByRole('button', { name: /Agents (to|from) sidebar/ })).toBeVisible({ timeout: 15_000 })
}
// The snapshot the token carries is what the sidebar starts from.
await openDirectory()
await page.getByRole('button', { name: 'Pin Agents to sidebar' }).click()
await expect(page.getByRole('button', { name: 'Remove Agents from sidebar' })).toBeVisible()
// Only a write the SERVER acknowledged earns the stamp that out-ranks the snapshot.
await expect
.poll(() => page.evaluate(() => localStorage.getItem('hanzo.console2.prefs.z.writtenAt')), { timeout: 10_000 })
.not.toBeNull()
await page.reload({ waitUntil: 'domcontentloaded' })
await expect(page.getByRole('button', { name: 'Ask Hanzo' })).toBeVisible({ timeout: 60_000 })
// Still pinned — the hour-old snapshot did not win. Asserted on what the user sees…
await openDirectory()
await expect(page.getByRole('button', { name: 'Remove Agents from sidebar' })).toBeVisible({ timeout: 15_000 })
// …and on what was actually kept (models from the snapshot, agents from the write).
const pins = await page.evaluate(() => {
const raw = JSON.parse(localStorage.getItem('hanzo.console2.prefs.z') ?? '{}')
return (raw.pins ?? []).map((p: { id: string }) => p.id)
})
expect(pins).toContain('agents')
expect(pins).toContain('models')
await ctx.close()
})
+10 -9
View File
@@ -1,12 +1,12 @@
/**
* e2e: two-tenant BILLING ISOLATION through the `/billing/*` proxy.
* e2e: two-tenant BILLING ISOLATION through the `/v1/billing/*` proxy.
*
* The proxy (app/billing/v1/[...path]/route.ts) resolves the billing subject from the
* The proxy (app/v1/billing/[...path]/route.ts) resolves the billing subject from the
* session server-side and pins the full subject-key set (user/userId/customerId) +
* the X-Org-Id header, so a tenant can only ever read its OWN commerce ledger. This
* spec proves that end-to-end against the LIVE proxy: two accounts in DIFFERENT orgs
* each fetch `/billing/subscriptions` (and `/payment-methods`), and we assert the
* two result sets are disjoint — neither tenant can see the other's rows.
* each fetch `/v1/billing/subscriptions` (and `/v1/billing/methods`), and we assert
* the two result sets are disjoint — neither tenant can see the other's rows.
*
* This is the regression guard for the IDOR RED found (the proxy previously pinned
* only `?user=` while commerce filters subscriptions on `?userId=`, so subscriptions
@@ -36,11 +36,12 @@ async function signIn(page: Page, email: string, password: string) {
await page.waitForLoadState('domcontentloaded')
}
/** Fetch a billing path through the same-origin DATA proxy (`/billing/v1/*`), as the
* signed-in browser. (`/billing/<slug>` without `v1/` is a UI tab, served by the SPA.) */
/** Fetch a billing path through the same-origin DATA proxy (`/v1/billing/*`), as the
* signed-in browser. (`/billing/<slug>` is a UI tab, served by the SPA — it differs at
* the FIRST path segment, so the two never collide.) */
async function billing(page: Page, path: string): Promise<{ status: number; ids: string[] }> {
return page.evaluate(async (p) => {
const res = await fetch(`/billing/v1/${p}`, { credentials: 'include', headers: { Accept: 'application/json' } })
const res = await fetch(`/v1/billing/${p}`, { credentials: 'include', headers: { Accept: 'application/json' } })
let ids: string[] = []
try {
const body = await res.json()
@@ -73,9 +74,9 @@ test.describe('billing is isolated per tenant through the proxy', () => {
await signIn(pageB, B.email, B.password)
// `invoices` is included because its row ids drive the per-invoice PDF URL
// (`/billing/v1/invoices/:id/pdf`) — proving the invoice list is tenant-isolated
// (`/v1/billing/invoices/:id/pdf`) — proving the invoice list is tenant-isolated
// proves a user can only ever build a PDF URL for their OWN org's invoices.
for (const path of ['subscriptions', 'payment-methods', 'invoices']) {
for (const path of ['subscriptions', 'methods', 'invoices']) {
const a = await billing(pageA, path)
const b = await billing(pageB, path)
+1 -1
View File
@@ -131,7 +131,7 @@ test.describe('Money/usage/o11y surface is fail-closed for anonymous (unauthenti
'/v1/billing/balance',
'/v1/billing/invoices',
'/v1/billing/usage',
'/v1/billing/payment-methods',
'/v1/billing/methods',
'/v1/billing/alerts',
'/v1/usage/summary',
'/v1/get-cloud-usages',
+1 -1
View File
@@ -40,7 +40,7 @@ const CANONICAL_IDS: string[] = JSON.parse(readFileSync(join(process.cwd(), 'e2e
* are NOT registry ids — they must resolve via SLUG_ALIASES to a real module (never
* a 404 blank). Auditing them here proves the alias map end-to-end against the real app.
*/
const ALIAS_SLUGS = ['traces', 'deploy', 'plans-pricing', 'wallets', 'model-catalog', 'fine-tuning', 'web-search', 'mlpipelines', 'kubeflow']
const ALIAS_SLUGS = ['traces', 'deploy', 'plans-pricing', 'wallets', 'model-catalog', 'fine-tuning', 'web-search']
const IDS: string[] = [...CANONICAL_IDS, ...ALIAS_SLUGS]
/** A global-admin (sees every surface) or a tenant customer (Dave/maxpower shape). */
+32 -20
View File
@@ -1,11 +1,12 @@
/**
* e2e: brand-forward chrome + voice — mocked-network render proof.
*
* The Chrome wave: the big floating chat CIRCLE was removed; the assistant now opens
* from the TOPBAR (a small brand-H "Chat with Hanzo" + a "Talk to Hanzo" mic), the
* top-left SidebarBrand renders the org's own logo (white-label), and the Developers
* dock is drag-resizable with a live "Create key". This spec proves all of it in a
* browser.
* The Chrome wave: the big floating chat CIRCLE was removed; the assistant opens from
* ONE control — a floating bottom-right cluster (a brand-H "Ask Hanzo" + a "Talk to
* Hanzo" mic, `AssistantFab`), NOT the topbar, which carries navigation and account
* chrome only. The top-left SidebarBrand renders the org's own logo (white-label), and
* the Developers dock is drag-resizable with a live "Create key". This spec proves all
* of it in a browser.
*
* Same harness as workbench.spec (the closest sibling): a LOCAL server with the
* network mocked. `primeSession` seeds the IAM-PKCE identity AND the first-run gates
@@ -13,7 +14,7 @@
* real-shaped ledger rows for the dock's Overview, `/v1/models` → a small catalog for
* the assistant's model list; everything else → an empty-ok envelope.
*
* Voice gotcha: headless chromium ships NO webkitSpeechRecognition, so the topbar mic
* Voice gotcha: headless chromium ships NO webkitSpeechRecognition, so the mic
* (rendered only when `voiceSupported()`) would be absent for an environment reason,
* not a code one. A tiny, inert Web Speech stub is injected BEFORE load
* (`installVoiceStub`) so `voiceSupported()` is deterministically true and the mic
@@ -107,20 +108,20 @@ function installVoiceStub(page: Page) {
})
}
/** Prime + navigate; the topbar brand-H is on EVERY viewport, so it is the mount signal. */
/** Prime + navigate; the floating brand-H is on EVERY viewport, so it is the mount signal. */
async function openHome(page: Page, waitForMount = true) {
await installVoiceStub(page)
await page.route('**/*', mock)
await primeSession(page)
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' })
if (waitForMount) {
await expect(page.locator('[aria-label="Chat with Hanzo"]').first()).toBeVisible({ timeout: 20_000 })
await expect(page.locator('[aria-label="Ask Hanzo"]').first()).toBeVisible({ timeout: 20_000 })
}
}
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
test('the floating circle is gone; the topbar carries chat + voice, the sidebar brand + docked assistant + Developers dock work', async ({ browser }) => {
test('one floating control carries chat + voice; the topbar carries neither; sidebar brand + docked assistant + Developers dock work', async ({ browser }) => {
// laptop (≥ lg 1024): the persistent sidebar, the Developers dock, and the docked
// assistant column are all present (they are desktop-only concerns).
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } })
@@ -130,10 +131,18 @@ test('the floating circle is gone; the topbar carries chat + voice, the sidebar
// 1. The OLD floating circle is GONE — the bubble that covered page content.
await expect(page.locator('[aria-label="Open AI assistant"]')).toHaveCount(0)
// 2. Topbar: the small brand-H "Chat with Hanzo" AND the "Talk to Hanzo" mic, both
// visible (the mic renders because the Web Speech stub makes voiceSupported() true).
await expect(page.locator('[aria-label="Chat with Hanzo"]').first()).toBeVisible()
await expect(page.locator('[aria-label="Talk to Hanzo"]').first()).toBeVisible()
// 2. ONE floating control, bottom-right: the brand-H "Ask Hanzo" AND the "Talk to
// Hanzo" mic (the mic renders because the Web Speech stub makes voiceSupported()
// true) — and the topbar carries no assistant control at all. The two used to live
// up there beside the search box, which put the assistant in a third place.
// Scoped to the control itself: the assistant's own composer carries a mic with
// the same label, mounted-but-hidden until the panel opens, so a bare
// `[aria-label="Talk to Hanzo"]` matches that one first and reads "hidden".
const fab = page.getByTestId('assistant-fab')
await expect(fab.locator('[aria-label="Ask Hanzo"]')).toBeVisible()
await expect(fab.locator('[aria-label="Talk to Hanzo"]')).toBeVisible()
await expect(page.locator('.hz-topbar [aria-label="Ask Hanzo"]')).toHaveCount(0)
await expect(page.locator('.hz-topbar [aria-label="Talk to Hanzo"]')).toHaveCount(0)
// 3. The top-left SidebarBrand renders the org logo / BrandMark (an <img> or <svg>).
const brand = page.locator('[aria-label*="right-click for brand menu"]').first()
@@ -147,15 +156,18 @@ test('the floating circle is gone; the topbar carries chat + voice, the sidebar
await expect(page.locator('[title="Drag to resize"]').first()).toBeVisible()
await expect(page.locator('text=Create key').first()).toBeVisible({ timeout: 15_000 })
// 5. Clicking "Chat with Hanzo" opens the DOCKED assistant surface — the "Assistant"
// header + its Undock control appear (uniquely the docked panel at lg+).
await page.locator('[aria-label="Chat with Hanzo"]').first().click()
// 5. Clicking "Ask Hanzo" opens the DOCKED assistant surface — the "Assistant"
// header + its Undock control appear (uniquely the docked panel at lg+). The
// floating control then steps aside: at lg+ the docked column IS the assistant,
// so keeping a button to open it on top of itself would be a second way in.
await fab.locator('[aria-label="Ask Hanzo"]').click()
await expect(page.locator('[aria-label^="Undock"]').first()).toBeVisible({ timeout: 15_000 })
await expect(page.getByText('Assistant', { exact: true }).filter({ visible: true }).first()).toBeVisible()
await expect(page.locator('[aria-label="Ask Hanzo"]')).toHaveCount(0)
// 6. The mic is wired: "Talk to Hanzo" → startVoice → the conversation opens the
// recognition (voiceSignal effect → voice.start() → the stub records the call).
await page.locator('[aria-label="Talk to Hanzo"]').first().click()
// 6. The mic is wired: "Talk to Hanzo" (now the open conversation's own) → the
// recognition opens (voice.start() → the stub records the call).
await page.locator('[aria-label="Talk to Hanzo"]').filter({ visible: true }).first().click()
await expect
.poll(() => page.evaluate(() => (window as unknown as { __voiceStarted?: number }).__voiceStarted ?? 0), { timeout: 15_000 })
.toBeGreaterThan(0)
@@ -179,7 +191,7 @@ test('renders across breakpoints with no horizontal body scroll on a phone; scre
// (real render, or an honest blank shell if the sandbox can't paint the SPA).
await openHome(page, false)
await page
.locator('[aria-label="Chat with Hanzo"]')
.locator('[aria-label="Ask Hanzo"]')
.first()
.waitFor({ state: 'visible', timeout: 20_000 })
.catch(() => {})
+10 -10
View File
@@ -14,7 +14,7 @@
*
* Run:
* HANZO_PASSWORD=xxx pnpm e2e
* HANZO_PASSWORD=xxx HANZO_API_KEY=hk-xxx pnpm e2e
* HANZO_PASSWORD=xxx HANZO_API_KEY=sk-xxx pnpm e2e
*/
import { test, expect, type Page } from '@playwright/test'
@@ -151,15 +151,15 @@ test.describe('Hanzo Cloud Console e2e', () => {
if (needsCreate) {
await createBtn.click()
// One-time reveal card with the hk- key
await expect(page.locator('text=/hk-/')).toBeVisible({ timeout: 25_000 })
// One-time reveal card with the sk- key
await expect(page.locator('text=/sk-/')).toBeVisible({ timeout: 25_000 })
await expect(page.locator('text=/shown only once/i')).toBeVisible()
await expect(page.locator('button:has-text("Copy")')).toBeVisible()
console.log('✓ API key created (hk- one-time reveal shown)')
console.log('✓ API key created (sk- one-time reveal shown)')
} else {
// Key already exists
await expect(hasKey).toBeVisible({ timeout: 10_000 })
await expect(page.locator('text=/hk-…|hk-[A-Za-z0-9]{3,}/i')).toBeVisible({ timeout: 5_000 })
await expect(page.locator('text=/sk-…|sk-[A-Za-z0-9]{3,}/i')).toBeVisible({ timeout: 5_000 })
console.log('✓ API key already exists (prefix shown)')
}
})
@@ -183,19 +183,19 @@ test.describe('Hanzo Cloud Console e2e', () => {
} else if (await createBtn.isVisible({ timeout: 1_000 }).catch(() => false)) {
await createBtn.click()
}
await expect(page.locator('text=/hk-/')).toBeVisible({ timeout: 25_000 })
await expect(page.locator('text=/sk-/')).toBeVisible({ timeout: 25_000 })
// Grab the FULL key from the one-time reveal — never the masked display
// (the account card shows `hk-2f18…` with an ellipsis, which is not a
// usable credential). Match only a full hk- token (no `…`/`...`).
const fullKey = /hk-[A-Za-z0-9._-]{16,}/
// (the account card shows `sk-2f18…` with an ellipsis, which is not a
// usable credential). Match only a full sk- token (no `…`/`...`).
const fullKey = /sk-[A-Za-z0-9._-]{16,}/
const keyEl = page.locator('[style*="monospace"]').filter({ hasText: fullKey }).first()
apiKey = (((await keyEl.textContent().catch(() => '')) ?? '').match(fullKey) ?? [''])[0]
if (!apiKey) {
const m = ((await page.textContent('body')) ?? '').match(fullKey)
apiKey = m ? m[0] : ''
}
expect(apiKey, 'Could not extract hk- key from page').toMatch(/^hk-/)
expect(apiKey, 'Could not extract sk- key from page').toMatch(/^sk-/)
console.log(`✓ Extracted key prefix: ${apiKey.slice(0, 11)}`)
}
+64
View File
@@ -0,0 +1,64 @@
/**
* The OAuth return raises EXACTLY ONE toast.
*
* A unit test cannot see this bug. It is a render loop: the toast provider built
* its context value fresh on every render and used it as the value, so every
* useToast() consumer got a new identity whenever a toast was added — and the
* integrations effect both DEPENDS on the toast api and RAISES a toast. Raising
* one re-rendered the provider, which handed the effect a new api, which raised
* another. Live this stacked ~15 identical "Connected slack" cards down the
* viewport. Stripping the query params could not stop it: router.replace is
* asynchronous, so the params are still readable on the renders in between.
*
* So the assertion is a COUNT after the loop has had time to run, on the real
* rendered DOM — the only place the defect exists.
*/
import { test, expect } from '@playwright/test'
import { primeSession } from './_session'
const PROVIDERS = [
{
id: 'slack',
name: 'Slack',
description: 'Post messages and receive events in your Slack workspace.',
category: 'Communication',
available: true,
connected: true,
connection: { account: 'The Foundation', connectedAt: '2026-08-05T00:16:49Z' },
},
]
test.describe('integrations OAuth return', () => {
test.beforeEach(async ({ page }) => {
// Everything else answers empty so the module mounts standalone.
await page.route('**/v1/**', async (route) => {
const url = route.request().url()
if (url.includes('/v1/integrations')) {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(PROVIDERS) })
}
return route.fulfill({ status: 200, contentType: 'application/json', body: '{"data":[]}' })
})
await primeSession(page)
})
test('a connected= return raises exactly one toast', async ({ page }) => {
await page.goto('/integrations?connected=slack&account=The+Foundation')
const toasts = page.getByText('Connected slack')
await expect(toasts.first()).toBeVisible({ timeout: 15_000 })
// Give the loop every chance to run: the effect re-fires on each provider
// re-render, and the pre-fix build had stacked well past a dozen by now.
await page.waitForTimeout(3_000)
expect(await toasts.count()).toBe(1)
await page.screenshot({ path: 'e2e-shots/integrations-one-toast.png', fullPage: false })
})
test('the callback params are stripped so a reload cannot replay it', async ({ page }) => {
await page.goto('/integrations?connected=slack&account=The+Foundation')
await expect(page.getByText('Connected slack').first()).toBeVisible({ timeout: 15_000 })
await expect.poll(() => new URL(page.url()).search, { timeout: 10_000 }).toBe('')
})
})
+1 -1
View File
@@ -134,7 +134,7 @@ test.describe('LIVE v8.4.15 — (a) business board + (c) billing dimension', ()
test('(c) billing Reports renders the cost-dimension surface', async ({ page }) => {
await signIn(page, CONSOLE)
// v8.4.16: the data proxy moved to /billing/v1/*, so /billing/reports now falls
// The data proxy lives at /v1/billing/*, so /billing/reports now falls
// through to the SPA (was shadowed by the /billing/[...path] proxy → raw JSON).
// A hard deep-link must render the Reports UI, not a proxy "not found".
await page.goto(`${CONSOLE}/billing/reports`, { waitUntil: 'domcontentloaded' })
+118
View File
@@ -0,0 +1,118 @@
/**
* Onboarding — Continue never moves, and Skip is always reachable.
*
* The complaint this pins: the Continue button landed at a different height on
* every step, so a user clicking through had to re-aim each time. StepActions was
* the LAST CHILD of a flex column, so its y was whatever the step's content
* happened to add up to. It is now a SLOT on StepShell above a content area with
* a reserved height — one placement, decided in one place.
*
* This is a GEOMETRY assertion on purpose. The JSX move is invisible to a unit
* test (both shapes render the same button with the same label); only the painted
* box says whether the thing the user complained about is fixed.
*/
import { test, expect, type Page } from '@playwright/test'
import { primeSession } from './_session'
/** Every step whose footer must line up, in flow order. */
const STEPS = ['Secure your account', 'Data & consent', 'Your organization', 'Free trial credits', 'AI access']
/** The y of the actions row, in page coordinates. */
async function actionsY(page: Page): Promise<number> {
const row = page.getByTestId('onboarding-actions')
await expect(row).toBeVisible()
const box = await row.boundingBox()
if (!box) throw new Error('actions row has no box')
return Math.round(box.y)
}
/** Advance past the current step, preferring Skip so the flow stays clickable. */
async function advance(page: Page): Promise<void> {
const row = page.getByTestId('onboarding-actions')
const skip = row.getByRole('button', { name: /^(Skip|Keep the default)/ })
if (await skip.count()) {
await skip.first().click()
return
}
// Consent has no Skip by design (accepting Terms is not optional), so tick the
// agreement and use Continue. Tick only if Continue is still disabled — a caller
// may already have ticked it, and toggling twice turns it back OFF.
const cont = row.getByRole('button', { name: /Continue/ })
if (await cont.isDisabled()) {
const agree = page.locator('[role="switch"], input[type="checkbox"]').first()
if (await agree.count()) await agree.click()
}
await cont.click()
}
test.beforeEach(async ({ page }) => {
// Anything the steps reach for answers empty — they are best-effort and must
// still render. Registered BEFORE primeSession so its handlers win.
await page.route('**/v1/**', (r) => r.fulfill({ status: 200, contentType: 'application/json', body: '{}' }))
await primeSession(page)
// primeSession marks onboarding DONE so other specs can reach the app. This
// spec is about the wizard, so un-mark it (the tour gate stays seeded).
await page.addInitScript(() => {
for (const k of Object.keys(localStorage)) if (k.startsWith('hz_onboarding_done:')) localStorage.removeItem(k)
})
})
test('Continue lands at the same height on every step', async ({ page }) => {
await page.goto('/')
const seen: { step: string; y: number }[] = []
for (const step of STEPS) {
await expect(page.getByTestId('onboarding-step-title')).toHaveText(step, { timeout: 15_000 })
seen.push({ step, y: await actionsY(page) })
await advance(page)
}
const ys = seen.map((s) => s.y)
const spread = Math.max(...ys) - Math.min(...ys)
expect(
spread,
`Continue moved ${spread}px across steps — ${seen.map((s) => `${s.step}:${s.y}`).join(' ')}`,
).toBeLessThanOrEqual(2)
})
test('every step always offers an enabled way forward', async ({ page }) => {
await page.goto('/')
// The real invariant behind "skip so easy to click through": on every step there
// is ALWAYS at least one enabled control that advances you — Skip where there is
// something to decline, Continue where there is not. Credits swaps between the
// two on purpose (Skip appears only when a card could be added; otherwise
// Continue carries you), so asserting a literal "Skip" everywhere would be
// asserting the wrong thing. Being STUCK is the defect.
for (const step of STEPS) {
await expect(page.getByTestId('onboarding-step-title')).toHaveText(step, { timeout: 15_000 })
// Consent gates Continue on accepting the Terms — not optional, so tick it
// first and then assert the way forward exists.
if (step === 'Data & consent') {
const agree = page.locator('[role="switch"], input[type="checkbox"]').first()
if (await agree.count()) await agree.click()
}
const row = page.getByTestId('onboarding-actions')
const forward = row.getByRole('button', { name: /^(Skip|Keep the default|Continue)/ })
const n = await forward.count()
expect(n, `${step} renders no forward control`).toBeGreaterThan(0)
let usable = 0
for (let i = 0; i < n; i++) {
const b = forward.nth(i)
if (await b.isDisabled()) continue
const box = await b.boundingBox()
if (!box || box.height < 24) continue
const hit = await b.evaluate((el) => {
const r = el.getBoundingClientRect()
return el.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2))
})
if (hit) usable++
}
expect(usable, `${step} has no enabled, clickable way forward`).toBeGreaterThan(0)
await advance(page)
}
})
-1
View File
@@ -18,7 +18,6 @@
"agents",
"inference",
"finetuning",
"ml-pipelines",
"embeddings",
"evals",
"gpus",
+3 -3
View File
@@ -128,11 +128,11 @@ async function mock(route: Route) {
async function openPolicy(page: Page, marker = 'Enabled models') {
await page.addInitScript((org) => {
try {
// A valid @hanzo/iam session: a non-expired access token in sessionStorage so the
// A valid @hanzo/iam session: a non-expired access token in localStorage so the
// SDK's getValidAccessToken() returns it (userinfo is network-mocked to CLAIMS).
// Without a future `expires_at` the SDK treats the token as expired → anonymous.
sessionStorage.setItem('hanzo_iam_access_token', 'mock-access-token')
sessionStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3600000))
localStorage.setItem('hanzo_iam_access_token', 'mock-access-token')
localStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3600000))
localStorage.setItem('hanzo.console.org', org)
// Scope shows the org PICKER until an org is explicitly entered — the scope
+48 -8
View File
@@ -1,4 +1,4 @@
import { readdirSync, readFileSync } from 'node:fs'
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
@@ -7,12 +7,16 @@ import { resolveBuildId, readGitSha } from './src/config/build-id.mjs'
/**
* Hanzo Cloud Console — Next.js config.
*
* Hanzo GUI is consumed at runtime (no optimizing compiler): the published
* `@hanzogui/next-plugin` has a broken npm dependency (`hanzogui-loader@7.3.0`
* is unpublished; the available fork renames its exports), so we transpile the
* Gui ESM packages with Next's built-in `transpilePackages` and let
* `GuiProvider` inject CSS at runtime. Gui is designed to work this way — the
* compiler is an optimization, not a requirement.
* Hanzo GUI is consumed at runtime (no optimizing compiler): we transpile the Gui
* ESM packages with Next's built-in `transpilePackages` and let `GuiProvider`
* inject CSS at runtime. Gui is designed to work this way — the compiler is an
* optimization, not a requirement.
*
* (The original reason to avoid `@hanzogui/next-plugin` no longer holds: the loader
* it depends on was unpublished at 7.3.0, but 8.x renamed it to `@hanzogui/loader`
* and both now ship. Adopting the compiler is therefore a live option — as an
* optimization to measure, not a correctness fix, so it is deliberately not bundled
* into the 8.x convergence.)
*
* `react-native` is aliased to `react-native-web` for the browser.
*
@@ -46,6 +50,9 @@ function guiPackages() {
return ['@hanzo/gui', '@hanzo/iam-js-sdk', '@hanzo/dash', '@hanzo/data', '@hanzo/canvas', '@hanzo/finance-ui', '@hanzo/usage', '@hanzo/ui', 'react-native-web', ...scoped]
}
/** A `@hanzogui/<pkg>/<subpath>/index.{js,cjs}` metro-compat shim (see `webpack()`). */
const GUI_SUBPATH_SHIM = /\/@hanzogui\/([^/]+)\/([^/]+)\/index\.c?js$/
/**
* Same-origin `/v1/*` — ZERO client-visible prefix (the CTO contract: "no prefix
* before /v1/ in any API call"). The browser ALWAYS calls its OWN origin at a clean
@@ -206,7 +213,40 @@ const nextConfig = {
experimental: {
esmExternals: true,
},
webpack(config) {
webpack(config, { webpack }) {
// `@hanzogui/*` 8.x ships legacy metro-compat subpath DIRECTORIES (`config/v5/`,
// `themes/v5/`, `shorthands/v5/`, …) beside the `exports` map that already names
// the real entry. Each holds a CommonJS `index.js` — inside a `"type": "module"`
// package. Whatever resolves the directory therefore parses that file as ESM: the
// `export *` chain goes opaque ("'defaultConfig' is not exported from
// '@hanzogui/config/v5'") and its bare `require('../dist/cjs/v5.cjs')` survives
// into the server chunk, where it MODULE_NOT_FOUNDs at prerender (the require is
// relative to `.next/server/chunks/`, not to the package).
//
// So redirect any such shim to the ESM build sitting beside it. Pattern-based, on
// the RESOLVED file, so it holds however the request got there — and costs nothing
// the day the shims stop shipping.
config.plugins.push(
new webpack.NormalModuleReplacementPlugin(GUI_SUBPATH_SHIM, (data) => {
const resource = data.createData?.resource
if (!resource) return
const shim = GUI_SUBPATH_SHIM.exec(resource)
if (!shim) return
const esm = `${resource.slice(0, shim.index)}/@hanzogui/${shim[1]}/dist/esm/${shim[2]}.mjs`
if (!existsSync(esm)) return
// The module's CONTEXT must move with it, or its own relative imports
// (`./v5-base.mjs`) keep resolving against the shim directory.
data.createData.resource = esm
data.createData.userRequest = esm
data.createData.context = dirname(esm)
data.context = dirname(esm)
}),
)
// @hanzo/ui is consumed from SOURCE via a workspace link. Keep the symlinked path
// so its own imports (@hanzo/gui, @hanzogui/*) walk up into the CONSOLE's
// node_modules — one Tamagui instance, as the tsconfig `paths` already pin for
// types. Resolving the realpath would load a second copy and break theme context.
config.resolve.symlinks = false
config.resolve.alias = {
...config.resolve.alias,
'react-native$': 'react-native-web',
+12019
View File
File diff suppressed because it is too large Load Diff
+10 -9
View File
@@ -1,11 +1,11 @@
{
"name": "@hanzo/console",
"version": "8.5.35",
"version": "8.5.50",
"packageManager": "pnpm@11.17.0",
"private": true,
"license": "BSD-3-Clause",
"license": "MIT OR Apache-2.0",
"author": "Hanzo AI <dev@hanzo.ai>",
"description": "Hanzo Cloud Console unified admin console for Hanzo Cloud and all cloud products.",
"description": "Hanzo Cloud Console \u2014 unified admin console for Hanzo Cloud and all cloud products.",
"scripts": {
"dev": "next dev -p 4000",
"build": "next build",
@@ -14,26 +14,27 @@
"typecheck": "tsc --noEmit",
"test": "vitest run",
"e2e": "playwright test",
"e2e:headed": "playwright test --headed",
"postinstall": "patch-package"
"e2e:headed": "playwright test --headed"
},
"dependencies": {
"@hanzo/brand": "^1.4.5",
"@hanzo/canvas": "^0.2.1",
"@hanzo/dash": "^0.3.0",
"@hanzo/data": "^1.2.2",
"@hanzo/design": "^0.4.6",
"@hanzo/event": "^0.3.8",
"@hanzo/finance-ui": "~0.1.1",
"@hanzo/gui": "^8.0.0",
"@hanzo/iam": "^0.21.2",
"@hanzo/iam": "^0.21.6",
"@hanzo/logo": "^1.0.14",
"@hanzo/ui": "^8.0.38",
"@hanzo/ui": "^8.0.56",
"@hanzo/usage": "^0.1.6",
"@hanzogui/config": "^8.0.0",
"@hanzogui/core": "^8.0.0",
"@hanzogui/lucide-icons-2": "^8.0.0",
"@hanzogui/next-theme": "^8.0.0",
"@hanzogui/shell": "^8.0.1",
"@hanzogui/telemetry": "^8.0.0",
"@hanzogui/shell": "^8.1.1",
"@lexical/html": "0.46.0",
"@lexical/link": "0.46.0",
"@lexical/list": "0.46.0",
@@ -53,6 +54,7 @@
"qrcode.react": "4.2.0",
"react": "19.2.7",
"react-dom": "19.2.7",
"react-native-svg": "15.15.5",
"react-native-web": "0.21.2",
"superjson": "2.2.2"
},
@@ -61,7 +63,6 @@
"@types/node": "22.20.0",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"patch-package": "^8.0.0",
"react-native": "0.83.9",
"typescript": "^5.9.3",
"vitest": "3.2.4"
+618 -264
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -195,8 +195,8 @@ try {
stdio: 'inherit',
// CONSOLE_EMBED gates the server-side build transforms; NEXT_PUBLIC_CONSOLE_EMBED
// is inlined into the CLIENT bundle so runtime code (lib/embed.ts → IS_EMBED) can
// skip the BFF-only session probes (/auth/refresh|session, /billing welcome) that
// don't exist in this static, server-less deployment.
// skip the BFF-only session probes (/auth/refresh|session) that don't exist in
// this static, server-less deployment.
env: { ...process.env, CONSOLE_EMBED: '1', NEXT_PUBLIC_CONSOLE_EMBED: '1' },
})
+48
View File
@@ -0,0 +1,48 @@
/**
* Asks the RENDERER which props @hanzo/gui 8 actually honors.
*
* gui accepts any prop and drops the ones it does not know, so a gui-7 `tag="a"`
* type-checks, builds, and ships a <div>: the link is inert and nothing anywhere says
* so. A green build cannot answer this; only the rendered markup can. Every rule in
* `src/lib/gui8-props.ts` was verified here before it was written down.
*
* gui injects its stylesheet as a leading <style>, so read the host element from the
* marked child — never from the first tag in the string.
*
* node scripts/gui-prop-probe.mjs (slow: it resolves ~184 unbundled ESM packages)
*/
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { defaultConfig } from '@hanzogui/config/v5'
import { XStack, Text, createGui, GuiProvider } from '@hanzo/gui'
const config = createGui(defaultConfig)
const render = (el) =>
renderToStaticMarkup(React.createElement(GuiProvider, { config, defaultTheme: 'dark' }, el))
/** The element carrying our marker id — not gui's injected <style>. */
const marked = (markup) => markup.match(/<([a-z]+)[^>]*\bid="probe"[^>]*>/)?.[0] ?? ''
const hostOf = (markup) => marked(markup).match(/^<([a-z]+)/)?.[1] ?? '(not found)'
const probe = (label, Comp, props) => {
const markup = render(React.createElement(Comp, { id: 'probe', ...props }, 'x'))
console.log(`${label.padEnd(30)} -> ${marked(markup) || '(not found)'}`)
return { host: hostOf(markup), markup }
}
console.log('--- host element: tag (gui 7) vs render (gui 8) ---')
const withTag = probe('tag="a"', XStack, { tag: 'a', href: 'https://hanzo.ai' })
const withRender = probe('render="a"', XStack, { render: 'a', href: 'https://hanzo.ai' })
console.log('\n--- style props that silently drop or mis-unit ---')
probe('lineHeight={1.1} (prop)', Text, { lineHeight: 1.1 })
probe('style lineHeight: 1.1 (ratio)', Text, { style: { lineHeight: 1.1 } })
probe("style lineHeight: '1.1' (string)", Text, { style: { lineHeight: '1.1' } })
probe('letterSpacing="-0.02em"', Text, { letterSpacing: '-0.02em' })
probe('animation="quick"', XStack, { animation: 'quick' })
probe('$sm={{...}}', XStack, { $sm: { bg: '$red10' } })
probe('$gtSm={{...}}', XStack, { $gtSm: { bg: '$red10' } })
console.log()
console.log(withTag.host === 'a' ? 'tag WORKS' : 'tag IS SILENTLY DROPPED')
console.log(withRender.host === 'a' ? 'render WORKS' : 'render IS SILENTLY DROPPED')
+20 -56
View File
@@ -1,29 +1,22 @@
'use client'
/**
* The account control — who you are, which organization you are acting in, what
* you have left to spend, and the way out. ONE control, at the foot of the rail.
* The account control — WHO you are: identity, your team, your personal
* settings, what you have left to spend, and the way out. ONE control, at the
* foot of the rail.
*
* It deliberately does NOT switch tenant. Org and project are one question —
* WHERE you are — and they are answered together by `ContextSwitcher` at the
* top-left, beside the tenant's own mark. Handing this menu an `orgState` too
* would put the org in two corners again, which is the exact confusion the
* condensed switcher removes. The cross-tenant reach, the admin-gated org list
* and the single `org-scope.switchOrg` money seam all moved there intact; there
* is still exactly one org switch in the app.
*
* It is `@hanzo/iam`'s `UserMenu`, the same component hanzo.chat mounts, so the
* identity, the switcher and the behaviour (click-away, Escape, close-before-
* navigate, never a raw uuid) are shared rather than rebuilt. This file is the
* ADAPTER — everything the console knows that the SDK does not:
*
* - ORG REACH. `useOrganizations()` reads the caller's memberships off the token
* and cannot express what an admin console does: enter ANY tenant. So the
* switcher is handed `findOrgs`, backed by the console's existing lazy,
* server-paged cross-tenant list (`IamAdminApi.organizations` through the
* gated `/admin/iam` proxy) — the same source the full-page org picker uses.
* A regular user never fires it: they see their own org, synthesized from the
* session, exactly as before. Nothing is fabricated, and nobody's reach widens.
*
* - MONEY. Switching goes through `org-scope.switchOrg` — the console's existing
* switch, passed by reference, not reimplemented. It persists the scope and
* reloads so every module refetches under the new `X-Org-Id`. That one seam is
* where tenant scoping and its billing attribution already live; this file adds
* no second switch, no header of its own, and no billing call, so the rule about
* which ledger a masquerading admin's spend lands on is exactly where it was.
* `org-state.test.ts` pins the identity so a second switch cannot creep in.
* identity and the behaviour (click-away, Escape, close-before-navigate, never a
* raw uuid) are shared rather than rebuilt. This file is the ADAPTER —
* everything the console knows that the SDK does not:
*
* - THEME. The console themes through `@hanzogui/next-theme` (which drives the
* Gui tree). That is adapted into the menu's shape rather than mounting IAM's
@@ -32,45 +25,19 @@
* - BRAND. The strip at the foot wears THIS host's brand. Passing nothing would
* paint a Hanzo mark on a Lux or Zoo console.
*/
import { useCallback, useMemo } from 'react'
import { UserMenu, type OrgState, type UserTheme } from '@hanzo/iam/react'
import { useMemo } from 'react'
import { UserMenu, type UserTheme } from '@hanzo/iam/react'
import { useThemeSetting } from '@hanzogui/next-theme'
import { config } from '~/config'
import { adminOrgState, scopedOrgRow } from '~/lib/account/org-state'
import { useSession } from '~/lib/auth/session'
import { useIsSuperAdmin } from '~/lib/auth/admin'
import { IamAdminApi, type Organization } from '~/lib/api'
import { ORG_PAGE_SIZE, orgQuery } from '~/lib/org-list'
import { currentOrg, leaveOrg, switchOrg } from '~/lib/org-scope'
import { useCloudBalance, spendableCents } from '~/lib/billing/live-balance'
export function AccountMenu() {
const { account, signOut } = useSession()
// The cross-tenant list is admin-gated at the proxy; a regular user would 403 it,
// so they are never asked to. Their own org is the honest answer.
const isSuperAdmin = useIsSuperAdmin()
const { balance } = useCloudBalance()
const { current, resolvedTheme, set } = useThemeSetting()
const scoped = currentOrg()
const findOrgs = useCallback(
async (query: string): Promise<Organization[]> => {
if (!isSuperAdmin) return scopedOrgRow(scoped)
const res = await IamAdminApi.organizations(orgQuery(0, query, ORG_PAGE_SIZE))
return res.rows ?? []
},
[isSuperAdmin, scoped],
)
// The console's own switch, by reference: persist the scope, reload, refetch
// under the new X-Org-Id. Pinned in `org-state.test.ts`.
const orgState: OrgState = useMemo(
() => adminOrgState({ scoped, findOrgs, switchOrg }),
[scoped, findOrgs],
)
// `system` is a real choice, and the console's provider already understands it.
const theme: UserTheme = useMemo(
() => ({
@@ -98,7 +65,6 @@ export function AccountMenu() {
isAuthenticated
isLoading={false}
onSignOut={() => void signOut()}
orgState={orgState}
theme={theme}
settingsUrl="/profile"
usageUrl="/billing"
@@ -106,12 +72,10 @@ export function AccountMenu() {
// Only shown when the backend actually reported a balance — never a fabricated $0.
balance={cents === null ? undefined : { amountUsd: cents / 100, topUpUrl: config.payUrl }}
items={[
{
label: 'All organizations',
// De-scope back to the full-page picker, where an org is entered — and
// where a new one is created. The dropdown never grew its own form.
onSelect: () => leaveOrg(),
},
// Your people, beside your own settings — the other half of "who am I".
// Choosing a DIFFERENT tenant is a different question and lives in the
// top-left context switcher, so this menu never re-scopes the console.
{ label: 'Members', href: '/team' },
{ label: 'Documentation', href: config.docsUrl, external: true, separatorBefore: true },
]}
brand={{ name: config.brandName }}
+54 -11
View File
@@ -1,12 +1,12 @@
'use client'
/**
* All products — the directory where you curate your sidebar. Every Hanzo product
* is always available on demand; this panel lists the FULL catalog the viewer may
* see (brand-scoped, admin surfaces gated), grouped by category, each row with a
* PIN toggle that promotes/removes it from the sidebar's Pinned quick-access section
* (via `usePins`). Rendered in the shared DetailPane (opened from the sidebar's
* "All products" row).
* All products — the directory of every Hanzo app, where you OPEN one and where you
* curate your sidebar. Every product is always available on demand; this panel lists
* the FULL catalog the viewer may see (brand-scoped, admin surfaces gated), grouped
* by category. A row OPENS its app; the pin toggle beside it promotes/removes it from
* the sidebar's Pinned quick-access section (via `usePins`). Rendered in the shared
* DetailPane (opened from the sidebar's "All products" row).
*
* Honest by construction: pinning is instant + optimistic (persisted through the
* account preferences store — no async error state). Products with REAL org usage
@@ -15,16 +15,18 @@
* the badges/filter degrade away — never a fabricated "in use".
*/
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button, Input, Text, XStack, YStack } from '@hanzo/gui'
import { Activity, Plus, Search, Star } from '@hanzogui/lucide-icons-2'
import { visibleCatalogByCategory, type CatalogEntry, type ProductIcon } from '~/lib/products/registry'
import { useIsSuperAdmin } from '~/lib/auth/admin'
import { usePins, useProductColors } from '~/lib/products/pins'
import { openProduct } from '~/lib/products/open'
import { useDetailPane } from '~/components/DetailPane'
import { fetchUsageRecords } from '~/lib/api/aimetrics'
import { inUseProductIds } from '~/lib/products/product-usage'
import { asColor } from '~/components/ui/color'
import { EmptyState } from '~/components/ui/EmptyState'
import { EmptyState, asColor } from '@hanzo/ui/product'
/** The list narrowing controls at the top. */
type Filter = 'all' | 'inuse' | 'pinned'
@@ -41,23 +43,51 @@ function InUseBadge() {
)
}
/** One catalog row: icon · label/description (+ In-use badge) · pin toggle. */
/**
* One catalog row: icon · label/description (+ In-use badge) · pin toggle.
*
* The row itself OPENS the product. It used to be an inert `XStack` — a plain div
* with `cursor: auto`, no role and no handler — so the one place in the console that
* lists every app was a directory you could not walk: the only live control in the
* row was the pin. Opening is delegated to the shared `openProduct`, the ONE opener
* every other surface (sidebar, ⌘K, category page) already routes through.
*
* Pin stays a SEPARATE control on the same row, so curating never navigates and
* navigating never curates. It stops the press from bubbling into the row for the
* same reason.
*/
function ProductRow({
entry,
color,
pinned,
inUse,
onOpen,
onToggle,
}: {
entry: CatalogEntry
color: string
pinned: boolean
inUse: boolean
onOpen: () => void
onToggle: () => void
}) {
const Icon = entry.icon
return (
<XStack items="center" gap="$3" py="$2" px="$2" rounded="$3" minH={44} hoverStyle={{ bg: '$color2' }}>
<XStack
role="button"
tabIndex={0}
onPress={onOpen}
cursor="pointer"
items="center"
gap="$3"
py="$2"
px="$2"
rounded="$3"
minH={44}
hoverStyle={{ bg: '$color2' }}
focusStyle={{ bg: '$color2' }}
aria-label={`Open ${entry.label}`}
>
<YStack width={32} height={32} rounded="$3" bg="$color3" items="center" justify="center">
<Icon size={16} color={asColor(color)} />
</YStack>
@@ -73,7 +103,10 @@ function ProductRow({
<Button
size="$2"
icon={pinned ? <Star size={15} /> : <Plus size={15} />}
onPress={onToggle}
onPress={(e?: { stopPropagation?: () => void }) => {
e?.stopPropagation?.()
onToggle()
}}
bg={pinned ? '$color5' : 'transparent'}
borderWidth={1}
borderColor="$borderColor"
@@ -121,6 +154,15 @@ export function AddProductPanel() {
const showAdmin = useIsSuperAdmin()
const { isPinned, toggle } = usePins()
const { colorOf } = useProductColors()
const router = useRouter()
const detail = useDetailPane()
// Opening an app takes you to it: navigate, then close the pane you launched from
// (it is a directory, not a destination — leaving it open would cover the page it
// just opened).
const openEntry = (entry: CatalogEntry) => {
openProduct(entry, (path) => router.push(path))
detail.close()
}
const [query, setQuery] = useState('')
const [filter, setFilter] = useState<Filter>('all')
// Real org usage signal. `null` = not (yet) known; a Set (even empty) = a real
@@ -236,6 +278,7 @@ export function AddProductPanel() {
color={colorOf(entry.id)}
pinned={isPinned(entry.id)}
inUse={inUse?.has(entry.id) ?? false}
onOpen={() => openEntry(entry)}
onToggle={() => toggle(entry.id)}
/>
))}
+45 -38
View File
@@ -1,40 +1,38 @@
'use client'
/**
* Bridges the console session + App-Router navigation into the shared analytics
* client (`@hanzo/event`). Rendered once, inside both `SessionProvider` and
* `AnalyticsProvider` (see `Provider.tsx`), it renders nothing.
* The console's telemetry surface — the ONE Hanzo telemetry provider, plus the one
* thing it deliberately leaves to the app.
*
* - `usePageview` emits a pageview on every path change (the provider fires the
* FIRST pageview itself, so this only covers subsequent client navigations).
* - `identify` binds the user to their Hanzo IAM user id (`account.userId`, the
* OIDC `sub`) once the session resolves, AND carries the attributes that make
* that id legible. Anonymous placeholder sessions are skipped.
* `TelemetrySurface` mounts `@hanzogui/telemetry`, which owns the whole plane for
* this subtree: pageviews (including SPA route changes), `window.onerror` +
* `unhandledrejection`, React render errors (its internal boundary REPORTS and
* re-throws, so the app's own error UI still decides what the user sees), lazily
* imported interaction capture, and DNT/GPC consent. It replaces the hand-rolled
* `<AnalyticsProvider>` + bridge + boundary combo; it mounts @hanzo/event's
* `AnalyticsProvider` internally with its own client, so every existing
* `useAnalytics()` call site keeps working against that ONE client and one stream.
* `product="console"` is all the configuration there is — @hanzo/event's DSN
* registry resolves the hanzo-console Sentry project from it, so the error plane
* needs no `dsn` prop and no env var.
*
* WHY NOT `owner/name`. This used to identify by the `${owner}/${name}` actor ref.
* That is an org-relative REFERENCE, not a user id: it is a different id space
* from the one hanzo.ai and hanzo.chat identify by (the IAM `sub`), so the same
* user counted twice the moment they used two Hanzo surfaces — and every
* cross-property funnel, retention curve and path silently measured nothing. It
* also moves when an org or a login handle is renamed, which rewrites history.
* It reads `usePathname()` ITSELF rather than taking a `path` prop from `Provider`:
* `Provider` memoizes its tree on `children`, so a path read up there would be
* baked into the cached element and go stale on the first client navigation.
*
* WHY TRAITS. A bare `identify(id)` writes a user id and nothing else, so the
* warehouse held a population of opaque subjects: every funnel could count users
* but no one could say WHICH user, and answering "who hit this error" meant a
* manual IAM lookup per row. Email and name are FIRST-PARTY facts about our own
* users — they arrive in the same IAM claims this file already decodes to get the
* id, and were simply dropped on the floor. Sent as traits they are exactly as
* sensitive as they were in the token, and the id stays the join key.
*
* WHAT IS STILL NOT SENT: the org. The tenant is stamped SERVER-SIDE from the
* validated bearer, so org-level cohorts are already queryable — and a tenant the
* client can name is a tenant the client can get wrong. Traits describe the user,
* never the scope they are trusted with.
* `AnalyticsBridge` is the one thing TelemetryProvider does NOT do — `identify`.
* It binds the person to the STABLE `owner/name` actor id (the same id the API
* client uses via `setCurrentActor`), never the email, once the session resolves.
* The org tenant is stamped server-side from the session, so we send the user id
* only, and anonymous placeholder sessions are skipped. It renders nothing and
* emits NO pageview — the provider owns those, and a second emitter would
* double-count every route.
*/
import { useEffect, useRef } from 'react'
import { useEffect, useRef, type ReactNode } from 'react'
import { usePathname } from 'next/navigation'
import { useAnalytics, usePageview } from '@hanzo/event/react'
import { TelemetryProvider, useTelemetry } from '@hanzogui/telemetry'
import { iamAccessToken } from '~/lib/auth/iam'
import { useSession } from '~/lib/auth/session'
import { type Account } from '~/lib/api/types'
@@ -49,21 +47,30 @@ export function identityTraits(account: Account): Record<string, unknown> {
return traits
}
export function TelemetrySurface({ children }: { children: ReactNode }) {
const path = usePathname()
// @hanzo/iam (PKCE) is the console's ONE credential and it is a BEARER — the same
// token `lib/api/client.ts` puts on every call — so telemetry authenticates the
// same way rather than relying on a cookie the ingest host would never receive.
return (
<TelemetryProvider product="console" path={path} getToken={iamAccessToken}>
{children}
</TelemetryProvider>
)
}
export function AnalyticsBridge() {
const analytics = useAnalytics()
const telemetry = useTelemetry()
const { account } = useSession()
usePageview(usePathname())
const identified = useRef('')
useEffect(() => {
if (!account || account.type === 'anonymous-user') return
// No IAM subject means no IAM user — leave the visitor anonymous rather than
// inventing an id for them. Identity flows from the token's own claims.
const userId = account.userId
if (!userId || identified.current === userId) return
identified.current = userId
analytics.identify(userId, identityTraits(account))
}, [account, analytics])
if (!account?.owner || !account?.name || account.type === 'anonymous-user') return
const personId = `${account.owner}/${account.name}`
if (identified.current === personId) return
identified.current = personId
telemetry.identify(personId)
}, [account, telemetry])
return null
}
+24 -6
View File
@@ -22,12 +22,13 @@ import { useIam } from '@hanzo/iam/react'
import { Button, Text, YStack } from '@hanzo/gui'
import { Loader } from '~/components/ui/Loader'
import { takeReturnTo } from '~/lib/auth/iam'
import { takeReturnTo, startReauth } from '~/lib/auth/iam'
import { classifyCallback, type CallbackVerdict } from '~/lib/auth/callback-error'
export function AuthCallback() {
const router = useRouter()
const { handleCallback } = useIam()
const [error, setError] = useState<string | null>(null)
const [verdict, setVerdict] = useState<CallbackVerdict | null>(null)
// The OAuth `code` is SINGLE-USE and the SDK removes the PKCE verifier BEFORE the
// token fetch, so the exchange must fire EXACTLY ONCE. Without this guard, React
// StrictMode's double-invoke (or any `handleCallback` identity change re-running the
@@ -47,20 +48,37 @@ export function AuthCallback() {
window.location.assign(takeReturnTo())
})
.catch(() => {
if (!cancelled) setError('Sign-in failed.')
if (cancelled) return
// The SDK is the AUTHORITY on whether a callback is a sign-in — it validates
// `state` before honouring an error branch, so an attacker-supplied
// /callback?error=… cannot mint a session. It reports every refusal the same
// way, though, so this screen used to say "Sign-in failed." to someone who had
// merely cancelled a consent screen. Read the code for WORDING only; the
// decision was already made above.
setVerdict(
classifyCallback(typeof window === 'undefined' ? '' : window.location.search) ?? {
kind: 'failed',
message: 'Sign-in failed.',
},
)
})
return () => {
cancelled = true
}
}, [handleCallback])
if (error) {
if (verdict) {
// A refusal is not always a fault. Only a genuine failure is worded as one, and
// the two benign outcomes lead with the action that actually resolves them.
const retry = verdict.kind === 'failed' ? 'Back to sign in' : 'Sign in'
return (
<YStack flex={1} minH="100vh" items="center" justify="center" gap="$3">
<Text color="$color12" fontWeight="600">
{error}
{verdict.message}
</Text>
<Button onPress={() => router.replace('/signin')}>Back to sign in</Button>
<Button onPress={() => (verdict.kind === 'failed' ? router.replace('/signin') : startReauth())}>
{retry}
</Button>
</YStack>
)
}
+3 -4
View File
@@ -73,17 +73,16 @@ import {
import { AiApi, IamAdminApi, type Organization } from '~/lib/api'
import { findEntry, type CatalogEntry } from '~/lib/products/registry'
import { commandBarSystemPrompt, hanzoAssistantSystemPrompt } from '~/lib/assistant'
import { assistantState, commandBarSystemPrompt, hanzoAssistantSystemPrompt } from '~/lib/assistant'
import { searchDestinations, type Destination } from '~/lib/products/search'
import { DEFAULT_GROUP_LABEL, pinnedFirst } from '~/lib/products/pins-core'
import { usePins, useProductColors } from '~/lib/products/pins'
import { asColor } from '~/components/ui/color'
import { ProductIcon } from '~/components/ui/ProductIcon'
import { openProduct } from '~/lib/products/open'
import { currentOrg, switchOrg } from '~/lib/org-scope'
import { useSession } from '~/lib/auth/session'
import { useIsSuperAdmin } from '~/lib/auth/admin'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import { BackendStateCard, asColor, type BackendState } from '@hanzo/ui/product'
const titleCase = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
@@ -542,7 +541,7 @@ function PaletteDialog({
setRun({ status: 'text', text: ans })
}
} catch (e) {
setRun({ status: 'error', state: classifyBackend(e) })
setRun({ status: 'error', state: assistantState(e) })
}
}, [mode, sub, showAdmin])
+194
View File
@@ -0,0 +1,194 @@
'use client'
/**
* Context switcher — WHERE you are: the organization and the project, in ONE
* control, at the TOP-LEFT where the tenant's mark already sits.
*
* The console used to answer "who and where am I" from three different corners:
* the org at the top of the rail, the account (which also switched org) at its
* foot, and the project chip in the top-right beside the network. Org and
* project are one question — which tenant, and which slice of it — so they are
* one control, and it sits with the org mark that already anchors the top-left.
*
* The ACCOUNT keeps the other question ("who am I": identity, team, personal
* settings, the way out) at the foot of the rail. The NETWORK stays its own
* control in the top-right, because it is a global MODE rather than a place —
* and its tier dot is a destructive-environment guard, not decoration.
*
* There is still exactly ONE org switch. `switchOrg` is passed by reference from
* `~/lib/org-scope` (the seam that persists the scope and reloads so every
* module refetches under the new `X-Org-Id`, which is where tenant scoping and
* its billing attribution already live). This control does not mint a second
* one, add a header of its own, or make a billing call — `org-state.test.ts`
* pins that identity. Cross-tenant reach is the SAME admin-gated, server-paged
* list the full-page picker uses; a regular user never fires it and sees only
* their own org.
*/
import { useCallback, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button, Popover, Text, XStack, YStack } from '@hanzo/gui'
import { ChevronsUpDown, FolderGit2, Plus } from '@hanzogui/lucide-icons-2'
import { useScope } from '~/lib/scope-context'
import { useOrgIdentity } from '~/components/ui/BrandLogo'
import { useIsSuperAdmin } from '~/lib/auth/admin'
import { IamAdminApi, type Organization } from '~/lib/api'
import { ORG_PAGE_SIZE, orgQuery } from '~/lib/org-list'
import { currentOrg, leaveOrg, switchOrg } from '~/lib/org-scope'
import { contextLabel, scopedOrgRow, titleCase } from '~/lib/account/org-state'
import { MenuRow } from '~/components/ui/MenuRow'
import { paper } from '~/components/ui/paper'
import { SearchInput } from '@hanzo/ui/product'
export function ContextSwitcher() {
const router = useRouter()
const org = useOrgIdentity()
const scoped = currentOrg()
const isSuperAdmin = useIsSuperAdmin()
const { scope, projects, loadingProjects, selectProject } = useScope()
const [open, setOpen] = useState(false)
const [orgs, setOrgs] = useState<Organization[] | null>(null)
const [query, setQuery] = useState('')
// IAM's display name when it has one; otherwise the slug, titled the same way
// `scopedOrgRow` titles it — one rule, so the trigger and the list agree.
const orgLabel = org.displayName || titleCase(org.name || scoped)
// The cross-tenant list is admin-gated at the proxy; a regular user would 403
// it, so they are never asked to — their own org is the honest answer. An admin
// searches the SERVER (the list is paged and far longer than one page), which is
// the only way to reach a tenant nobody is a member of.
const loadOrgs = useCallback(
async (q: string) => {
if (!isSuperAdmin) return setOrgs(scopedOrgRow(scoped) as Organization[])
const res = await IamAdminApi.organizations(orgQuery(0, q, ORG_PAGE_SIZE))
setOrgs(res.rows ?? [])
},
[isSuperAdmin, scoped],
)
const onOpenChange = useCallback(
(next: boolean) => {
setOpen(next)
if (next && orgs === null) void loadOrgs('')
},
[orgs, loadOrgs],
)
const search = useCallback(
(q: string) => {
setQuery(q)
void loadOrgs(q)
},
[loadOrgs],
)
const pick = useCallback(
(fn: () => void) => () => {
setOpen(false)
fn()
},
[],
)
const orgRows = useMemo(() => orgs ?? [], [orgs])
return (
<Popover open={open} onOpenChange={onOpenChange} placement="bottom-start">
<Popover.Trigger asChild>
<Button
size="$3"
chromeless
justify="flex-start"
px="$2"
data-testid="switcher-context"
iconAfter={<ChevronsUpDown size={13} opacity={0.6} />}
aria-label={`Organization and project — ${contextLabel(orgLabel, scope.project)}`}
>
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1} flex={1}>
{contextLabel(orgLabel, scope.project)}
</Text>
</Button>
</Popover.Trigger>
<Popover.Content {...paper} p="$2" width={280}>
<YStack gap="$0.5">
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="500">
Organization
</Text>
{/* Admins only: the cross-tenant list is server-paged and longer than
one page, so reaching a tenant nobody is a member of means SEARCHING
it, not scrolling. A regular user has one org and no field. */}
{isSuperAdmin ? (
<YStack px="$1" pb="$1">
{/* A search landmark names the control for assistive tech — the shared
SearchInput has no accessible-name prop of its own. */}
<div role="search" aria-label="Find an organization">
<SearchInput value={query} onChange={search} placeholder="Find an organization" name="org" />
</div>
</YStack>
) : null}
<YStack role="radiogroup" aria-label="Organizations" gap="$0.5">
{orgRows.map((o) => (
<MenuRow
key={o.name}
label={o.displayName || o.name}
active={scoped === o.name}
onPress={pick(() => {
if (o.name !== scoped) switchOrg(o.name)
})}
/>
))}
</YStack>
{orgRows.length === 0 ? (
<Text px="$2" py="$1.5" fontSize="$2" color="$color10">
{orgs === null ? 'Loading…' : 'No organization matches that.'}
</Text>
) : null}
<MenuRow label="All organizations" icon={<Plus size={14} />} onPress={pick(leaveOrg)} />
<XStack height={1} bg="$borderColor" my="$1" />
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="500">
Project
</Text>
<YStack role="radiogroup" aria-label="Projects" gap="$0.5">
{/* Org-level scope — no X-Project-Id sent. */}
<MenuRow
label="All projects"
sub="Org-level"
active={!scope.project}
onPress={pick(() => selectProject(undefined))}
/>
{projects.map((p) => (
<MenuRow
key={p.name}
label={p.displayName || p.name}
active={scope.project === p.name}
onPress={pick(() => selectProject(p.name))}
/>
))}
</YStack>
{projects.length === 0 && !loadingProjects ? (
<Text px="$2" py="$1.5" fontSize="$2" color="$color10">
No projects yet.
</Text>
) : null}
<MenuRow
label="New project"
icon={<FolderGit2 size={14} />}
onPress={pick(() => router.push('/projects'))}
/>
</YStack>
</Popover.Content>
</Popover>
)
}
+1 -1
View File
@@ -28,8 +28,8 @@ import { Button, ScrollView, Text, XStack, YStack } from '@hanzo/gui'
import { X } from '@hanzogui/lucide-icons-2'
import { SlideOver } from '~/components/ui/SlideOver'
import { asColor, type IconLike } from '~/components/ui/color'
import { Z } from '~/lib/z'
import { asColor, type IconLike } from '@hanzo/ui/product'
export type DetailDescriptor = {
/** Pane title (the item's name). */
+120 -59
View File
@@ -15,7 +15,23 @@
*
* Docking is a desktop concern (a phone has no room for a permanent column), so on
* `<lg` the assistant is ALWAYS the floating bubble/sheet regardless of the dock
* choice; `docked` only reserves the right column at `lg+`.
* choice. That fact — the persisted CHOICE against a viewport that can honor it —
* meets in exactly one place, `column`, and it is what every shape is chosen by.
*
* It is not a detail. The sheet is a MODAL dialog, so leaving it open behind a hidden
* column put a full-viewport dialog over the page: the column painted, and every click
* on it landed on the dialog instead — an assistant you could read and could not type
* into. Hiding one of two open surfaces with CSS cannot fix that, because `display:
* none` on the dialog's own content does not make the dialog stop being modal. So only
* one is ever open, and `column` is the one fact that says which.
*
* The assistant has ONE entry point and it lives HERE: `AssistantFab`, a floating
* control fixed bottom-right over every dashboard page. It used to be two small
* buttons in the topbar (a brand-H and a mic), which put the assistant in a third
* place — beside the search box, competing with the org/theme/alert chrome — while
* this module owned every other shape it can take. Chat and voice are the same
* surface opened two ways, so they sit together, in the corner the assistant
* actually appears in.
*
* Every shape REUSES the one working chat surface (`ChatConversation` → `AiApi.chat`
* → the keyless `/ai` proxy → /v1/chat/completions). Nothing about AI is rebuilt
@@ -25,27 +41,33 @@
*/
import { createContext, useCallback, useContext, useState, type ReactNode } from 'react'
import { usePathname, useRouter } from 'next/navigation'
import { Button, Dialog, Text, VisuallyHidden, XStack, YStack } from '@hanzo/gui'
import { PanelRight, PanelRightClose, Sparkles, X } from '@hanzogui/lucide-icons-2'
import { Button, Dialog, Text, VisuallyHidden, XStack, YStack, useMedia } from '@hanzo/gui'
import { Mic, PanelRight, PanelRightClose, Sparkles, X } from '@hanzogui/lucide-icons-2'
import { ChatConversation } from '~/components/products/chat/ChatConversation'
// NB: the old `BrandMark` bubble import was removed with the floating circle — the
// assistant now opens from the topbar's small brand-H + mic controls.
import { BrandMark } from '~/components/ui/BrandLogo'
import { usePreferences } from '~/lib/products/preferences'
import { voiceSupported } from '~/lib/voice'
import { Z } from '~/lib/z'
type FloatingChatApi = {
isOpen: boolean
open: () => void
close: () => void
toggle: () => void
/** True when the assistant is docked as a permanent right column (persisted). */
docked: boolean
/**
* True when the permanent right column IS the assistant right now: the dock choice,
* a viewport wide enough to hold it, and a page that is not already a composer.
* Every surface reads this rather than the raw choice, so exactly one composer is
* on screen at any width.
*/
column: boolean
setDocked: (v: boolean) => void
/**
* Open the assistant with a PRE-FILLED prompt (e.g. "Ask AI about this code" from the
* Code hub). The composer is seeded and focused; the user reviews and sends (never an
* auto-send — no surprise billing), matching the suggested-prompt UX. Opens the floating
* sheet when floating; when docked, the permanent column receives the seed.
* auto-send — no surprise billing), matching the suggested-prompt UX. The column takes
* the seed when it is the assistant; otherwise the sheet opens with it.
*/
ask: (prompt: string) => void
/** The current pending seed for the composer (consumed once by the active conversation). */
@@ -114,17 +136,14 @@ function ChatSheet({
onOpenChange,
onHistory,
onDock,
docked,
seed,
voiceSignal,
}: {
/** Open ONLY when the sheet is the assistant — never alongside the column. */
open: boolean
onOpenChange: (o: boolean) => void
onHistory: () => void
onDock: () => void
/** When docked (desktop), the permanent column is the surface — so the floating
* sheet is suppressed at lg+ (it still serves phones, which have no column). */
docked: boolean
/** Pre-fill seed for the composer (from `useFloatingChat().ask`). */
seed?: string | null
/** Voice-start signal forwarded to the conversation ("talk to Hanzo"). */
@@ -136,12 +155,7 @@ function ChatSheet({
return (
<Dialog modal open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay
key="chat-overlay"
className="hz-scrim-in"
bg="rgba(0,0,0,0.5)"
$lg={{ bg: 'transparent', display: docked ? 'none' : undefined }}
/>
<Dialog.Overlay key="chat-overlay" className="hz-scrim-in" bg="rgba(0,0,0,0.5)" $lg={{ bg: 'transparent' }} />
<Dialog.Content
key="chat-content"
className="hz-paper hz-pop-in"
@@ -158,18 +172,8 @@ function ChatSheet({
width="100vw"
height="100dvh"
rounded="$0"
// Desktop (≥lg): a compact popover bottom-right, above the bubble. Docked →
// hidden at lg+ (the permanent right column replaces it).
$lg={{
t: 'auto',
l: 'auto',
b: 88,
r: 24,
width: 380,
height: 560,
rounded: '$6',
display: docked ? 'none' : undefined,
}}
// Desktop (≥lg): a compact popover bottom-right, above the bubble.
$lg={{ t: 'auto', l: 'auto', b: 88, r: 24, width: 380, height: 560, rounded: '$6' }}
>
<VisuallyHidden>
<Dialog.Title>Assistant</Dialog.Title>
@@ -204,6 +208,64 @@ function ChatSheet({
)
}
/**
* The floating assistant control — bottom-right, on every dashboard page.
*
* Two ways into one surface, side by side: the brand mark opens the assistant (the
* docked right column on a laptop, the full sheet on a phone) and the mic opens it
* listening. The mic renders only where the browser can actually listen, so there is
* never a dead control.
*
* It sits ABOVE the Developers dock at `lg+` (that dock's collapsed bar is 44px and
* exists only there), and its caller suppresses it exactly where the assistant is
* already on screen: while the sheet is open, on the pages that ARE a composer
* (`/chat`, `/playground`), and while the assistant IS the column.
*/
function AssistantFab({ onOpen, onVoice }: { onOpen: () => void; onVoice: () => void }) {
const [voiceOk] = useState(() => voiceSupported())
return (
<XStack
testID="assistant-fab"
position="fixed"
r={20}
b={20}
$lg={{ b: 64 }}
items="center"
gap="$2"
style={{ zIndex: Z.raised }}
>
{voiceOk ? (
<Button
className="hz-paper"
size="$3"
circular
width={44}
height={44}
bg="$color2"
borderWidth={1}
borderColor="$borderColor"
icon={<Mic size={18} />}
onPress={onVoice}
aria-label="Talk to Hanzo"
/>
) : null}
<Button
className="hz-paper"
size="$4"
circular
width={52}
height={52}
bg="$color2"
borderWidth={1}
borderColor="$borderColor"
icon={<BrandMark size={22} />}
onPress={onOpen}
aria-label="Ask Hanzo"
/>
</XStack>
)
}
/**
* The DOCKED assistant — a permanent right column. Rendered by `Dashboard`
* inside the layout's reserved right rail (lg+ only), so it reserves space beside
@@ -229,30 +291,36 @@ export function Chat({ children }: { children: ReactNode }) {
const { get, set } = usePreferences()
const docked = get<boolean>('chatDocked', false)
const setDocked = useCallback((v: boolean) => set('chatDocked', v), [set])
// The bubble is redundant — and OVERLAPS the composer's send control — on the
// pages that ARE a full chat/composer surface. Suppress it there (the assistant
// is still openable programmatically via `useFloatingChat`); every other page
// keeps the one-tap bubble.
const media = useMedia()
// The pages that ARE a full composer already show the assistant, so no other shape
// of it belongs on them — the bubble would overlap the page's own send control, and
// the column would be a second composer beside the first.
const onChatSurface =
pathname === '/chat' ||
pathname.startsWith('/chat/') ||
pathname === '/playground' ||
pathname.startsWith('/playground/')
// Every fact about which shape the assistant takes meets here and nowhere else: the
// persisted choice, a viewport wide enough to honor it, and whether this page is
// already a composer. A JS fact, not a media prop, because it decides what MOUNTS —
// a modal dialog that is merely hidden is still modal, and still eats every click.
const column = docked && media.lg && !onChatSurface
const [isOpen, setIsOpen] = useState(false)
const open = useCallback(() => setIsOpen(true), [])
const close = useCallback(() => setIsOpen(false), [])
const toggle = useCallback(() => setIsOpen((v) => !v), [])
// Seed the composer from anywhere (e.g. the Code hub's "Ask AI about this code").
// Open the floating sheet when floating; when docked, the permanent column is already
// on screen and receives the seed, so opening the (hidden) sheet is skipped.
// The column is already on screen and receives the seed; otherwise open the sheet —
// including on a phone whose owner once docked on a laptop, where the choice is
// remembered but no column exists to deliver the prompt.
const [seed, setSeed] = useState<string | null>(null)
const ask = useCallback(
(prompt: string) => {
setSeed(prompt)
if (!docked) setIsOpen(true)
if (!column) setIsOpen(true)
},
[docked],
[column],
)
const onHistory = useCallback(() => {
@@ -270,10 +338,9 @@ export function Chat({ children }: { children: ReactNode }) {
// conversation opens the mic on change.
const [voiceSignal, setVoiceSignal] = useState(0)
// The topbar brand-H entry: TOGGLE the assistant. Desktop → the docked right
// sidebar column; phones (no column) → the full sheet. Setting both in tandem is
// correct because at lg+ the sheet is suppressed while docked, and below lg the
// dock column is display:none — so one control opens the right surface per viewport.
// The topbar brand-H entry: TOGGLE the assistant. Desktop → the right column;
// phones (no column) → the full sheet. Both are set because `column` then admits
// exactly one of them per viewport.
const openChat = useCallback(() => {
const next = !docked
setDocked(next)
@@ -289,29 +356,23 @@ export function Chat({ children }: { children: ReactNode }) {
}, [setDocked])
return (
<Ctx.Provider value={{ isOpen, open, close, toggle, docked, setDocked, ask, seed, openChat, startVoice, voiceSignal }}>
<Ctx.Provider value={{ isOpen, open, close, toggle, column, setDocked, ask, seed, openChat, startVoice, voiceSignal }}>
{children}
{/* The bubble — fixed bottom-right over every page. Hidden while open (the
sheet's own close is the single dismiss). Hidden on the chat/playground
surfaces (would overlap the page composer). At lg+ it is ALSO hidden when
docked (the permanent column is the surface); on phones it always shows,
since docking has no room there. */}
{/* NO floating bubble — the big circle that covered page content is gone. The
assistant is opened from the topbar (the small brand-H "chat" control + the
"talk to Hanzo" mic), so the user's OWN brand leads the chrome and AI help is
one small press away. `open`/`toggle`/`ask` still drive it programmatically
(e.g. the Code hub's "Ask AI"). */}
{/* The assistant's ONE entry point — bottom-right, over every page. Hidden
while the sheet is open (its own close is the single dismiss), on the pages
that ARE a composer, and while the column is the surface. `open`/`toggle`/
`ask` still drive the assistant programmatically (e.g. "Ask AI"). */}
{isOpen || onChatSurface || column ? null : <AssistantFab onOpen={openChat} onVoice={startVoice} />}
{/* The floating sheet. Suppressed on the full chat/playground surfaces (the page
IS the composer) and, at lg+, while docked (the right column is the surface);
on phones it's the assistant even when docked. */}
{/* The floating sheet — the assistant wherever the column is not: every width on
a phone or tablet, and on a laptop until the user docks it. Never open at the
same time as the column, so there is one composer and it takes the click. */}
<ChatSheet
open={isOpen && !onChatSurface}
open={isOpen && !onChatSurface && !column}
onOpenChange={setIsOpen}
onHistory={onHistory}
onDock={dock}
docked={docked}
seed={seed}
voiceSignal={voiceSignal}
/>
+70 -4
View File
@@ -20,11 +20,27 @@ import { Button, Card, Input, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { Building2, ArrowRight, Sparkles } from '@hanzogui/lucide-icons-2'
import { useSession } from '~/lib/auth/session'
import { slugifyOrg, validateOrgName } from '~/lib/server/onboarding'
import { readOnboardRefusal, slugifyOrg, validateOrgName } from '~/lib/server/onboarding'
import { v1Url } from '~/lib/api/client'
import { FadeIn } from '~/components/ui/FadeIn'
import { FadeIn } from '@hanzo/ui/product'
type Phase = 'form' | 'done'
type Phase = 'form' | 'done' | 'exists'
// currentOwner asks the server which organization this account is in. IAM answers
// the legacy envelope ({status,msg,data}) on this address, so read through `data`
// and fall back to a bare body. Any failure returns null and the caller falls back
// to showing the server's own message — a recovery that guesses is worse than none.
async function currentOwner(): Promise<string | null> {
try {
const res = await fetch(v1Url('iam/account'), { credentials: 'include' })
if (!res.ok) return null
const body = (await res.json()) as { data?: { owner?: string }; owner?: string } | null
const owner = body?.data?.owner ?? body?.owner
return owner && owner !== 'admin' ? owner : null
} catch {
return null
}
}
export function OrgOnboarding() {
const { signIn, signOut } = useSession()
@@ -32,6 +48,9 @@ export function OrgOnboarding() {
const [busy, setBusy] = useState<false | 'create' | 'personal'>(false)
const [error, setError] = useState<string | null>(null)
const [phase, setPhase] = useState<Phase>('form')
// The org the SERVER says this account already admins, discovered only after a
// refused create. See the 409 branch in onboard().
const [existingOrg, setExistingOrg] = useState<string | null>(null)
const slug = slugifyOrg(name)
const named = validateOrgName(name)
@@ -55,7 +74,28 @@ export function OrgOnboarding() {
}
const json = (await res.json().catch(() => null)) as { org?: string; error?: string } | null
if (!res.ok || !json?.org) {
setError(json?.error || `Could not create the organization (HTTP ${res.status}).`)
// 409 = the FIRST-RUN GATE, not a name collision. Onboarding MOVES the caller
// into the org it founds, so founding a second one would orphan the org this
// account already admins — IAM refuses (provision.go: "you already have an
// organization"). This screen is only ever rendered when the client resolved
// an EMPTY owner, and an owner that is empty because a read failed looks
// exactly like a brand-new account. So the refusal is the first reliable
// signal that the session was wrong, and the only honest thing to do with it
// is recover: ask the server which org this account is actually in and offer
// the way in. Leaving the customer on a form that can never submit — with a
// message about organizations when they just typed a name — is how "it said
// the name was taken" happens.
const refusal = readOnboardRefusal(
res.status,
json?.error,
res.status === 409 ? await currentOwner() : null,
)
if (refusal.action === 'recover') {
setExistingOrg(refusal.org)
setPhase('exists')
} else {
setError(refusal.error)
}
setBusy(false)
return
}
@@ -69,6 +109,32 @@ export function OrgOnboarding() {
signIn()
}
if (phase === 'exists' && existingOrg) {
return (
<Center>
<FadeIn style={CENTER_STYLE}>
<Card p="$5" gap="$4" width={440} borderWidth={1} borderColor="$borderColor" bg="$color1" items="center">
<Building2 size={20} />
<YStack gap="$1" items="center">
<Text fontSize="$6" fontWeight="800">
You{'\u2019'}re already in {existingOrg}
</Text>
<Text fontSize="$3" color="$color11" text="center">
This account already belongs to an organization, so there was nothing to
create. Sign in again to continue there.
</Text>
</YStack>
{/* Explicit, never automatic. Re-authenticating on our own would loop
forever against whatever left the session without an owner. */}
<Button size="$3" onPress={() => signIn()}>
Continue to {existingOrg}
</Button>
</Card>
</FadeIn>
</Center>
)
}
if (phase === 'done') {
return (
<Center>
+1 -2
View File
@@ -27,10 +27,9 @@ import { useIsSuperAdmin } from '~/lib/auth/admin'
import { enterOrg } from '~/lib/org-scope'
import { IamAdminApi, type Organization } from '~/lib/api'
import { BrandMark } from '~/components/ui/BrandLogo'
import { EmptyState } from '~/components/ui/EmptyState'
import { FadeIn } from '~/components/ui/FadeIn'
import { OrgOnboarding } from '~/components/OrgOnboarding'
import { PAGE_SIZE, pickerView, type OrgCard, type PickerContext } from '~/components/org-picker/logic'
import { EmptyState, FadeIn } from '@hanzo/ui/product'
const titleCase = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
+8 -11
View File
@@ -8,15 +8,13 @@ import { useMemo, type ReactNode } from 'react'
import { GuiProvider } from '@hanzo/gui'
import { NextThemeProvider, useRootTheme } from '@hanzogui/next-theme'
import { registerDefaultFields, registerField } from '@hanzo/data'
import { AnalyticsProvider } from '@hanzo/event/react'
import { IamProvider } from '@hanzo/iam/react'
import config from '../../gui.config'
import { SessionProvider } from '~/lib/auth/session'
import { iamConfig } from '~/lib/auth/iam'
import { EntitlementsProvider } from '~/lib/entitlements-context'
import { eventClient } from '~/lib/event'
import { AnalyticsBridge } from './Analytics'
import { AnalyticsBridge, TelemetrySurface } from './Analytics'
import { OrgAccentProvider } from './OrgAccentProvider'
import { RichTextDisplay, RichTextInput } from './fields/RichTextField'
@@ -64,16 +62,15 @@ export function Provider({ children }: { children: ReactNode }) {
{/* Entitlements live inside the session (they read the signed-in account +
active org scope) so the sidebar/palette gate from ONE fetch. */}
<EntitlementsProvider>
{/* Analytics lives INSIDE the session so `identify` binds the signed-in
actor. The ONE shared `eventClient` (same-origin /v1/event; the tenant is
stamped server-side, so the client never sends an org) is also referenced
by the error boundaries, so every signal rides one stream. The provider
fires the first pageview (autoPageview); `AnalyticsBridge` wires the
per-navigation pageviews + identity. */}
<AnalyticsProvider client={eventClient}>
{/* Telemetry lives INSIDE the session so `identify` binds the signed-in
actor. `TelemetrySurface` is the ONE provider — pageviews, errors,
interaction capture, consent — and it mounts @hanzo/event internally,
so `useAnalytics()` call sites share its client. `AnalyticsBridge`
adds only identity; the provider owns pageviews. */}
<TelemetrySurface>
<AnalyticsBridge />
{children}
</AnalyticsProvider>
</TelemetrySurface>
</EntitlementsProvider>
</SessionProvider>
</IamProvider>
-186
View File
@@ -1,186 +0,0 @@
'use client'
/**
* PublicLanding — what an UNAUTHENTICATED visitor sees at `/` (the marketing face
* of the console). The SAME one `cloud` binary that serves the signed-in console
* serves this to anon — one binary, one way, no separate marketing service.
*
* Content is DERIVED from the real product taxonomy (categoriesForBrand +
* CATEGORY_SUMMARY), brand-scoped via getBrand — so it is honest (every tile is a
* real category the platform ships) and white-labels for free. The primary CTA is
* the ONE sign-in surface (/signin); "Learn more" goes to the brand's own site.
*/
import { useRouter } from 'next/navigation'
import { Button, Text, XStack, YStack } from '@hanzo/gui'
import { HanzoHeader, HANZO_PRODUCT_CATEGORIES, findSurfaceByHost, type HanzoSurface } from '@hanzogui/shell'
import { config } from '~/config'
import { getBrand } from '~/lib/branding/brands'
import { categoriesForBrand, CATEGORY_SUMMARY } from '~/lib/products/brand-scope'
import { landingSurface } from '~/lib/products/landing-surface'
import { ConsoleFooter } from '~/components/ConsoleFooter'
/** The shared header, with its CTAs re-pointed at this landing's own sign-in
* (the canonical surface aims them AT the console — a self-link from here). */
const LANDING_SURFACE: HanzoSurface = landingSurface(findSurfaceByHost('cloud.hanzo.ai'))
/**
* Decline the shell's account control. `HanzoHeader` renders its OWN text "Sign in"
* link whenever `account` is nullish (`account ?? <DefaultAccount/>`), so omitting it
* put a second sign-in beside the primary CTA that IS the sign-in — the desktop
* logged-out header read `[Get API key] [Sign in] [Sign in]`. `false` is how a caller
* says "no account node": it is not nullish, so the default never renders, and React
* renders nothing for it — including in the mobile sheet, which would otherwise draw
* an empty bordered identity row around it. Exactly ONE sign-in affordance.
*/
const NO_ACCOUNT = false
/**
* The house hero buttons — the same pill pair every Hanzo landing wears: a white
* primary carrying the weight, and a hairline secondary that is still visibly a
* button. Tamagui's default Button is a grey chip that reads as DISABLED next to
* the white sign-in pill in the header, and `chromeless` has no edge at all.
*/
const CTA_PRIMARY = {
rounded: 999,
bg: '$color12',
color: '$color1',
borderWidth: 0,
hoverStyle: { bg: '$color12', opacity: 0.85 },
pressStyle: { bg: '$color12', opacity: 0.7 },
} as const
const CTA_SECONDARY = {
rounded: 999,
bg: 'transparent',
color: '$color12',
borderWidth: 1,
borderColor: '$color6',
hoverStyle: { bg: '$color3', borderColor: '$color8' },
pressStyle: { bg: '$color4' },
} as const
export function PublicLanding() {
const router = useRouter()
const brand = getBrand()
const categories = categoriesForBrand(brand.id).filter((c) => c !== 'Settings')
const signIn = (
<Button size="$3" onPress={() => router.push('/signin')}>
Sign in
</Button>
)
return (
<YStack minH="100vh" bg="$color1">
{/*
Top bar. On the Hanzo brand this is the UNIFIED @hanzogui/shell HanzoHeader
(Meet Hanzo + the rich ten-category Products mega-menu + brand tokens) — the
SAME header every Hanzo surface wears. White-label brands (lux/zoo/…) keep the
brand-neutral bar so no Hanzo ecosystem URL leaks onto their console.
*/}
{brand.id === 'hanzo' ? (
// The header's own primary CTA IS the sign-in (see LANDING_SURFACE), so the
// account control is declined explicitly (see NO_ACCOUNT) — one way in, not
// two competing sign-ins.
<HanzoHeader
surface={LANDING_SURFACE}
productsTaxonomy={HANZO_PRODUCT_CATEGORIES}
account={NO_ACCOUNT}
/>
) : (
<XStack
items="center"
justify="space-between"
px="$4"
py="$3"
borderBottomWidth={1}
borderColor="$borderColor"
$md={{ px: '$6' }}
>
<Text fontSize="$6" fontWeight="700" color="$color12">
{config.brandName}
</Text>
{signIn}
</XStack>
)}
{/* Hero */}
<YStack items="center" gap="$4" px="$4" py="$10" $md={{ py: '$12' }}>
<YStack items="center" gap="$3" maxW={760}>
{/* `hz-display` gives the headline a unitless line-height (see globals.css):
the size token's own line-height is tuned for ONE line, so on a phone —
where this always wraps — the two lines overprint without it. */}
<Text
render="h1"
className="hz-display"
fontSize="$11"
$md={{ fontSize: '$13' }}
fontWeight="800"
color="$color12"
style={{ textAlign: 'center' }}
>
The AI cloud, one platform
</Text>
<Text fontSize="$5" color="$color10" style={{ textAlign: 'center' }}>
Models, compute, training, data, and the tools to ship with usage-based billing and a single API.
</Text>
</YStack>
<XStack gap="$3" mt="$2" flexWrap="wrap" justify="center">
<Button
size="$4"
{...CTA_PRIMARY}
onPress={() => router.push('/signin')}
>
Get started
</Button>
<Button
size="$4"
{...CTA_SECONDARY}
onPress={() => typeof window !== 'undefined' && window.open(brand.websiteUrl, '_blank', 'noopener')}
>
Learn more
</Button>
</XStack>
</YStack>
{/* Real product categories — derived from the live taxonomy */}
<XStack justify="center" px="$4" pb="$10" $md={{ px: '$6' }}>
<YStack width="100%" maxW={1080} gap="$4">
<div
style={{
display: 'grid',
gap: 16,
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
}}
>
{categories.map((c) => (
<YStack
key={c}
gap="$1.5"
p="$4"
rounded="$4"
borderWidth={1}
borderColor="$borderColor"
bg="$color2"
>
<Text fontSize="$5" fontWeight="700" color="$color12">
{c}
</Text>
<Text fontSize="$3" color="$color10">
{CATEGORY_SUMMARY[c]}
</Text>
</YStack>
))}
</div>
</YStack>
</XStack>
<XStack justify="center" px="$4" $md={{ px: '$6' }}>
<YStack width="100%" maxW={1080}>
<ConsoleFooter />
</YStack>
</XStack>
</YStack>
)
}
+21 -115
View File
@@ -1,32 +1,35 @@
'use client'
/**
* Scope switcher — the project + network pickers that scope every module.
* Scope switcher — the NETWORK picker, and only that.
*
* Two chips next to the org switcher: the active PROJECT (or "All projects" for
* org-level scope) and the active NETWORK. The network picker offers the stock
* tiers (Mainnet/Testnet/Devnet — the live Hanzo networks), a Local option for a
* self-hosted cloud binary, and any custom networks the user adds (their own
* networkID / EVM chainID / RPC / API). Selecting a network writes through
* `useScope`, which updates the module-level scope the API client reads — so it
* re-scopes every module (via `X-Environment`) AND retargets chain/RPC/API at once.
* A "New project" affordance routes to the Projects module; we never fabricate one.
* The network is a global MODE, not a place, so it stays its own control in the
* top-right while org and project condense into the top-left `ContextSwitcher`.
* Its tier dot is a destructive-environment guard (mainnet live green, testnet
* caution amber), which is why it keeps a distinct, always-visible chip rather
* than folding into a menu you have to open to read.
*
* The picker offers the stock tiers (Mainnet/Testnet/Devnet — the live Hanzo
* networks), a Local option for a self-hosted cloud binary, and any custom
* networks the user adds (their own networkID / EVM chainID / RPC / API).
* Selecting a network writes through `useScope`, which updates the module-level
* scope the API client reads — so it re-scopes every module (via
* `X-Environment`) AND retargets chain/RPC/API at once.
*/
import { useMemo, useState, type ReactNode } from 'react'
import { useRouter } from 'next/navigation'
import { useMemo, useState } from 'react'
import { Button, Popover, Text, XStack, YStack } from '@hanzo/gui'
import { Check, ChevronsUpDown, FolderGit2, Layers, Plus, Trash } from '@hanzogui/lucide-icons-2'
import { Check, ChevronsUpDown, Layers, Plus, Trash } from '@hanzogui/lucide-icons-2'
import { useScope } from '~/lib/scope-context'
import { STOCK_ENVIRONMENTS } from '~/lib/scope'
import { isStockNetwork, parseCustomNetwork, type Network } from '~/lib/network'
import { FieldText } from '~/components/ui/Field'
import { MenuRow, type DotColor } from '~/components/ui/MenuRow'
import { paper } from '~/components/ui/paper'
import { FieldText } from '@hanzo/ui/product'
/** A small dot keyed to the network tier. Monochrome by default; only the genuine
* states carry a hue — mainnet is live (green), testnet is a caution (amber). Every
* other tier is a neutral off the design ladder (Tamagui $colorN). */
type DotColor = '$green10' | '$yellow10' | '$color10' | '$color9' | '$color8'
const NET_DOT: Record<string, DotColor> = {
mainnet: '$green10',
testnet: '$yellow10',
@@ -41,59 +44,6 @@ const titleCase = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
const idSub = (n: Network): string =>
n.networkID === n.evmChainID ? `Chain ${n.evmChainID}` : `Net ${n.networkID} · Chain ${n.evmChainID}`
function ProjectPicker() {
const router = useRouter()
const { scope, projects, loadingProjects, selectProject } = useScope()
const label = scope.project ? scope.project : 'All projects'
return (
<Popover placement="bottom-end">
<Popover.Trigger asChild>
<Button size="$2" chromeless icon={<FolderGit2 size={14} />} iconAfter={<ChevronsUpDown size={13} />}>
{label}
</Button>
</Popover.Trigger>
<Popover.Content {...paper} p="$2" width={260}>
<YStack gap="$0.5">
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="500">
Project
</Text>
{/* Org-level scope — no X-Project-Id sent. */}
<Row
label="All projects"
sub="Org-level"
active={!scope.project}
onPress={() => selectProject(undefined)}
/>
{projects.map((p) => (
<Row
key={p.name}
label={p.displayName || p.name}
active={scope.project === p.name}
onPress={() => selectProject(p.name)}
/>
))}
{projects.length === 0 && !loadingProjects ? (
<Text px="$2" py="$1.5" fontSize="$2" color="$color10">
No projects yet.
</Text>
) : null}
<XStack height={1} bg="$borderColor" my="$1" />
<Row
label="New project"
icon={<Plus size={14} />}
onPress={() => router.push('/projects')}
/>
</YStack>
</Popover.Content>
</Popover>
)
}
const EMPTY_FORM = { label: '', networkID: '', evmChainID: '', rpcEndpoint: '', apiEndpoint: '' }
function AddNetworkForm({
@@ -200,7 +150,7 @@ function NetworkPicker() {
Project environments
</Text>
{extraEnvs.map((env) => (
<Row
<MenuRow
key={env}
label={titleCase(env)}
sub="Custom"
@@ -216,7 +166,7 @@ function NetworkPicker() {
{adding ? (
<AddNetworkForm taken={takenCustomIds} onAdd={add} onCancel={() => setAdding(false)} />
) : (
<Row label="Add custom network" icon={<Plus size={14} />} onPress={() => setAdding(true)} />
<MenuRow label="Add custom network" icon={<Plus size={14} />} onPress={() => setAdding(true)} />
)}
</YStack>
</Popover.Content>
@@ -263,54 +213,10 @@ function NetworkRow({
)
}
/** One selectable row in a picker popover. */
function Row({
label,
sub,
dot,
icon,
active,
onPress,
}: {
label: string
sub?: string
dot?: DotColor
icon?: ReactNode
active?: boolean
onPress: () => void
}) {
return (
<XStack
onPress={onPress}
cursor="pointer"
items="center"
gap="$2"
px="$2"
py="$2"
rounded="$3"
hoverStyle={{ bg: '$color4' }}
>
{dot ? <YStack width={8} height={8} rounded="$10" bg={dot} /> : icon}
<YStack flex={1}>
<Text fontSize="$2" color="$color12" numberOfLines={1}>
{label}
</Text>
{sub ? (
<Text fontSize="$1" color="$color10">
{sub}
</Text>
) : null}
</YStack>
{active ? <Check size={14} /> : null}
</XStack>
)
}
/** Project + network pickers as a unit (topbar). */
/** The network picker (topbar). Org + project live in `ContextSwitcher`, top-left. */
export function ScopeSwitcher() {
return (
<XStack items="center" gap="$1">
<ProjectPicker />
<XStack items="center" gap="$1" data-testid="switcher-network">
<NetworkPicker />
</XStack>
)
+1 -1
View File
@@ -26,13 +26,13 @@
import { useCallback, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
import { useRouter } from 'next/navigation'
import { Text, XStack, YStack } from '@hanzo/gui'
import { OrgMark } from '@hanzo/ui/product'
import { BookOpen, Globe, Info, SlidersHorizontal } from '@hanzogui/lucide-icons-2'
import { config } from '~/config'
import { getBrand } from '~/lib/branding/brands'
import { useOrgIdentity } from '~/components/ui/BrandLogo'
import { Z } from '~/lib/z'
import { OrgMark } from '@hanzo/ui/product'
type MenuItem = {
icon: typeof SlidersHorizontal
+1 -2
View File
@@ -22,9 +22,8 @@ import { COLOR_SWATCHES } from '~/lib/products/colors'
import { DEFAULT_GROUP, DEFAULT_GROUP_LABEL, type PinGroupView } from '~/lib/products/pins-core'
import { usePins, useProductColors } from '~/lib/products/pins'
import { findEntry } from '~/lib/products/registry'
import { Reorder } from '~/components/ui/Reorder'
import { asColor } from '~/components/ui/color'
import { contrastText } from '~/lib/theme/accent'
import { Reorder, asColor } from '@hanzo/ui/product'
/** A round color swatch button; ringed + checked when selected. */
function SwatchButton({ hex, selected, onPress }: { hex: string; selected: boolean; onPress: () => void }) {
+1 -1
View File
@@ -24,9 +24,9 @@ import { useIam } from '@hanzo/iam/react'
import { Text, YStack } from '@hanzo/gui'
import { Loader } from '~/components/ui/Loader'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { config } from '~/config'
import { useSession } from '~/lib/auth/session'
import { PrimaryButton } from '@hanzo/ui/product'
export function SignIn() {
const { account, loading } = useSession()
@@ -34,9 +34,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { Bot, Plus, Terminal, X } from '@hanzogui/lucide-icons-2'
import { ComboBox, type ComboOption } from '~/components/ui/ComboBox'
import { FieldRow, FieldSelect, FieldSlider, FieldSwitch, FieldText, FieldTextArea } from '~/components/ui/Field'
import { SelectMenu, type SelectOption } from '~/components/ui/SelectMenu'
import {
canSubmit,
classifyBuilderError,
@@ -48,6 +45,7 @@ import {
toCreateBody,
} from './logic'
import type { AgentBuilderLoaders, AgentConfig, AgentSpec, BuilderOption, BuilderPrompt, ReasoningEffort } from './types'
import { ComboBox, FieldRow, FieldSelect, FieldSlider, FieldSwitch, FieldText, FieldTextArea, SelectMenu, type ComboOption, type SelectOption } from '@hanzo/ui/product'
/** Async option-list state for the live pickers (model/tool). */
type OptState =
+1 -1
View File
@@ -20,9 +20,9 @@ import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { RefreshCw, TriangleAlert } from '@hanzogui/lucide-icons-2'
import { RecordsView, type FieldDefinition } from '@hanzo/data'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import { BaseDataApi, type BaseRecord } from '~/lib/base-data/api'
import { baseCollectionToFields } from '~/lib/base-data/fields'
import { BackendStateCard, classifyBackend, type BackendState } from '@hanzo/ui/product'
type LoadState =
| { phase: 'loading' }
@@ -21,11 +21,10 @@ import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { ArrowLeft, Pencil, Save, Trash2, TriangleAlert, X } from '@hanzogui/lucide-icons-2'
import { RecordDetail, RecordForm, type FieldDefinition } from '@hanzo/data'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { BaseDataApi, type BaseRecord } from '~/lib/base-data/api'
import { baseCollectionToFields } from '~/lib/base-data/fields'
import { recordLabel, savePayload } from './records'
import { BackendStateCard, PrimaryButton, classifyBackend, type BackendState } from '@hanzo/ui/product'
export interface RecordDetailViewProps {
/** A configured Base client (transport already wired — e.g. the `/superbase` proxy). */
+1 -3
View File
@@ -16,9 +16,6 @@ import { useCallback, useMemo, useState, type CSSProperties } from 'react'
import { Button, Card, Input, Label, Text, XStack, YStack } from '@hanzo/gui'
import { GripVertical, Plus, Trash2, TriangleAlert, ArrowUp, ArrowDown } from '@hanzogui/lucide-icons-2'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { FieldSwitch } from '~/components/ui/Field'
import { classifyBackend } from '~/components/ui/BackendState'
import type { FrameworkClient } from '~/lib/framework/client'
import type { DocType, Fieldtype } from '~/lib/framework/types'
import {
@@ -32,6 +29,7 @@ import {
type BuilderField,
} from './builder-logic'
import { CHEVRON } from '~/components/ui/Field'
import { FieldSwitch, PrimaryButton, classifyBackend } from '@hanzo/ui/product'
// A value≠label native <select>, themed with the app CSS vars (same idiom as
// ui/Field.tsx FieldSelect, but here options carry a distinct value + label).
@@ -16,15 +16,12 @@ import { useCallback, useEffect, useState } from 'react'
import { Card, Text, XStack, YStack } from '@hanzo/gui'
import { Boxes, Plus, TriangleAlert } from '@hanzogui/lucide-icons-2'
import { PageHeader } from '~/components/ui/PageHeader'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { EmptyState } from '~/components/ui/EmptyState'
import { Loader } from '~/components/ui/Loader'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import type { FrameworkClient } from '~/lib/framework/client'
import type { DocType } from '~/lib/framework/types'
import { moduleDoctypes } from '~/lib/framework/fields'
import { CollectionBuilder } from './CollectionBuilder'
import { BackendStateCard, EmptyState, PageHeader, PrimaryButton, classifyBackend, type BackendState } from '@hanzo/ui/product'
export interface CollectionsBrowserProps {
client: FrameworkClient
+1 -2
View File
@@ -19,12 +19,11 @@ import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { ArrowLeft, Ban, Globe, Pencil, PenOff, Save, Send, Trash2, TriangleAlert, X } from '@hanzogui/lucide-icons-2'
import { RecordDetail, RecordForm, type FieldDefinition, type SelectOption } from '@hanzo/data'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import type { FrameworkClient } from '~/lib/framework/client'
import type { DocType, FrameworkDoc } from '~/lib/framework/types'
import { docTypeToFields, toRecord, enrichLinks, savePayload, newDraft, statusField, titleOf, hasProjectField, PROJECT_FIELD } from '~/lib/framework/fields'
import { loadLinkOptions, makeFieldOptions } from './data'
import { BackendStateCard, PrimaryButton, classifyBackend, type BackendState } from '@hanzo/ui/product'
export interface DocTypeDetailProps {
client: FrameworkClient
+1 -1
View File
@@ -19,12 +19,12 @@ import { Button } from '@hanzo/gui'
import { RefreshCw } from '@hanzogui/lucide-icons-2'
import { RecordsView, type FieldDefinition, type SelectOption } from '@hanzo/data'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import type { FrameworkClient } from '~/lib/framework/client'
import type { DocType, FrameworkDoc } from '~/lib/framework/types'
import { docTypeToFields, toRecord, enrichLinks, savePayload, isMediaDoctype, hasProjectField, PROJECT_FIELD } from '~/lib/framework/fields'
import { loadLinkOptions, makeFieldOptions } from './data'
import { MediaGrid } from './MediaGrid'
import { BackendStateCard, classifyBackend, type BackendState } from '@hanzo/ui/product'
type LoadState =
| { phase: 'loading' }
+1 -3
View File
@@ -17,13 +17,11 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { Image as ImageIcon, Trash2, TriangleAlert, Upload } from '@hanzogui/lucide-icons-2'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { EmptyState } from '~/components/ui/EmptyState'
import { classifyBackend } from '~/components/ui/BackendState'
import type { FrameworkClient } from '~/lib/framework/client'
import type { DocType, FrameworkDoc } from '~/lib/framework/types'
import { mediaFileField, titleOf } from '~/lib/framework/fields'
import { uploadMedia, resolveMediaUrl, deleteMediaObject } from './media-upload'
import { EmptyState, PrimaryButton, classifyBackend } from '@hanzo/ui/product'
export interface MediaGridProps {
client: FrameworkClient
+8 -4
View File
@@ -43,7 +43,6 @@ import {
Zap,
} from '@hanzogui/lucide-icons-2'
import { FadeIn } from '~/components/ui/FadeIn'
import { GuidedTour } from '~/components/tour/GuidedTour'
import { useGuideSignals } from '~/lib/guide/use-signals'
import { stepAnchorId } from '~/lib/guide/signals'
@@ -58,6 +57,7 @@ import {
type ProductGuide,
} from '~/lib/guide/spec'
import type { ProductIcon } from '~/lib/products/registry'
import { FadeIn } from '@hanzo/ui/product'
/** Pitch-point icon name → Lucide glyph (kept out of the pure data layer). */
const PITCH_ICON: Record<PitchIcon, ProductIcon> = {
@@ -129,8 +129,8 @@ export function PitchHero({ guide }: { guide: ProductGuide }) {
<Text fontSize="$1" color="$color10" fontWeight="500">
Get started
</Text>
{/* `hz-display` — the ONE way this app sets display leading (globals.css,
same as PublicLanding). It was `style={{ lineHeight: 1.12 }}`, which is a
{/* `hz-display` — the ONE way this app sets display leading (globals.css).
It was `style={{ lineHeight: 1.12 }}`, which is a
correct ratio in plain React but NOT under @hanzo/gui: react-native-web
appends `px` to any numeric style value absent from its unitless list, and
`lineHeight` is absent — so it compiled to `line-height: 1.12px`, a 1px box
@@ -143,8 +143,12 @@ export function PitchHero({ guide }: { guide: ProductGuide }) {
</Text>
</YStack>
<XStack gap="$2" items="center">
{/* Neutral, not filled: the ONE filled action in this card is the
checklist's ACTIVE step below — the thing to do next. A white "Take
the tour" beside it made two buttons compete for the same emphasis,
and the tour is the aside. */}
{tourSteps.length ? (
<Button size="$2" theme="light" icon={<Compass size={14} />} onPress={() => setTourOpen(true)}>
<Button size="$2" icon={<Compass size={14} />} onPress={() => setTourOpen(true)}>
Take the tour
</Button>
) : null}
@@ -22,7 +22,6 @@ import { config } from '~/config'
import { useSession } from '~/lib/auth/session'
import { usePreferences } from '~/lib/products/preferences'
import { BrandMark } from '~/components/ui/BrandLogo'
import { FadeIn } from '~/components/ui/FadeIn'
import {
ONBOARDING_STEPS,
LAST_INDEX,
@@ -42,6 +41,7 @@ import { TeamStep } from '~/components/onboarding/steps/TeamStep'
import { CreditsStep } from '~/components/onboarding/steps/CreditsStep'
import { AiAccessStep } from '~/components/onboarding/steps/AiAccessStep'
import { LaunchStep } from '~/components/onboarding/steps/LaunchStep'
import { FadeIn } from '@hanzo/ui/product'
const STEP_COMPONENTS: Record<StepId, (p: StepProps) => ReactNode> = {
secure: SecureStep,
+24 -4
View File
@@ -10,30 +10,50 @@ import type { ReactNode } from 'react'
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { ArrowLeft, ArrowRight, Check } from '@hanzogui/lucide-icons-2'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { ONBOARDING_STEPS, type StepId, type StepStatus } from '~/lib/onboarding/steps'
import { PrimaryButton } from '@hanzo/ui/product'
/** Title + subtitle header + body for one step. */
/**
* The content area's reserved height. Every step's body occupies at least this
* much, so `actions` lands at the SAME y on every step and Continue never moves
* under the pointer between steps — the whole reason actions is a slot on the
* shell rather than the last child a step happens to render.
*
* A taller step still grows (the area is flex), so this reserves space without
* capping it.
*/
const CONTENT_MIN_HEIGHT = 320
export function StepShell({
title,
subtitle,
children,
actions,
}: {
title: string
subtitle: string
children: ReactNode
/**
* The step's StepActions. It is a SLOT, not a child, so the shell decides
* where it sits — one placement for every step, decided in one place.
*/
actions?: ReactNode
}) {
return (
<YStack gap="$4" flex={1} minW={0}>
<YStack gap="$1.5">
<Text fontSize="$8" fontWeight="800" color="$color12">
<Text testID="onboarding-step-title" fontSize="$8" fontWeight="800" color="$color12">
{title}
</Text>
<Text fontSize="$4" color="$color11">
{subtitle}
</Text>
</YStack>
{children}
<YStack gap="$4" flex={1} minH={CONTENT_MIN_HEIGHT}>
{children}
</YStack>
{actions}
</YStack>
)
}
@@ -61,7 +81,7 @@ export function StepActions({
busy?: boolean
}) {
return (
<XStack gap="$3" items="center" justify="space-between" flexWrap="wrap" pt="$2">
<XStack testID="onboarding-actions" gap="$3" items="center" justify="space-between" flexWrap="wrap" pt="$2">
<XStack>
{onBack ? (
<Button size="$3" chromeless disabled={busy} icon={<ArrowLeft size={16} />} onPress={onBack}>
@@ -24,12 +24,11 @@ import { Wand2, KeyRound, LogIn, Check, Plus } from '@hanzogui/lucide-icons-2'
import { AiAccountsApi } from '~/lib/api/ai-accounts'
import { AiConnectionsApi, AI_CONNECTION_PROVIDERS, type AiConnection, type AiConnectionProvider } from '~/lib/api/ai-connections'
import { ApiError } from '~/lib/api/client'
import { FieldSelect, FieldText } from '~/components/ui/Field'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { useToast } from '~/components/ui/Toast'
import { ChoiceCard, StepShell, StepActions } from '~/components/onboarding/parts'
import { withAiChoice, type AiChoice } from '~/lib/onboarding/steps'
import type { StepProps } from '~/components/onboarding/types'
import { FieldSelect, FieldText, PrimaryButton } from '@hanzo/ui/product'
const providerLabel = (id: AiConnectionProvider): string => AI_CONNECTION_PROVIDERS.find((p) => p.id === id)?.label ?? id
const providerFromLabel = (label: string): AiConnectionProvider =>
@@ -121,7 +120,19 @@ export function AiAccessStep({ state, patch, next, skip, back, isFirst }: StepPr
const connectedLabels = connections.map((c) => c.provider).join(', ')
return (
<StepShell title="AI access" subtitle="Choose how you want to power AI. You can change or combine these anytime in AI Accounts.">
<StepShell
title="AI access"
subtitle="Choose how you want to power AI. You can change or combine these anytime in AI Accounts."
actions={
<StepActions
onBack={isFirst ? undefined : back}
onSkip={skip}
skipLabel="Decide later"
onContinue={next}
continueLabel="Continue"
/>
}
>
<ChoiceCard
icon={<Wand2 size={20} />}
title="Let Hanzo power it"
@@ -247,14 +258,6 @@ export function AiAccessStep({ state, patch, next, skip, back, isFirst }: StepPr
{err}
</Text>
) : null}
<StepActions
onBack={isFirst ? undefined : back}
onSkip={skip}
skipLabel="Decide later"
onContinue={next}
continueLabel="Continue"
/>
</StepShell>
)
}
@@ -12,9 +12,9 @@ import { FileText, Sparkles } from '@hanzogui/lucide-icons-2'
import { config } from '~/config'
import { getBrand } from '~/lib/branding/brands'
import { FieldSwitch } from '~/components/ui/Field'
import { StepShell, StepActions, InlineLink } from '~/components/onboarding/parts'
import type { StepProps } from '~/components/onboarding/types'
import { FieldSwitch } from '@hanzo/ui/product'
export function ConsentStep({ state, patch, next, back, isFirst }: StepProps) {
const brand = getBrand()
@@ -33,7 +33,17 @@ export function ConsentStep({ state, patch, next, back, isFirst }: StepProps) {
}
return (
<StepShell title="Data & consent" subtitle={`A couple of choices about how ${config.brandName} handles your data.`}>
<StepShell
title="Data & consent"
subtitle={`A couple of choices about how ${config.brandName} handles your data.`}
actions={
// No Skip here on purpose: accepting the Terms is not optional, so an
// affordance that skips past them would be dishonest. Continue stays
// disabled until the box is ticked. The data-sharing choice beside it IS
// optional and defaults to off — leaving it alone is the skip.
<StepActions onBack={isFirst ? undefined : back} onContinue={commit} continueDisabled={!agreed} />
}
>
<Card p="$4" gap="$3" borderWidth={1} borderColor="$borderColor">
<XStack gap="$3" items="flex-start" justify="space-between">
<XStack gap="$3" items="center" flex={1} minW={0}>
@@ -80,7 +90,6 @@ export function ConsentStep({ state, patch, next, back, isFirst }: StepProps) {
</Text>
) : null}
<StepActions onBack={isFirst ? undefined : back} onContinue={commit} continueDisabled={!agreed} />
</StepShell>
)
}
+22 -18
View File
@@ -7,8 +7,8 @@
* browser — the console never sees a PAN. REAL: `BillingApi.paymentConfig` mounts the
* element (`useSquareCard`), `createPaymentMethod({token})` vaults the card (the
* commerce handler grants/extends the trial credit as a side-effect, $1 verify-then-
* void, no charge), `welcome()` claims the fixed starter grant (idempotent), and
* `balance()` shows the granted balance. Skippable — credits can be added later.
* void, no charge), and `balance()` shows the granted balance. Skippable — credits
* can be added later.
*/
import { useEffect, useRef, useState } from 'react'
import { Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
@@ -19,10 +19,10 @@ import type { CloudBalance } from '~/lib/api/wallet'
import { useSquareCard } from '~/lib/billing/use-square-card'
import { trialCents, spendableCents, balanceSplitLabel, invalidateBalance } from '~/lib/billing/live-balance'
import { ApiError } from '~/lib/api/client'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { useToast } from '~/components/ui/Toast'
import { StepShell, StepActions } from '~/components/onboarding/parts'
import type { StepProps } from '~/components/onboarding/types'
import { PrimaryButton } from '@hanzo/ui/product'
const usd = (c: number): string => `$${(c / 100).toFixed(2)}`
@@ -70,9 +70,9 @@ export function CreditsStep({ next, skip, back, isFirst }: StepProps) {
try {
const token = await card.tokenize()
await BillingApi.createPaymentMethod({ type: 'card', token })
// Claim the fixed starter grant too (idempotent server-side); the card-added
// handler also extends the trial — both are safe to run.
await BillingApi.welcome().catch(() => undefined)
// The trial credit is granted SERVER-SIDE as a side-effect of vaulting the card.
// The browser never mints its own credit — the only credit mint is the
// mint-gated POST /v1/billing/credit, which a tenant session cannot call.
const bal = await BillingApi.balance().catch(() => balance)
if (!mounted.current) return
setBalance(bal)
@@ -91,7 +91,21 @@ export function CreditsStep({ next, skip, back, isFirst }: StepProps) {
const split = balanceSplitLabel(balance)
return (
<StepShell title="Free trial credits" subtitle="Add a card to unlock free trial credits. No charge now — it just keeps your account ready when the trial ends.">
<StepShell
title="Free trial credits"
subtitle="Add a card to unlock free trial credits. No charge now — it just keeps your account ready when the trial ends."
actions={
<StepActions
onBack={isFirst ? undefined : back}
onSkip={phase === 'ready' && !isUnlocked ? skip : undefined}
skipLabel="Skip for now"
onContinue={next}
continueLabel="Continue"
continueDisabled={phase === 'ready' && !isUnlocked}
busy={adding}
/>
}
>
{phase === 'loading' ? (
<Card p="$5" items="center" borderWidth={1} borderColor="$borderColor">
<Spinner size="large" color="$color11" />
@@ -121,7 +135,7 @@ export function CreditsStep({ next, skip, back, isFirst }: StepProps) {
<XStack gap="$2" items="center">
<CreditCard size={18} color="var(--color10)" />
<Text fontSize="$4" fontWeight="700" color="$color12">
Payments aren't set up on this deployment
Payments aren't set up for this organization
</Text>
</XStack>
<Text fontSize="$3" color="$color11">
@@ -195,16 +209,6 @@ export function CreditsStep({ next, skip, back, isFirst }: StepProps) {
</XStack>
</Card>
)}
<StepActions
onBack={isFirst ? undefined : back}
onSkip={phase === 'ready' && !isUnlocked ? skip : undefined}
skipLabel="Skip for now"
onContinue={next}
continueLabel="Continue"
continueDisabled={phase === 'ready' && !isUnlocked}
busy={adding}
/>
</StepShell>
)
}
@@ -12,8 +12,8 @@ import { EVENTS } from '@hanzo/event'
import { config } from '~/config'
import { StepShell, ChoiceCard } from '~/components/onboarding/parts'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import type { StepProps } from '~/components/onboarding/types'
import { PrimaryButton } from '@hanzo/ui/product'
const TILES: { icon: typeof MessageSquare; title: string; description: string; to: string }[] = [
{ icon: MessageSquare, title: 'Start a chat', description: 'Talk to the latest Zen model right now.', to: '/chat' },
@@ -30,11 +30,11 @@ export function LaunchStep({ finish, back, isFirst }: StepProps) {
<XStack gap="$2" items="center">
<PartyPopper size={20} color="var(--green11)" />
<Text fontSize="$5" fontWeight="700" color="$green11">
Your workspace is set up
Your organization is set up
</Text>
</XStack>
<Text fontSize="$3" color="$color11">
Two-factor, consent, workspace, credits, and AI access are all configured.
Two-factor, consent, organization, credits, and AI access are all configured.
</Text>
</Card>
+16 -12
View File
@@ -13,9 +13,9 @@ import { ShieldCheck, KeyRound, Copy } from '@hanzogui/lucide-icons-2'
import { useSession } from '~/lib/auth/session'
import { ApiError } from '~/lib/api/client'
import { MfaApi, type MfaSetup } from '~/lib/api/mfa'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { StepShell, StepActions } from '~/components/onboarding/parts'
import type { StepProps } from '~/components/onboarding/types'
import { PrimaryButton } from '@hanzo/ui/product'
function copy(value: string) {
if (typeof navigator !== 'undefined' && navigator.clipboard) void navigator.clipboard.writeText(value).catch(() => {})
@@ -62,7 +62,21 @@ export function SecureStep({ next, skip, back, isFirst }: StepProps) {
}
return (
<StepShell title="Secure your account" subtitle="Add two-factor authentication so a stolen password isn't enough to sign in.">
<StepShell
title="Secure your account"
subtitle="Add two-factor authentication so a stolen password isn't enough to sign in."
actions={
<StepActions
onBack={isFirst ? undefined : back}
onSkip={enabled ? undefined : skip}
skipLabel="Skip securing my account"
onContinue={next}
continueLabel="Continue"
continueDisabled={!enabled}
busy={busy}
/>
}
>
{enabled ? (
<Card p="$4" gap="$3" borderWidth={1} borderColor="$green7" bg="$green2">
<XStack gap="$2" items="center">
@@ -161,16 +175,6 @@ export function SecureStep({ next, skip, back, isFirst }: StepProps) {
</XStack>
</Card>
)}
<StepActions
onBack={isFirst ? undefined : back}
onSkip={enabled ? undefined : skip}
skipLabel="Skip securing my account"
onContinue={next}
continueLabel="Continue"
continueDisabled={!enabled}
busy={busy}
/>
</StepShell>
)
}
+21 -15
View File
@@ -1,11 +1,11 @@
'use client'
/**
* Step 3 — Your workspace. Confirms the org the user is in (created at first-run
* Step 3 — Your organization. Confirms the org the user is in (created at first-run
* org onboarding) and lets them optionally NAME it. REAL: reads/writes the org via
* the org-admin `TeamApi` (`get-organization` / `update-organization`, pinned to the
* caller's own org server-side). Renaming is best-effort — a read/write failure never
* blocks the flow (the workspace already exists).
* blocks the flow (the organization already exists).
*/
import { useEffect, useState } from 'react'
import { Card, Input, Spinner, Text, XStack, YStack } from '@hanzo/gui'
@@ -20,7 +20,7 @@ import { useToast } from '~/components/ui/Toast'
import { StepShell, StepActions } from '~/components/onboarding/parts'
import type { StepProps } from '~/components/onboarding/types'
export function TeamStep({ next, back, isFirst }: StepProps) {
export function TeamStep({ next, skip, back, isFirst }: StepProps) {
const { account } = useSession()
const toast = useToast()
const org = account?.owner || currentOrg()
@@ -52,9 +52,9 @@ export function TeamStep({ next, back, isFirst }: StepProps) {
setBusy(true)
try {
await TeamApi.updateOrganization({ ...record, displayName: name })
toast.success('Workspace renamed', name)
toast.success('Organization renamed', name)
} catch (e) {
toast.error('Could not rename the workspace', e instanceof ApiError ? e.message : undefined)
toast.error('Could not rename the organization', e instanceof ApiError ? e.message : undefined)
} finally {
setBusy(false)
}
@@ -63,7 +63,20 @@ export function TeamStep({ next, back, isFirst }: StepProps) {
}
return (
<StepShell title="Your workspace" subtitle="This is where your projects, usage, and billing live. Name it now, or keep the default.">
<StepShell
title="Your organization"
subtitle="This is where your projects, usage, and billing live. Name it now, or keep the default."
actions={
<StepActions
onBack={isFirst ? undefined : back}
onSkip={skip}
skipLabel="Keep the default"
onContinue={() => void commit()}
continueLabel="Continue"
busy={busy}
/>
}
>
<Card p="$4" gap="$3" borderWidth={1} borderColor="$borderColor">
<XStack gap="$3" items="center">
<YStack width={44} height={44} rounded="$4" items="center" justify="center" bg="$color3">
@@ -82,7 +95,7 @@ export function TeamStep({ next, back, isFirst }: StepProps) {
{record ? (
<YStack gap="$1.5">
<Text fontSize="$2" color="$color11" fontWeight="600">
Workspace name
Organization name
</Text>
<Input value={displayName} onChangeText={setDisplayName} placeholder="Acme Inc" autoCapitalize="words" />
</YStack>
@@ -92,16 +105,9 @@ export function TeamStep({ next, back, isFirst }: StepProps) {
<XStack gap="$2" items="center">
<Users size={16} color="var(--color10)" />
<Text fontSize="$2" color="$color10">
Invite teammates and switch workspaces anytime from the top bar.
Invite teammates and switch organizations anytime from the top bar.
</Text>
</XStack>
<StepActions
onBack={isFirst ? undefined : back}
onContinue={() => void commit()}
continueLabel="Continue"
busy={busy}
/>
</StepShell>
)
}
@@ -16,9 +16,8 @@ import { useCallback, useState } from 'react'
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { Accessibility, ExternalLink } from '@hanzogui/lucide-icons-2'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { toIssues, summarize, IMPACTS, type A11yIssue, type A11ySummary, type Impact } from '~/lib/a11y/scan'
import { DataTable, PageHeader, type Column } from '@hanzo/ui/product'
type State =
| { phase: 'idle' }
@@ -11,9 +11,7 @@ import { useRouter } from 'next/navigation'
import type { CatalogEntry, ProductSubpage } from '~/lib/products/registry'
import { config } from '~/config'
import { PageHeader } from '~/components/ui/PageHeader'
import { EmptyState } from '~/components/ui/EmptyState'
import { FadeIn } from '~/components/ui/FadeIn'
import { EmptyState, FadeIn, PageHeader } from '@hanzo/ui/product'
export function AdminManagedNotice({
entry,
+2 -4
View File
@@ -23,10 +23,8 @@ import {
} from '~/lib/api/admin'
import { config } from '~/config'
import { currentOrg } from '~/lib/org-scope'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { FieldRow, FieldText } from '~/components/ui/Field'
import { ErrorState, asApiError, isForbidden, SuperAdminRequired, type HonestCopy } from '~/components/ui/States'
import { DataTable, FieldRow, FieldText, PageHeader, SecretInput, type Column } from '@hanzo/ui/product'
/** IAM-specific guidance for the honest 404 / unauthorized states. */
const IAM_COPY: HonestCopy = {
@@ -221,7 +219,7 @@ function UsersAdminView({ owner }: { owner: string }) {
<Text fontSize="$4" fontWeight="700">New user in {owner}</Text>
<FieldRow label="Name"><FieldText value={name} onChange={setName} placeholder="jdoe" disabled={saving} /></FieldRow>
<FieldRow label="Email"><FieldText value={email} onChange={setEmail} placeholder="jdoe@hanzo.ai" disabled={saving} /></FieldRow>
<FieldRow label="Password"><FieldText value={password} onChange={setPassword} placeholder="initial password" secure disabled={saving} /></FieldRow>
<FieldRow label="Password"><SecretInput value={password} onChange={setPassword} placeholder="initial password" disabled={saving} copy={false} id="initial-password" /></FieldRow>
<XStack gap="$2" items="center">
<Button self="flex-start" icon={<Plus size={15} />} disabled={saving} onPress={() => void create()}>
{saving ? 'Creating…' : 'Create user'}
+1 -1
View File
@@ -20,11 +20,11 @@ import { Activity, AlertTriangle, Boxes, Building2, Coins, Cpu, Gauge, ScrollTex
import { AdminO11yApi, type FleetO11y, type O11yRange } from '~/lib/api/admin-o11y'
import { MetricCard } from '~/components/ui/Metric'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { LineChart, type ChartPoint } from '~/components/ui/Charts'
import { asApiError, ErrorState, SuperAdminRequired } from '~/components/ui/States'
import { useIsSuperAdmin } from '~/lib/auth/admin'
import { formatMetric } from '~/components/products/overview/living/logic'
import { DataTable, type Column } from '@hanzo/ui/product'
const RANGES: O11yRange[] = ['24h', '7d', '30d']
const pct1 = (v: number): string => (Number.isFinite(v) ? `${v.toFixed(1)}%` : '—')
+1 -7
View File
@@ -21,14 +21,8 @@ import { Text, XStack, YStack } from '@hanzo/gui'
import { Target, Plus, RefreshCw } from '@hanzogui/lucide-icons-2'
import { PLATFORMS, STATUSES, AdsApi, type Campaign, type Summary } from '~/lib/api/ads'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { EmptyState } from '~/components/ui/EmptyState'
import { FieldRow, FieldText, FieldSelect } from '~/components/ui/Field'
import { PageHeader } from '~/components/ui/PageHeader'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { StatusTag } from '~/components/ui/StatusTag'
import { SlideOver } from '~/components/ui/SlideOver'
import { BackendStateCard, DataTable, EmptyState, FieldRow, FieldSelect, FieldText, PageHeader, PrimaryButton, StatusTag, classifyBackend, type BackendState, type Column } from '@hanzo/ui/product'
/** cents (int64 minor units from the backend) → a $ label. */
function money(cents: number): string {
@@ -15,15 +15,12 @@ import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { BadgeCheck, Ban, Coins, HandCoins, Handshake, Percent, RefreshCw, Users, Wallet, Zap } from '@hanzogui/lucide-icons-2'
import { AdminAffiliatesApi, type AdminAffiliate, type AdminAffiliatesView, type SweepResult } from '~/lib/api/admin-affiliates'
import { PageHeader } from '~/components/ui/PageHeader'
import { MetricCard } from '~/components/ui/Metric'
import { EmptyState } from '~/components/ui/EmptyState'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { FieldRow, FieldSelect, FieldText } from '~/components/ui/Field'
import { asApiError, ErrorState, isForbidden, SuperAdminRequired } from '~/components/ui/States'
import { ApiError } from '~/lib/api'
import { dollarsToCents, percentToBps, ratePct, shortDate, statusLabel, statusColor, usd } from './affiliates/logic'
import { toneColor } from '~/components/ui/tone'
import { EmptyState, FieldRow, FieldSelect, FieldText, PageHeader, PrimaryButton } from '@hanzo/ui/product'
/** The backend caps the DIRECT (L1) rate at 9300 bps so the L1+L2+L3 schedule ≤ 100% of margin. */
const MAX_RATE_BPS = 9300
+2 -5
View File
@@ -16,14 +16,11 @@ import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { BadgeCheck, Check, Coins, Copy, HandCoins, Handshake, RefreshCw, Users, Wallet } from '@hanzogui/lucide-icons-2'
import { AffiliatesApi, type AffiliateOverview } from '~/lib/api/affiliates'
import { PageHeader } from '~/components/ui/PageHeader'
import { MetricCard } from '~/components/ui/Metric'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { FieldRow, FieldText } from '~/components/ui/Field'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import { payoutMethodLabel, ratePct, shortDate, statusLabel, statusColor, usd } from './affiliates/logic'
import { EarningsPanel, LinksPanel, LeaderboardPanel } from './affiliates/panels'
import { toneColor } from '~/components/ui/tone'
import { BackendStateCard, FieldRow, FieldText, PageHeader, PrimaryButton, classifyBackend, type BackendState } from '@hanzo/ui/product'
type Async<T> =
| { phase: 'loading' }
@@ -203,7 +200,7 @@ function AffiliateDashboard({
</Text>
) : (
<Text fontSize="$3" color="$color11">
Share your link. Every team that signs up through it earns you {ratePct(data.rateBps)} of their spend, every
Share your link. Every organization that signs up through it earns you {ratePct(data.rateBps)} of their spend, every
period.
</Text>
)}
+1 -4
View File
@@ -64,10 +64,6 @@ import {
type MetricsRange,
} from '~/lib/api/agents'
import { productColorHex } from '~/lib/products/colors'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { EmptyState } from '~/components/ui/EmptyState'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import { LineChart, Sparkline, type ChartPoint } from '~/components/ui/Charts'
import { useDetailPane } from '~/components/DetailPane'
import { MetricCard, Delta } from './functions/parts'
@@ -82,6 +78,7 @@ import {
VersionBadge,
} from './agents/parts'
import { AgentDetailView, NewAgentForm } from './agents/forms'
import { BackendStateCard, DataTable, EmptyState, PageHeader, classifyBackend, type BackendState, type Column } from '@hanzo/ui/product'
const PAGE_SIZE = 8
const DASH = '—'
+1 -3
View File
@@ -18,10 +18,8 @@ import { Button, Text } from '@hanzo/gui'
import { RefreshCw } from '@hanzogui/lucide-icons-2'
import { restGet, cloudProxyV1Url } from '~/lib/api/client'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { StatusTag } from '~/components/ui/StatusTag'
import { interpretPlatformError, PlatformStateCard, type PlatformError } from './platform/state'
import { DataTable, PageHeader, StatusTag, type Column } from '@hanzo/ui/product'
type Alert = {
id: string
+1 -3
View File
@@ -35,12 +35,10 @@ import {
type Top,
} from '~/lib/api/analytics'
import { fmtUsd, fmtInt } from '~/lib/api/functions'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { LineChart, Donut, type ChartPoint, type Slice } from '~/components/ui/Charts'
import { RAMP } from '~/lib/theme/ramp'
import { classifyBackend, BackendStateCard, type BackendState } from '~/components/ui/BackendState'
import { MetricCard } from './functions/parts'
import { BackendStateCard, DataTable, PageHeader, classifyBackend, type BackendState, type Column } from '@hanzo/ui/product'
const fmtPct = (n: number): string => (Number.isFinite(n) ? `${(n * 100).toFixed(1)}%` : '—')
@@ -21,11 +21,10 @@ import {
type AnnotationQueueItem,
type O11yPageMeta,
} from '~/lib/api'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { RuntimeNotice } from './observability/RuntimeNotice'
import { Pager } from './observability/Pager'
import { fmtDate } from './observability/format'
import { DataTable, PageHeader, type Column } from '@hanzo/ui/product'
const PAGE_LIMIT = 50
+6 -37
View File
@@ -1,12 +1,12 @@
'use client'
/**
* API Keys — create, copy, rotate, and revoke the per-user `hk-` Cloud API key.
* API Keys — create, copy, rotate, and revoke the per-user `sk-` Cloud API key.
*
* The key is minted server-side (`/keys` route → IAM, app-on-behalf as the
* confidential console client); the browser only sends its session cookie and
* never holds a long-lived secret beyond the one-time reveal at creation. This
* is the real credential the user presents as `Authorization: Bearer hk-…` to
* is the real credential the user presents as `Authorization: Bearer sk-…` to
* the SDKs, CLI, and the api.hanzo.ai gateway.
*
* `ApiKeysView` is the bare surface so Settings can embed it as a tab;
@@ -22,8 +22,8 @@ import { EVENTS } from '@hanzo/event'
import { ApiError, KeysApi, type KeyStatus } from '~/lib/api'
import { useSession } from '~/lib/auth/session'
import { config } from '~/config'
import { PageHeader } from '~/components/ui/PageHeader'
import { ErrorState } from '~/components/ui/States'
import { CopyButton, PageHeader, SecretInput } from '@hanzo/ui/product'
// The docs site serves everything under the `/docs` base path (a bare
// `docs.hanzo.ai/<slug>` 404s), so the API reference is `/docs/api` — white-labeled
@@ -37,25 +37,6 @@ function fmtKeyDate(iso?: string): string {
return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}
/** Copy-to-clipboard button with a transient confirmed state. */
function CopyButton({ value, label = 'Copy' }: { value: string; label?: string }) {
const [copied, setCopied] = useState(false)
const copy = async () => {
try {
await navigator.clipboard?.writeText(value)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {
/* clipboard blocked (insecure context) — the value is already visible */
}
}
return (
<Button size="$2" icon={copied ? <Check size={14} /> : <Copy size={14} />} onPress={() => void copy()}>
{copied ? 'Copied' : label}
</Button>
)
}
/** The full secret, shown ONCE right after creation. */
function NewKeyCard({ accessKey, onDone }: { accessKey: string; onDone: () => void }) {
return (
@@ -66,19 +47,7 @@ function NewKeyCard({ accessKey, onDone }: { accessKey: string; onDone: () => vo
Your new API key
</Text>
</XStack>
<XStack gap="$2" items="center" flexWrap="wrap">
<Text
fontSize="$3"
color="$color12"
flex={1}
minW={260}
selectable
style={{ fontFamily: 'monospace' }}
>
{accessKey}
</Text>
<CopyButton value={accessKey} />
</XStack>
<SecretInput value={accessKey} id="api-key" />
<XStack gap="$2" items="center">
<TriangleAlert size={14} color="$yellow10" />
<Text fontSize="$2" color="$color11">
@@ -190,7 +159,7 @@ export function ApiKeysView() {
Cloud API key
</Text>
<Text fontSize="$2" color="$color10" style={{ fontFamily: 'monospace' }}>
{status?.keyPrefix ? `${status.keyPrefix}` : 'hk-…'}
{status?.keyPrefix ? `${status.keyPrefix}` : 'sk-…'}
</Text>
{fmtKeyDate(status?.createdAt) ? (
<Text fontSize="$1" color="$color10">
@@ -233,7 +202,7 @@ export function ApiKeysView() {
</XStack>
<Text fontSize="$3" color="$color11">
One key for your account, scoped to your organization. Use it as{' '}
<Text fontSize="$2" style={{ fontFamily: 'monospace' }}>Authorization: Bearer hk-</Text> with the SDKs,
<Text fontSize="$2" style={{ fontFamily: 'monospace' }}>Authorization: Bearer sk-</Text> with the SDKs,
CLI, and the api.hanzo.ai gateway.
</Text>
<Button
+1 -6
View File
@@ -35,12 +35,7 @@ import { AppWindow, Cpu, ExternalLink, Globe, HardDrive, Pencil, RefreshCw, Rock
import { config } from '~/config'
import { AppsApi, builderEditUrl, type App, type AppDeployment } from '~/lib/api/apps'
import { PlatformSitesApi } from '~/lib/api/platform-sites'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { StatusTag } from '~/components/ui/StatusTag'
import { EmptyState } from '~/components/ui/EmptyState'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import { BackendStateCard, DataTable, EmptyState, PageHeader, PrimaryButton, StatusTag, classifyBackend, type BackendState, type Column } from '@hanzo/ui/product'
type Async<T> =
| { phase: 'loading' }
@@ -15,10 +15,8 @@ import { Button, Text } from '@hanzo/gui'
import { RefreshCw } from '@hanzogui/lucide-icons-2'
import { restGet } from '~/lib/api/client'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { StatusTag } from '~/components/ui/StatusTag'
import { interpretPlatformError, PlatformStateCard, type PlatformError } from './platform/state'
import { DataTable, PageHeader, StatusTag, type Column } from '@hanzo/ui/product'
const paas = (path: string) => `/paas/${path.replace(/^\/+/, '')}`
@@ -15,15 +15,12 @@ import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { BadgeCheck, Ban, Coins, Github, HandCoins, RefreshCw, Wallet, Zap } from '@hanzogui/lucide-icons-2'
import { AdminAuthorsApi, type AdminAuthor, type AdminAuthorsView, type SweepResult } from '~/lib/api/admin-authors'
import { PageHeader } from '~/components/ui/PageHeader'
import { MetricCard } from '~/components/ui/Metric'
import { EmptyState } from '~/components/ui/EmptyState'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { FieldRow, FieldSelect, FieldText } from '~/components/ui/Field'
import { asApiError, ErrorState, isForbidden, SuperAdminRequired } from '~/components/ui/States'
import { ApiError } from '~/lib/api'
import { dollarsToCents, sharePct, shortDate, statusLabel, statusColor, usd } from './authors/logic'
import { toneColor } from '~/components/ui/tone'
import { EmptyState, FieldRow, FieldSelect, FieldText, PageHeader, PrimaryButton } from '@hanzo/ui/product'
type Async<T> =
| { phase: 'loading' }
+2 -5
View File
@@ -31,13 +31,10 @@ import {
} from '@hanzogui/lucide-icons-2'
import { AuthorsApi, type AuthorOverview } from '~/lib/api/authors'
import { PageHeader } from '~/components/ui/PageHeader'
import { MetricCard } from '~/components/ui/Metric'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { FieldRow, FieldText } from '~/components/ui/Field'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import { payoutMethodLabel, sharePct, shortDate, statusLabel, statusColor, usd, verifyMethodLabel } from './authors/logic'
import { toneColor } from '~/components/ui/tone'
import { BackendStateCard, FieldRow, FieldText, PageHeader, PrimaryButton, classifyBackend, type BackendState } from '@hanzo/ui/product'
type Async<T> =
| { phase: 'loading' }
@@ -122,7 +119,7 @@ function AuthorConnect({ onConnected }: { onConnected: () => void }) {
<Text fontSize="$3" color="$color11">
The OSS Author program pays you an ongoing share of the platform spend of every organization that deploys your
open-source project on Hanzo. Connect GitHub, verify the repositories you own, and earn your share every period
for as long as teams build on your work. New authors are reviewed by our team before earnings begin.
for as long as organizations build on your work. New authors are reviewed by our team before earnings begin.
</Text>
<YStack gap="$1">
<FieldRow label="GitHub login (optional)">
Binary file not shown.
@@ -26,15 +26,8 @@ import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { Blocks, ListChecks, Play, Plus, Power, RefreshCw, Trash2, Workflow } from '@hanzogui/lucide-icons-2'
import { AutomationsApi, type AutomationFlow, type FlowRun, type Piece } from '~/lib/api/automations'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { EmptyState } from '~/components/ui/EmptyState'
import { FieldSelect, FieldText } from '~/components/ui/Field'
import { Loader } from '~/components/ui/Loader'
import { PageHeader } from '~/components/ui/PageHeader'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { SlideOver } from '~/components/ui/SlideOver'
import { StatusTag } from '~/components/ui/StatusTag'
import {
authLabel,
capabilitySummary,
@@ -49,6 +42,7 @@ import {
validateFlowName,
} from './automations/logic'
import { toneColor } from '~/components/ui/tone'
import { BackendStateCard, DataTable, EmptyState, FieldSelect, FieldText, PageHeader, PrimaryButton, StatusTag, classifyBackend, type BackendState, type Column } from '@hanzo/ui/product'
/** Cap the rendered connector cards; search/category narrow the rest (honest note). */
const CATALOG_RENDER_CAP = 240
@@ -14,9 +14,8 @@ import { FlaskConical, RefreshCw } from '@hanzogui/lucide-icons-2'
import { ApiError } from '~/lib/api'
import { EnablementApi, type UserEnablementItem } from '~/lib/api/admin-cockpit'
import { PageHeader } from '~/components/ui/PageHeader'
import { EmptyState } from '~/components/ui/EmptyState'
import { ErrorState, asApiError } from '~/components/ui/States'
import { EmptyState, PageHeader } from '@hanzo/ui/product'
export function BetaFeaturesModule() {
const [view, setView] = useState<{ items: UserEnablementItem[]; betas: UserEnablementItem[] } | null>(null)
+1 -2
View File
@@ -14,8 +14,7 @@ import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { RefreshCw } from '@hanzogui/lucide-icons-2'
import { ApiError, BotApi } from '~/lib/api'
import { PageHeader } from '~/components/ui/PageHeader'
import { StatusTag } from '~/components/ui/StatusTag'
import { PageHeader, StatusTag } from '@hanzo/ui/product'
/** Operator surfaces for the bot — public product domains, opened in a tab. */
const SURFACES: ReadonlyArray<{ label: string; href: string; hint: string }> = [
+1 -2
View File
@@ -31,8 +31,7 @@ import { Bot, Cpu, ExternalLink, Monitor, Play, RefreshCw, Square, Terminal } fr
import { ApiError } from '~/lib/api'
import { BotsApi, type BotRunRow, type BotSurface } from '~/lib/api/bots'
import { PageHeader } from '~/components/ui/PageHeader'
import { StatusTag } from '~/components/ui/StatusTag'
import { PageHeader, StatusTag } from '@hanzo/ui/product'
/** Statuses a run can't be stopped from — the Stop button is disabled on these. */
const TERMINAL = new Set([
+1 -3
View File
@@ -13,10 +13,8 @@ import { Button, Text } from '@hanzo/gui'
import { RefreshCw } from '@hanzogui/lucide-icons-2'
import { restGet, cloudProxyV1Url } from '~/lib/api/client'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { StatusTag } from '~/components/ui/StatusTag'
import { interpretPlatformError, PlatformStateCard, type PlatformError } from './platform/state'
import { DataTable, PageHeader, StatusTag, type Column } from '@hanzo/ui/product'
type Build = {
id: string
+2 -8
View File
@@ -54,21 +54,15 @@ import {
type Safe,
type Round,
} from '~/lib/api/captable'
import { PageHeader } from '~/components/ui/PageHeader'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { EmptyState } from '~/components/ui/EmptyState'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import { StatusTag } from '~/components/ui/StatusTag'
import { SlideOver } from '~/components/ui/SlideOver'
import { FieldRow, FieldText, FieldSelect, FieldOptionSelect } from '~/components/ui/Field'
import { FieldOptionSelect } from '~/components/ui/Field'
import { MetricCard } from '~/components/ui/Metric'
import { Donut } from '~/components/ui/Charts'
import { RAMP } from '~/lib/theme/ramp'
import { Loader } from '~/components/ui/Loader'
import { useToast } from '~/components/ui/Toast'
import { ConfirmDelete } from '~/components/ui/ConfirmDelete'
import { toneColor } from '~/components/ui/tone'
import { BackendStateCard, ConfirmDelete, DataTable, EmptyState, FieldRow, FieldSelect, FieldText, PageHeader, PrimaryButton, StatusTag, classifyBackend, type BackendState, type Column } from '@hanzo/ui/product'
type Tab = 'summary' | 'stakeholders' | 'shares' | 'classes' | 'fundraising'
const tabPath = (id: Tab): string => (id === 'summary' ? '/captable' : `/captable/${id}`)
+1 -5
View File
@@ -27,12 +27,7 @@ import { Boxes, Check, Pencil, Plus, RefreshCw, Save, Trash2 } from '@hanzogui/l
import { ApiError } from '~/lib/api'
import { CatalogAdminApi, type CatalogEntry, type CatalogEntryInput } from '~/lib/api/catalog-admin'
import { config } from '~/config'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { FieldRow, FieldSelect, FieldSwitch, FieldText, FieldTextArea } from '~/components/ui/Field'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { SlideOver } from '~/components/ui/SlideOver'
import { ConfirmDelete } from '~/components/ui/ConfirmDelete'
import { ErrorState, asApiError, isForbidden, SuperAdminRequired, type HonestCopy } from '~/components/ui/States'
import { MetadataEditor } from './pricing/MetadataEditor'
import {
@@ -49,6 +44,7 @@ import {
specSummary,
} from './catalog/logic'
import { toneVar } from '~/components/ui/tone'
import { ConfirmDelete, DataTable, FieldRow, FieldSelect, FieldSwitch, FieldText, FieldTextArea, PageHeader, PrimaryButton, type Column } from '@hanzo/ui/product'
const CATALOG_COPY: HonestCopy = {
notFound:
+5 -9
View File
@@ -40,16 +40,9 @@ import {
type WorkerRoute,
type WorkerScript,
} from '~/lib/api/cloudflare'
import { PageHeader } from '~/components/ui/PageHeader'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { EmptyState } from '~/components/ui/EmptyState'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import { ConfirmDelete } from '~/components/ui/ConfirmDelete'
import { StatusTag } from '~/components/ui/StatusTag'
import { SlideOver } from '~/components/ui/SlideOver'
import { FieldRow, FieldText, FieldTextArea } from '~/components/ui/Field'
import { useToast } from '~/components/ui/Toast'
import { BackendStateCard, ConfirmDelete, DataTable, EmptyState, FieldRow, FieldText, FieldTextArea, PageHeader, PrimaryButton, StatusTag, classifyBackend, type BackendState, type Column } from '@hanzo/ui/product'
/** Cloudflare brand orange — used ONLY for Cloudflare-specific affordances. */
const CF_TONE = 'var(--color11)' // the Cloudflare lane reads by icon + label, not hue
@@ -167,7 +160,10 @@ function LinkOut({ href, label }: { href: string; label?: string }) {
<XStack
items="center"
gap="$1"
tag="a"
// `render`, not `tag`: gui 8 renamed the host-element prop, and gui drops a prop
// it does not know without erroring — `tag="a"` type-checked, built, and shipped
// a <div>, so this chip was an inert link that nothing reported.
render="a"
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{...({ href, target: '_blank', rel: 'noopener noreferrer' } as any)}
>

Some files were not shown because too many files have changed in this diff Show More