Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e35aa8c693 | ||
|
|
129599fe9e | ||
|
|
a28b0e796d | ||
|
|
a1a13cdc25 | ||
|
|
60b906b448 | ||
|
|
49d11bb9bc | ||
|
|
2b5786685d | ||
|
|
a33acca87f | ||
|
|
ff9d42e0ba | ||
|
|
a70a66b1cc | ||
|
|
344bb1cfc2 |
@@ -3921,3 +3921,113 @@ One placement note that cost a debug cycle: the quickstart branch must return BE
|
||||
reading the ones that exist, and the moments you most need the quickstart — no agents
|
||||
yet, or the registry not answering — are exactly the ones those early returns swallow
|
||||
it in.
|
||||
|
||||
## The rail shows ONE level, and the catalog is not an allow-list (fix/console-six)
|
||||
|
||||
Five defects the owner hit, and all five were one thing twice.
|
||||
|
||||
**Level 2 replaced level 1 in principle and was added to it in practice.** A product's
|
||||
pages expanded INDENTED beneath its row while the whole catalog stayed painted below,
|
||||
so two levels shared the screen. Worse, a PINNED product appeared twice — once under
|
||||
Pinned carrying the pages, once in its category carrying nothing — and the `owns` rule
|
||||
that picked which copy got the sub-list is the tell: a rule to decide which of two
|
||||
identical rows is the real one means there should have been one row.
|
||||
|
||||
The level is a REPLACEMENT now and it is a function of the ROUTE alone. Inside a
|
||||
product the rail is: the category (the way back up), the product as a HEADING, its
|
||||
pages FLUSH, then `More in <category>` — its siblings, so sideways is still one click.
|
||||
Nothing is remembered, so a reload, a deep link and Back cannot disagree with it.
|
||||
Deleted: `NAV_PRODUCT_OPEN_PREF`, `productIsOpen`, `toggleProduct`, the `owns` rule, the
|
||||
per-row expand chevron, and `SubRows`' inert accordion. `nav-accordion.ts` → `nav.ts`
|
||||
(it holds the rail's view model, not one accordion).
|
||||
|
||||
**`SubRow` is now the only level-2 row.** The product-shell face had its own copy plus a
|
||||
private `BILLING_SUBPAGE_ICON` map that was a strict subset of the shared `subpageIcon`.
|
||||
Both gone.
|
||||
|
||||
**"Many products are missing" was a hard-coded list of 13 ids.** `filterBeta` kept only
|
||||
`LAUNCH_PRODUCTS` for anyone who was not a superadmin — a deny-everything-not-named list
|
||||
wearing the word "beta" (zero entries were actually stamped `beta: true`). A hanzo-org
|
||||
user saw 13 of 185 products in 6 of 14 categories. Deleted whole: `LAUNCH_PRODUCTS`,
|
||||
`isLaunchProduct`, `filterBeta`, `isBetaEntry`, `CatalogEntry.beta`, `useAppsBeta`
|
||||
(`lib/products/beta.ts`), `beta-gate.test.ts`, and the `showBeta` argument threaded
|
||||
through `visibleCatalog`, `searchDestinations`, the rail, the palette and the panel.
|
||||
|
||||
That left TWO answers to "may I see this product": the All-products panel said every
|
||||
product is always available (`visibleCatalogByCategory(showAdmin, null)`) and the rail
|
||||
still filtered by the org's entitlement set. Same catalog, two sizes, depending which
|
||||
you asked. The `navCatalog` preference settles it: ON (default) the rail is everything
|
||||
the viewer may SEE — permission only; OFF it narrows to the org's enabled set plus pins
|
||||
and wherever you are. **Measured**: an org whose entitlement plane answers
|
||||
`{enabled: []}` — which silently collapsed the rail to the always-on essentials — now
|
||||
shows all 11 categories.
|
||||
|
||||
Permission (admin surfaces, brand scope) still holds in both states, so nothing on the
|
||||
rail is a surface the viewer cannot open.
|
||||
|
||||
**One search.** The rail had its own text filter that narrowed only the rows it had
|
||||
already drawn — a second search answering a smaller question, next to the header's ⌘K
|
||||
palette and a third hand-rolled copy in the mobile drawer. All three are
|
||||
`CommandSearchBox` now (`height` + `onOpen` are the only differences), so the rail's box
|
||||
opens the one palette, which asks the WHOLE catalog from either level. `filtering` left
|
||||
the rail entirely, and with it `categoryIsOpen`'s `ctx` argument.
|
||||
|
||||
**The two ends of the rail are peers.** `@hanzo/ui@8.0.56` ships an `OrgSwitcher` +
|
||||
`UserMenu` pair built as peers, and it was surveyed: `OrgSwitcher` is better than ours
|
||||
(debounced search, real paging — ours hard-codes page 0, so an admin could never reach
|
||||
org #21 — and race protection), but its trigger is org-only and cannot say `Org /
|
||||
project`; `UserMenu` hard-codes `placement: "bottom-end"` with no prop to change it, so
|
||||
it CANNOT mount at the foot of a rail. Adopting one and not the other is not
|
||||
convergence, so both are local and both are now ONE component: `components/ui/Menu.tsx`
|
||||
— trigger, sheet and rows, worn by `ContextSwitcher` and `AccountMenu` alike.
|
||||
|
||||
- **The org name was replaced by the org's logo.** The trigger branched on `org.logo`
|
||||
and rendered the image INSTEAD of the name; the name survived only in the aria-label.
|
||||
The logo is the MARK now (`OrgMark` resolves logo-else-monogram), the name is the
|
||||
label, always.
|
||||
- `AccountMenu` was `@hanzo/iam`'s `UserMenu` — a second rendering system inside one
|
||||
rail: raw `createElement`, an injected global `hz-iam-*` stylesheet, its own portal, a
|
||||
28px CIRCLE with a one-letter initial against the org's 20px rounded square with two.
|
||||
It is @hanzo/gui + `MenuRow` + `paper` now. Identity still comes from IAM
|
||||
(`useSession`, `signOut`); only the drawing changed.
|
||||
- **The balance row is gone from the menu** — `SidebarWallet` sits one row below it,
|
||||
reads the same `useCloudBalance`/`spendableCents`, links the same `config.payUrl`, and
|
||||
adds the trial/prepaid split. The same number twice, the second time behind a click.
|
||||
- ONE naming rule: `orgLabel(org)` in `account/org-state.ts`. The trigger titled the
|
||||
slug and the rows printed it raw, so the control could read "Acme" over an active row
|
||||
reading "acme". `SidebarBrand` used the raw slug too.
|
||||
- `SidebarBrand` said "the tenant leads the chrome … never the house mark" and then
|
||||
rendered `BrandMark` for any org without a logo — so one org wore the Hanzo glyph in
|
||||
the collapsed rail and its own monogram in the expanded one. It matches its own rule.
|
||||
|
||||
**Two bugs `paper` and the ladder had been hiding.** `paper` declared `bordered: true`,
|
||||
which leaves `borderWidth` at 0 — every anchored sheet in the console met the page with
|
||||
no edge. And the menu took no z-layer, so on a phone (where it opens from inside the
|
||||
account SHEET at `Z.modal`) it was measurable and unclickable; it is `Z.popover` now,
|
||||
which is the layer the ladder documents for exactly this. `up` is a preference rather
|
||||
than a promise (`allowFlip`/`stayInFrame`): at the foot of a desktop rail upward is
|
||||
right, and 365px off the top of a phone it is not.
|
||||
|
||||
**Verification.** `tsc --noEmit` clean · `next build` clean · `vitest` **3340 passed /
|
||||
8 skipped** (267 files) · Playwright **22/22** across `e2e/rail.spec.ts` (new, 6),
|
||||
`account-menu.spec.ts` (4), `level-2-nav.spec.ts` (5) and `find-and-do.spec.ts` (7).
|
||||
`rail.spec.ts` measures what only a browser can: that the level-2 pages sit FLUSH with
|
||||
the row naming the level (x within 2px, the indentation defect), that nothing from
|
||||
another category is painted beside them, that the product is NAMED once and linked
|
||||
zero times, that both triggers share height/left-edge/type/weight and both carry a
|
||||
chevron, and that the sheet is opaque at opacity 1 and fully on screen.
|
||||
|
||||
Two traps worth keeping: `getByLabel('Find an organization')` resolves to the
|
||||
`role="search"` DIV wrapping `SearchInput` (which takes no `aria-label`), so `.fill()`
|
||||
throws — use the placeholder. And typing at the PAGE (`keyboard.type`) puts text in a
|
||||
React-Native-Web `Input`'s DOM without ever raising `onChangeText`: the box reads
|
||||
"vector" while the list is still the unfiltered browse view, so a spec that then looks
|
||||
for "Vector" finds it in the browse list and passes without the search having run. Fill
|
||||
the input and assert where ENTER lands.
|
||||
|
||||
**Found, not fixed — for the palette lane.** The palette's visible list disagrees with
|
||||
the list it acts on. Type `vector` and commit: Enter correctly opens `/vector`
|
||||
(`items[sel]` comes from the ranked `destResults`), while the rows on screen are the
|
||||
unfiltered `browseGroups` in catalog order with `Overview` first and `#cmdk-active` on
|
||||
it. Reproduces via ⌘K, so it predates this work and is not caused by the rail's new
|
||||
search box. `find-and-do.spec.ts` passes because it probes Enter, never DOM order.
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
* against is a menu that is present in the DOM and unreadable — a library that
|
||||
* paints with utility class names renders exactly that in this app, because
|
||||
* Tailwind never scanned node_modules. An `expect(locator).toBeVisible()` would
|
||||
* have passed on the broken build.
|
||||
* have passed on the broken build. That is also why the menu is now the console's
|
||||
* OWN `Menu` on @hanzo/gui rather than a second rendering system injecting its own
|
||||
* global stylesheet: the rows are `MenuRow`, so they are located by ARIA role.
|
||||
*/
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { primeSession } from './_session'
|
||||
@@ -152,7 +154,7 @@ test.describe('account control', () => {
|
||||
expect(onTop).toBe(true)
|
||||
|
||||
// Rows are padded, tall enough to hit, and readable.
|
||||
const rows = await menu.locator('.hz-iam-row').evaluateAll((els) =>
|
||||
const rows = await menu.locator('[role=menuitem], [role=radio]').evaluateAll((els) =>
|
||||
els.map((el) => {
|
||||
const s = getComputedStyle(el)
|
||||
return { pl: s.paddingLeft, h: el.getBoundingClientRect().height, color: s.color, text: (el.textContent ?? '').trim() }
|
||||
@@ -166,7 +168,7 @@ test.describe('account control', () => {
|
||||
}
|
||||
|
||||
// Hover is a real state — the switch-that-rendered-identical class of bug.
|
||||
const first = menu.locator('.hz-iam-row').first()
|
||||
const first = menu.locator('[role=menuitem], [role=radio]').first()
|
||||
const atRest = await first.evaluate((el) => getComputedStyle(el).backgroundColor)
|
||||
await first.hover()
|
||||
expect(await first.evaluate((el) => getComputedStyle(el).backgroundColor)).not.toBe(atRest)
|
||||
@@ -200,7 +202,7 @@ test.describe('account control', () => {
|
||||
|
||||
// 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')
|
||||
await page.getByPlaceholder('Find an organization').fill('acme')
|
||||
// `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' })
|
||||
|
||||
+27
-21
@@ -1,16 +1,17 @@
|
||||
/**
|
||||
* e2e: ONE level-2 nav.
|
||||
*
|
||||
* Clicking into a product must reveal ITS options rather than replacing the screen,
|
||||
* and there must be exactly ONE such nav on screen — not the sidebar's level 2 AND a
|
||||
* competing tab strip in the content, which is what `/models` used to do (eight items
|
||||
* in the rail, four in the content, disagreeing on the index's own name).
|
||||
* Clicking into a product must reveal ITS options, and there must be exactly ONE
|
||||
* such nav on screen — not the sidebar's level 2 AND a competing tab strip in the
|
||||
* content, which is what `/models` used to do (eight items in the rail, four in the
|
||||
* content, disagreeing on the index's own name).
|
||||
*
|
||||
* "Rather than replacing the screen" is now literal on both axes: the product's
|
||||
* sub-pages expand BENEATH its row and the rest of the catalog stays put. The rail
|
||||
* used to swap itself for the product's sub-nav behind a "Back to all products"
|
||||
* button, so these specs assert the other products are still there — that is the
|
||||
* whole point of the change, and the part a future drill would silently undo.
|
||||
* The RAIL owns level 2: inside a product it lists that product's pages, flush, with
|
||||
* the rest of its category beneath them and the category itself as the way back up.
|
||||
* It does not swap itself for a bare sub-nav behind a "Back to all products" button
|
||||
* (nothing to move sideways to), and it does not indent the pages under the product's
|
||||
* row while the whole catalog stays painted below (two levels at once, and the same
|
||||
* product listed twice). These specs pin both failures shut.
|
||||
*
|
||||
* These are assertions only a browser can make. They read COMPUTED style and
|
||||
* GEOMETRY, not source: a strip hidden by a `$lg` media style prop is still in the
|
||||
@@ -82,11 +83,9 @@ test('desktop: the sidebar owns level 2 — the content strip is not a second na
|
||||
const page = await ctx.newPage()
|
||||
await open(page, '/models')
|
||||
|
||||
// The rail expanded Models in place — and did NOT swap itself for it.
|
||||
// No dead-end drill: the way out is the category and the full catalog, both named.
|
||||
await expect(page.getByRole('button', { name: 'Back to all products' })).toHaveCount(0)
|
||||
// The rest of the catalog is still there — "All products" sits at the FOOT of the
|
||||
// product list, so its presence proves the list was never swapped away. This is the
|
||||
// assertion the drill could not have passed.
|
||||
await expect(page.getByRole('button', { name: 'Back to AI' }).first()).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'All products' }).first()).toBeVisible()
|
||||
|
||||
// The index is named what the PRODUCT calls it — Models' index is the Catalog,
|
||||
@@ -119,7 +118,12 @@ test('phone: the strip carries level 2 where the sidebar is a drawer', async ({
|
||||
const labels = await strip(page, 'models').getByRole('button').allInnerTexts()
|
||||
// Routing is admin-only and this account is an ORG admin, not a global one — the
|
||||
// one nav gates it, so a customer is never offered a surface they cannot open.
|
||||
expect(labels).toEqual(['Catalog', 'Leaderboard', 'Blend', 'Settings', 'Status', 'Logs', 'Metrics'])
|
||||
//
|
||||
// The tail reads raw → summary: Logs, then Metrics, then Status LAST (the
|
||||
// live-health verdict comes after the signals it is derived from). f6df104ec8
|
||||
// reordered BASE_SUBPAGES and updated match-core.test.ts but not this spec, so it
|
||||
// asserted the retired order and failed against every build from 8.5.75 on.
|
||||
expect(labels).toEqual(['Catalog', 'Leaderboard', 'Blend', 'Settings', 'Logs', 'Metrics', 'Status'])
|
||||
|
||||
// The strip wraps rather than pushing the page sideways.
|
||||
const scrolls = await page.evaluate(
|
||||
@@ -176,8 +180,8 @@ test('back returns a level without losing pinned state', async ({ browser }) =>
|
||||
await page.waitForTimeout(900)
|
||||
expect(new URL(page.url()).pathname).toBe('/models')
|
||||
|
||||
// Models is still expanded with the same options — browser Back moved the ROUTE,
|
||||
// and the rail followed it without collapsing what the user was looking at.
|
||||
// Models still owns the rail with the same options — browser Back moved the ROUTE,
|
||||
// and the rail is a function of the route, so it followed without losing the level.
|
||||
await expect(visibleTab(page, 'Catalog').first()).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Back to all products' })).toHaveCount(0)
|
||||
|
||||
@@ -187,10 +191,9 @@ test('back returns a level without losing pinned state', async ({ browser }) =>
|
||||
|
||||
/**
|
||||
* Every product that used to carry its own `const TABS` — the whole conversion, in
|
||||
* one sweep. For each: the page renders, the rail expands it in place, and the
|
||||
* content strip is present but PAINTS NOTHING at lg+. That is the "no second nav"
|
||||
* invariant, and it is the thing that regresses the moment someone adds a tab bar
|
||||
* back.
|
||||
* one sweep. For each: the page renders, the rail carries its pages, and the content
|
||||
* strip is present but PAINTS NOTHING at lg+. That is the "no second nav" invariant,
|
||||
* and it is the thing that regresses the moment someone adds a tab bar back.
|
||||
*/
|
||||
const CONVERTED = [
|
||||
'models', 'evals', 'ai-accounts', 'containers', 'analytics', 'finetuning', 'team',
|
||||
@@ -199,6 +202,9 @@ const CONVERTED = [
|
||||
] as const
|
||||
|
||||
test('no product paints a second level-2 nav at lg+', async ({ browser }) => {
|
||||
// Eighteen full page loads in one test — it lands within a hair of the 60s
|
||||
// default and fails on the wrong side of it about as often as the right one.
|
||||
test.slow()
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
|
||||
@@ -211,7 +217,7 @@ test('no product paints a second level-2 nav at lg+', async ({ browser }) => {
|
||||
).toBe('none')
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Back to all products' }),
|
||||
`${id}: the rail expands in place — it must never swap itself for one product`,
|
||||
`${id}: the rail keeps a way out — never a bare drill behind one button`,
|
||||
).toHaveCount(0)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* e2e: the rail — its two levels, its two ends, and the one search.
|
||||
*
|
||||
* These are assertions only a browser can make. They read COMPUTED geometry and
|
||||
* counts, because every defect here is a LAYOUT defect: a level that renders while
|
||||
* the level above it is still painted, a name replaced by a picture, two triggers
|
||||
* that are meant to be peers and are not. `toBeVisible()` resolves to display /
|
||||
* visibility / box-size, which is the only honest test of "is this on screen".
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:4123 npx playwright test rail
|
||||
*/
|
||||
import { test, expect, type Route, type Page } from '@playwright/test'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { primeSession } from './_session'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
const SHOTS = join(process.cwd(), 'e2e-shots')
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|org|auth\/refresh)(\/|$|\?)/
|
||||
|
||||
/** A tenant that HAS uploaded a logo — the case that used to erase the org's name. */
|
||||
const LOGO = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"%3E%3Crect width="24" height="24" fill="%237c5cff"/%3E%3C/svg%3E'
|
||||
|
||||
/**
|
||||
* Every backend answers 401 except the org identity, which answers a real org
|
||||
* WITH a logo and a display name. This spec is about the RAIL, not data, and an
|
||||
* unauthorized read is the state every module already handles honestly.
|
||||
*/
|
||||
async function mock(route: Route) {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
const json = (body: unknown, status = 200) =>
|
||||
route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
|
||||
if (url.pathname.startsWith('/auth/')) return json({ ok: true })
|
||||
if (url.href.includes('get-organization')) {
|
||||
return json({ status: 'ok', data: { name: 'hanzo', displayName: 'Hanzo AI', logo: LOGO } })
|
||||
}
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
if (sameOrigin && !API_RE.test(url.pathname)) return route.continue()
|
||||
return json({ error: 'Sign in to use Hanzo Cloud.' }, 401)
|
||||
}
|
||||
|
||||
/** A hanzo-org user — an admin of their OWN org, NOT a platform super admin.
|
||||
* This is the identity the whole catalog must be available to. */
|
||||
const ACCOUNT = { owner: 'hanzo', name: 'z', email: 'z@hanzo.ai', displayName: 'Z Admin', isAdmin: true }
|
||||
|
||||
async function open(page: Page, path: string) {
|
||||
await page.route('**/*', mock)
|
||||
await primeSession(page, ACCOUNT)
|
||||
await page.goto(`${BASE_URL}${path}`, { waitUntil: 'domcontentloaded' })
|
||||
await page.locator('nav[aria-label="Products"]').first().waitFor({ state: 'attached', timeout: 30_000 })
|
||||
await page.waitForTimeout(1500)
|
||||
}
|
||||
|
||||
/** The persistent rail (the shell mounts the same nav three times; this is the
|
||||
* first in document order, and every geometry check proves it is the painted one). */
|
||||
const rail = (page: Page) => page.locator('nav[aria-label="Products"]').first()
|
||||
const railRow = (page: Page, name: string) =>
|
||||
rail(page).getByRole('button', { name, exact: true }).filter({ visible: true })
|
||||
|
||||
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
|
||||
|
||||
test('level 1 is the whole catalog — a hanzo-org user is not shown a dozen products', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
|
||||
const page = await ctx.newPage()
|
||||
await open(page, '/')
|
||||
|
||||
// The catalog used to be filtered to a hard-coded 13-id launch list, so a
|
||||
// customer saw six categories. Every category the brand admits is here now.
|
||||
const text = await rail(page).innerText()
|
||||
for (const category of ['AI', 'Compute', 'Data', 'Network', 'Security', 'Observe', 'Platform', 'Dev', 'Web3', 'Apps']) {
|
||||
expect(text, `${category} is a section of the catalog`).toContain(category)
|
||||
}
|
||||
// Products from categories the launch list erased entirely.
|
||||
for (const label of ['Vector', 'Functions', 'Gateway', 'Fine-tuning', 'Projects']) {
|
||||
await expect(railRow(page, label).first(), `${label} is reachable`).toBeVisible()
|
||||
}
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'rail-level-1.png') })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('level 2 REPLACES level 1 — the pages are flush and the catalog is not underneath', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
|
||||
const page = await ctx.newPage()
|
||||
await open(page, '/models')
|
||||
|
||||
// The product's own pages are the list.
|
||||
for (const label of ['Catalog', 'Leaderboard', 'Blend']) {
|
||||
await expect(railRow(page, label).first(), `${label} is a rail row`).toBeVisible()
|
||||
}
|
||||
// Its category siblings follow them, so moving sideways is one click.
|
||||
await expect(rail(page)).toContainText('More in AI')
|
||||
await expect(railRow(page, 'Playground').first()).toBeVisible()
|
||||
|
||||
// …and NOTHING from another category is painted: level 1 is gone, not pushed down.
|
||||
for (const label of ['Vector', 'Functions', 'Gateway']) {
|
||||
expect(await railRow(page, label).count(), `${label} belongs to level 1`).toBe(0)
|
||||
}
|
||||
|
||||
// Exactly ONE mention of the product. It used to appear TWICE — once under
|
||||
// Pinned carrying the indented pages, once in its category carrying nothing —
|
||||
// so the rail showed the same product in two places, one of them dead.
|
||||
const lines = (await rail(page).innerText()).split('\n').map((l) => l.trim())
|
||||
expect(lines.filter((l) => l === 'Models'), 'Models is named once').toHaveLength(1)
|
||||
// …and it is a HEADING, not a link: its index page is the first row beneath it,
|
||||
// so a second way to the same page would be the duplication this level removes.
|
||||
expect(await railRow(page, 'Models').count(), 'the name is not a second link').toBe(0)
|
||||
|
||||
// The pages are FLUSH with the row that names the level, not indented under it.
|
||||
const pages = await Promise.all(
|
||||
['Catalog', 'Leaderboard', 'Blend'].map(async (l) => (await railRow(page, l).first().boundingBox())!.x),
|
||||
)
|
||||
const back = (await railRow(page, 'Back to AI').first().boundingBox())!.x
|
||||
for (const [i, x] of pages.entries()) {
|
||||
expect(Math.abs(x - back), `page ${i} is flush with the level, not indented`).toBeLessThanOrEqual(2)
|
||||
}
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'rail-level-2.png') })
|
||||
|
||||
// The way back up is the category the product sits in.
|
||||
await railRow(page, 'Back to AI').first().click()
|
||||
await page.waitForTimeout(900)
|
||||
expect(new URL(page.url()).pathname).toBe('/category/ai')
|
||||
// …and the rail is level 1 again.
|
||||
await expect(railRow(page, 'Vector').first()).toBeVisible()
|
||||
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('the org switcher shows the org NAME even when the org has a logo', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
|
||||
const page = await ctx.newPage()
|
||||
await open(page, '/')
|
||||
|
||||
const org = page.getByTestId('switcher-context').first()
|
||||
// The logo took the name's slot; the name survived only in the aria-label.
|
||||
await expect(org).toContainText('Hanzo AI')
|
||||
// The logo is the MARK beside it, not a replacement for it.
|
||||
await expect(org.locator('img')).toBeVisible()
|
||||
|
||||
await org.click()
|
||||
await page.locator('[role=menu]').first().waitFor()
|
||||
await page.waitForTimeout(600)
|
||||
await page.screenshot({ path: join(SHOTS, 'rail-org-name.png'), animations: 'disabled' })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('the two ends of the rail are peers — same box, same mark, same type', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
|
||||
const page = await ctx.newPage()
|
||||
await open(page, '/')
|
||||
|
||||
const measure = (id: string) =>
|
||||
page.getByTestId(id).first().evaluate((el) => {
|
||||
const s = getComputedStyle(el)
|
||||
const mark = el.querySelector('img, [class*="OrgMark"], span, div')
|
||||
return {
|
||||
height: el.getBoundingClientRect().height,
|
||||
x: el.getBoundingClientRect().x,
|
||||
font: s.fontSize,
|
||||
weight: s.fontWeight,
|
||||
// The chevron: both must carry one, or one reads as a caption.
|
||||
svgs: el.querySelectorAll('svg').length,
|
||||
markH: mark ? Math.round(mark.getBoundingClientRect().height) : 0,
|
||||
}
|
||||
})
|
||||
|
||||
const org = await measure('switcher-context')
|
||||
const account = await measure('nav-user')
|
||||
|
||||
expect(account.height, 'same height').toBe(org.height)
|
||||
expect(account.x, 'same left edge').toBe(org.x)
|
||||
expect(account.font, 'same type size').toBe(org.font)
|
||||
expect(account.weight, 'same type weight').toBe(org.weight)
|
||||
expect(account.svgs > 0 && org.svgs > 0, 'both carry a chevron').toBe(true)
|
||||
|
||||
// The account is at the FOOT, the org at the HEAD — peers, not a stack.
|
||||
const orgY = (await page.getByTestId('switcher-context').first().boundingBox())!.y
|
||||
const accY = (await page.getByTestId('nav-user').first().boundingBox())!.y
|
||||
expect(accY).toBeGreaterThan(orgY)
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'rail-switchers.png') })
|
||||
|
||||
// Both open onto the same sheet, and the account's opens UPWARD so it stays
|
||||
// on screen from the foot of the rail.
|
||||
await page.getByTestId('nav-user').first().click()
|
||||
const menu = page.locator('[role=menu]').first()
|
||||
await menu.waitFor()
|
||||
await page.waitForTimeout(600)
|
||||
const box = (await menu.boundingBox())!
|
||||
expect(box.y, 'the account sheet opens upward, fully on screen').toBeGreaterThanOrEqual(0)
|
||||
expect(box.y + box.height).toBeLessThanOrEqual(1001)
|
||||
// It PAINTS: an opaque sheet, not a transparent stack the rail reads through.
|
||||
const paint = await menu.evaluate((el) => {
|
||||
const s = getComputedStyle(el)
|
||||
return { bg: s.backgroundColor, opacity: s.opacity }
|
||||
})
|
||||
expect(paint.bg).not.toBe('rgba(0, 0, 0, 0)')
|
||||
expect(Number(paint.opacity)).toBe(1)
|
||||
await page.screenshot({ path: join(SHOTS, 'rail-account-menu.png'), animations: 'disabled' })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('search from the rail reaches a product the rail is not currently showing', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
|
||||
const page = await ctx.newPage()
|
||||
// Inside a product, so the rail is level 2 and the catalog is put away.
|
||||
await open(page, '/models')
|
||||
expect(await railRow(page, 'Vector').count(), 'Vector is not on the rail here').toBe(0)
|
||||
|
||||
// The rail's search box is the SAME palette the header opens — one search.
|
||||
await rail(page).getByText('Search or jump to…').first().click()
|
||||
await page.waitForTimeout(900)
|
||||
|
||||
// Fill the palette's OWN input, then commit — where the query TAKES you is the
|
||||
// honest probe. Merely finding the word "Vector" on screen proves nothing: with an
|
||||
// empty query the palette browses the whole catalog, so "Vector" is already there
|
||||
// and a spec that looks for it passes without the search ever having run.
|
||||
const box = page.getByPlaceholder(/Search apps and commands/i).filter({ visible: true }).first()
|
||||
await box.fill('vector')
|
||||
await expect(page.locator('#cmdk-active').first()).toBeVisible()
|
||||
await page.screenshot({ path: join(SHOTS, 'rail-search.png'), animations: 'disabled' })
|
||||
|
||||
await page.keyboard.press('Enter')
|
||||
await expect.poll(() => new URL(page.url()).pathname, { timeout: 15_000 }).toBe('/vector')
|
||||
|
||||
// …and the rail followed: it is now Vector's level 2, from a product that was
|
||||
// nowhere on the rail a moment ago.
|
||||
await page.waitForTimeout(1500)
|
||||
await expect(rail(page)).toContainText('More in Data')
|
||||
await page.screenshot({ path: join(SHOTS, 'rail-search-landed.png'), animations: 'disabled' })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('the settings toggle decides whether the rail lists the catalog', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
|
||||
const page = await ctx.newPage()
|
||||
await open(page, '/profile')
|
||||
|
||||
const label = page.getByText('Show every product').first()
|
||||
await expect(label).toBeVisible()
|
||||
await page.screenshot({ path: join(SHOTS, 'rail-toggle.png') })
|
||||
|
||||
// ON by default: the rail carries the catalog — here, the rest of this
|
||||
// product's category beneath its own pages.
|
||||
await expect(rail(page)).toContainText('More in Settings')
|
||||
await expect(railRow(page, 'Members').first()).toBeVisible()
|
||||
|
||||
// Turn it off — the rail keeps this product's pages and the pins, nothing else.
|
||||
const toggle = page.getByRole('switch').first()
|
||||
await expect(toggle).toBeVisible()
|
||||
await toggle.click()
|
||||
await page.waitForTimeout(900)
|
||||
await expect(rail(page), 'the catalog is put away').not.toContainText('More in Settings')
|
||||
await expect(railRow(page, 'Account').first(), 'this product keeps its pages').toBeVisible()
|
||||
await page.screenshot({ path: join(SHOTS, 'rail-toggle-off.png') })
|
||||
|
||||
// Back at level 1 the catalog is put away there too — the pins remain.
|
||||
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForTimeout(1500)
|
||||
expect(await railRow(page, 'Vector').count(), 'no catalog at level 1 either').toBe(0)
|
||||
await expect(railRow(page, 'Models').first(), 'the pins stay').toBeVisible()
|
||||
|
||||
await ctx.close()
|
||||
})
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "@hanzo/console",
|
||||
"version": "8.5.75",
|
||||
"version": "8.5.83",
|
||||
"packageManager": "pnpm@11.17.0",
|
||||
"private": true,
|
||||
"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",
|
||||
|
||||
+104
-58
@@ -2,83 +2,129 @@
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* settings, the theme, 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.
|
||||
* top-left, beside the tenant's own mark. Handing this menu an org 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 live there; 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 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:
|
||||
* It is the SAME `Menu` the context switcher wears — same height, same mark, same
|
||||
* type, same chevron, same sheet — so the two ends of the rail are peers. It used
|
||||
* to be `@hanzo/iam`'s `UserMenu`: a second rendering system inside one rail,
|
||||
* drawing raw DOM through an injected global stylesheet into its own portal, with
|
||||
* a 28px circle and a one-letter initial against the org's rounded-square,
|
||||
* two-letter mark. Identity still comes from IAM (`useSession`, `signOut`) — what
|
||||
* changed is that the console draws its own rail with its own primitives, once.
|
||||
*
|
||||
* - 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
|
||||
* own theme hook beside it — one theme system, not two.
|
||||
*
|
||||
* - BRAND. The strip at the foot wears THIS host's brand. Passing nothing would
|
||||
* paint a Hanzo mark on a Lux or Zoo console.
|
||||
* The BALANCE is not here. `SidebarWallet` sits one row below this control and
|
||||
* shows the same number from the same hook, plus the trial/prepaid split and a
|
||||
* top-up button — so the copy in here was the same fact twice, the second time
|
||||
* behind a click.
|
||||
*/
|
||||
import { useMemo } from 'react'
|
||||
import { UserMenu, type UserTheme } from '@hanzo/iam/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Text, YStack } from '@hanzo/gui'
|
||||
import { BookOpen, LogOut, Receipt, Users, UserRound } from '@hanzogui/lucide-icons-2'
|
||||
import { useThemeSetting } from '@hanzogui/next-theme'
|
||||
|
||||
import { config } from '~/config'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { useCloudBalance, spendableCents } from '~/lib/billing/live-balance'
|
||||
import { MenuRow } from '~/components/ui/MenuRow'
|
||||
import { Menu, MenuLabel, MenuRule } from '~/components/ui/Menu'
|
||||
|
||||
/** `system` is a real choice, and the console's provider already understands it. */
|
||||
const THEMES = [
|
||||
{ mode: 'light', label: 'Light' },
|
||||
{ mode: 'dark', label: 'Dark' },
|
||||
{ mode: 'system', label: 'Sync with system' },
|
||||
] as const
|
||||
|
||||
export function AccountMenu() {
|
||||
const router = useRouter()
|
||||
const { account, signOut } = useSession()
|
||||
const { balance } = useCloudBalance()
|
||||
const { current, resolvedTheme, set } = useThemeSetting()
|
||||
|
||||
// `system` is a real choice, and the console's provider already understands it.
|
||||
const theme: UserTheme = useMemo(
|
||||
() => ({
|
||||
mode: (current === 'light' || current === 'dark' ? current : 'system') as UserTheme['mode'],
|
||||
resolved: (resolvedTheme ?? current) === 'light' ? 'light' : 'dark',
|
||||
setMode: (mode) => set(mode),
|
||||
}),
|
||||
[current, resolvedTheme, set],
|
||||
)
|
||||
const { current, set } = useThemeSetting()
|
||||
|
||||
if (!account) return null
|
||||
|
||||
const cents = spendableCents(balance)
|
||||
// Never a fabricated "User" — a name nobody chose reads as a bug to the person
|
||||
// it names. The email's local part is a real name; the account name is a real name.
|
||||
const name = account.displayName?.trim() || account.name
|
||||
const mode = current === 'light' || current === 'dark' ? current : 'system'
|
||||
|
||||
return (
|
||||
<UserMenu
|
||||
align="up"
|
||||
identity={{
|
||||
name,
|
||||
email: account.email ?? null,
|
||||
initials: (name || '?').slice(0, 1).toUpperCase(),
|
||||
avatarUrl: account.avatar || null,
|
||||
<Menu
|
||||
mark={{ name, logo: account.avatar || undefined }}
|
||||
label={name}
|
||||
aria={`Account — ${name}`}
|
||||
testId="nav-user"
|
||||
up
|
||||
>
|
||||
{(close) => {
|
||||
const go = (href: string) => () => {
|
||||
close()
|
||||
router.push(href)
|
||||
}
|
||||
return (
|
||||
<YStack gap="$0.5">
|
||||
<YStack px="$2" py="$1.5">
|
||||
<Text fontSize="$2" color="$color12" numberOfLines={1}>
|
||||
{name}
|
||||
</Text>
|
||||
{account.email ? (
|
||||
<Text fontSize="$1" color="$color10" numberOfLines={1}>
|
||||
{account.email}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
|
||||
<MenuRule />
|
||||
|
||||
<MenuRow label="Profile" icon={<UserRound size={14} />} onPress={go('/profile')} />
|
||||
<MenuRow label="Billing & usage" icon={<Receipt size={14} />} onPress={go('/billing')} />
|
||||
{/* Your people, beside your own settings — the other half of "who am I". */}
|
||||
<MenuRow label="Members" icon={<Users size={14} />} onPress={go('/team')} />
|
||||
<MenuRow
|
||||
label="Documentation"
|
||||
icon={<BookOpen size={14} />}
|
||||
onPress={() => {
|
||||
close()
|
||||
window.open(config.docsUrl, '_blank', 'noopener,noreferrer')
|
||||
}}
|
||||
/>
|
||||
|
||||
<MenuRule />
|
||||
|
||||
<MenuLabel>Theme</MenuLabel>
|
||||
<YStack role="radiogroup" aria-label="Theme" gap="$0.5">
|
||||
{THEMES.map((t) => (
|
||||
<MenuRow
|
||||
key={t.mode}
|
||||
label={t.label}
|
||||
active={mode === t.mode}
|
||||
onPress={() => {
|
||||
close()
|
||||
set(t.mode)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</YStack>
|
||||
|
||||
<MenuRule />
|
||||
|
||||
<MenuRow
|
||||
label="Sign out"
|
||||
icon={<LogOut size={14} />}
|
||||
onPress={() => {
|
||||
close()
|
||||
void signOut()
|
||||
}}
|
||||
/>
|
||||
</YStack>
|
||||
)
|
||||
}}
|
||||
isAuthenticated
|
||||
isLoading={false}
|
||||
onSignOut={() => void signOut()}
|
||||
theme={theme}
|
||||
settingsUrl="/profile"
|
||||
usageUrl="/billing"
|
||||
usageLabel="Billing & usage"
|
||||
// Only shown when the backend actually reported a balance — never a fabricated $0.
|
||||
balance={cents === null ? undefined : { amountUsd: cents / 100, topUpUrl: config.payUrl }}
|
||||
items={[
|
||||
// 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 }}
|
||||
/>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ 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 { useAppsBeta } from '~/lib/products/beta'
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { usePins, useProductColors } from '~/lib/products/pins'
|
||||
import { openProduct } from '~/lib/products/open'
|
||||
@@ -190,8 +189,7 @@ export function AddProductPanel() {
|
||||
|
||||
// Source = the FULL catalog the viewer may see (ungated → both pinned and unpinned
|
||||
// appear), grouped by category.
|
||||
const showBeta = useAppsBeta(showAdmin)
|
||||
const groups = useMemo(() => visibleCatalogByCategory(showAdmin, null, showBeta), [showAdmin, showBeta])
|
||||
const groups = useMemo(() => visibleCatalogByCategory(showAdmin, null), [showAdmin])
|
||||
|
||||
// Literal, case-insensitive substring match over label/description/id — NOT a
|
||||
// compiled RegExp of user input.
|
||||
|
||||
@@ -77,7 +77,6 @@ import { assistantState, commandBarSystemPrompt, hanzoAssistantSystemPrompt } fr
|
||||
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 { useAppsBeta } from '~/lib/products/beta'
|
||||
import { ProductIcon } from '~/components/ui/ProductIcon'
|
||||
import { openProduct } from '~/lib/products/open'
|
||||
import { currentOrg, switchOrg } from '~/lib/org-scope'
|
||||
@@ -214,6 +213,7 @@ function CatalogRow({
|
||||
return (
|
||||
<XStack
|
||||
className="hz-row-pin"
|
||||
data-testid="palette-hit"
|
||||
onPress={onPress}
|
||||
cursor="pointer"
|
||||
items="center"
|
||||
@@ -279,6 +279,7 @@ function DestinationRow({
|
||||
const Icon = subpage.icon ?? entry.icon
|
||||
return (
|
||||
<XStack
|
||||
data-testid="palette-hit"
|
||||
onPress={onPress}
|
||||
cursor="pointer"
|
||||
items="center"
|
||||
@@ -365,7 +366,6 @@ function PaletteDialog({
|
||||
const router = useRouter()
|
||||
const { signOut } = useSession()
|
||||
const showAdmin = useIsSuperAdmin()
|
||||
const showBeta = useAppsBeta(showAdmin)
|
||||
const { colorOf } = useProductColors()
|
||||
const pins = usePins()
|
||||
const { current, resolvedTheme, set: setTheme } = useThemeSetting()
|
||||
@@ -441,7 +441,7 @@ function PaletteDialog({
|
||||
// a search, so the ranked branch is left strictly alone.
|
||||
const destResults = useMemo(() => {
|
||||
if (mode !== 'catalog') return []
|
||||
const found = searchDestinations(query, showAdmin, null, showBeta)
|
||||
const found = searchDestinations(query, showAdmin, null)
|
||||
if (sub) return found.slice(0, 50)
|
||||
return pinnedFirst(found, (d) => (d.kind === 'product' ? d.entry.id : ''), pins.pinnedIds)
|
||||
}, [mode, query, sub, showAdmin, pins.pinnedIds])
|
||||
@@ -822,17 +822,26 @@ export function Palette({ children }: { children: ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Header trigger — a search box that opens the palette. */
|
||||
export function CommandSearchBox() {
|
||||
/**
|
||||
* THE search box — the one control that opens the palette, wherever a surface wants
|
||||
* to offer search. The header, the sidebar rail and the mobile drawer all mount this
|
||||
* one component; each used to draw its own box, which is three searches for one
|
||||
* question. `onOpen` lets a surface that is itself dismissible (the drawer) step out
|
||||
* of the way first.
|
||||
*/
|
||||
export function CommandSearchBox({ height = 36, onOpen }: { height?: number; onOpen?: () => void } = {}) {
|
||||
const { open } = useCommandPalette()
|
||||
return (
|
||||
<XStack
|
||||
onPress={open}
|
||||
onPress={() => {
|
||||
onOpen?.()
|
||||
open()
|
||||
}}
|
||||
cursor="pointer"
|
||||
items="center"
|
||||
gap="$2"
|
||||
px="$3"
|
||||
height={36}
|
||||
height={height}
|
||||
flex={1}
|
||||
maxW={420}
|
||||
bg="$color2"
|
||||
|
||||
+102
-144
@@ -10,10 +10,14 @@
|
||||
* 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.
|
||||
* The ACCOUNT keeps the other question ("who am I") at the foot of the rail, and
|
||||
* wears the SAME `Menu` — same height, same mark, same type, same chevron — so the
|
||||
* two ends of the rail read as two halves of one identity.
|
||||
*
|
||||
* The org's NAME always shows. It used to be replaced by the org's logo whenever
|
||||
* IAM carried one, which left a tenant with a logo looking at a picture and no
|
||||
* name at all — the name survived only in the aria-label. The logo is the MARK now
|
||||
* (that is what `OrgMark` is for), and the name is the label, always.
|
||||
*
|
||||
* 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
|
||||
@@ -26,20 +30,19 @@
|
||||
*/
|
||||
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 { Text, YStack } from '@hanzo/gui'
|
||||
import { FolderGit2, Plus, SlidersHorizontal } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { useScope } from '~/lib/scope-context'
|
||||
import { useOrgIdentity } from '~/components/ui/BrandLogo'
|
||||
import { OrgMark } from '@hanzo/ui/product'
|
||||
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 { contextLabel, orgLabel, scopedOrgRow } from '~/lib/account/org-state'
|
||||
import { MenuRow } from '~/components/ui/MenuRow'
|
||||
import { paper } from '~/components/ui/paper'
|
||||
import { SearchInput } from '@hanzo/ui/product'
|
||||
import { Menu, MenuLabel, MenuRule } from '~/components/ui/Menu'
|
||||
import { OrgMark, SearchInput } from '@hanzo/ui/product'
|
||||
|
||||
export function ContextSwitcher() {
|
||||
const router = useRouter()
|
||||
@@ -47,13 +50,12 @@ export function ContextSwitcher() {
|
||||
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)
|
||||
// ONE naming rule — `orgLabel` — so the trigger and the row for the very same
|
||||
// org can never read differently ("Acme" above, "acme" below).
|
||||
const name = orgLabel(org.displayName ? org : { ...org, name: 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
|
||||
@@ -68,14 +70,6 @@ export function ContextSwitcher() {
|
||||
[isSuperAdmin, scoped],
|
||||
)
|
||||
|
||||
const onOpenChange = useCallback(
|
||||
(next: boolean) => {
|
||||
setOpen(next)
|
||||
if (next && orgs === null) void loadOrgs('')
|
||||
},
|
||||
[orgs, loadOrgs],
|
||||
)
|
||||
|
||||
const search = useCallback(
|
||||
(q: string) => {
|
||||
setQuery(q)
|
||||
@@ -84,141 +78,105 @@ export function ContextSwitcher() {
|
||||
[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)}`}
|
||||
>
|
||||
{org.logo ? (
|
||||
// The org's own logo IS the label — the uploaded mark takes the
|
||||
// slot the name held, height-capped to the row so any aspect fits.
|
||||
// A scoped project keeps its text beside it; the full text stays
|
||||
// in the aria-label either way. Arbitrary tenant URL/data URL, so
|
||||
// a raw <img> (next/image would need a per-tenant remote
|
||||
// allow-list) — same call BrandLogo makes.
|
||||
<XStack items="center" gap="$2" flex={1} minW={0}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={org.logo}
|
||||
alt={orgLabel}
|
||||
style={{ height: 22, width: 'auto', maxWidth: 140, objectFit: 'contain', display: 'block' }}
|
||||
/>
|
||||
{scope.project ? (
|
||||
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1} flex={1}>
|
||||
/ {scope.project}
|
||||
</Text>
|
||||
) : null}
|
||||
</XStack>
|
||||
) : (
|
||||
// No uploaded logo: lead with the org's shared OrgMark (its monogram —
|
||||
// the SAME mark SidebarBrand and the account widget wear), so the switcher
|
||||
// is never a bare name. White-label safe: OrgMark is the tenant's OWN mark
|
||||
// (the org's IAM logo when set, else its monogram), never the house glyph.
|
||||
<XStack items="center" gap="$2" flex={1} minW={0}>
|
||||
<OrgMark org={org} size={20} />
|
||||
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1} flex={1}>
|
||||
{contextLabel(orgLabel, scope.project)}
|
||||
</Text>
|
||||
</XStack>
|
||||
)}
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
<Menu
|
||||
mark={org}
|
||||
label={contextLabel(name, scope.project)}
|
||||
aria={`Organization and project — ${contextLabel(name, scope.project)}`}
|
||||
testId="switcher-context"
|
||||
onOpen={() => {
|
||||
if (orgs === null) void loadOrgs('')
|
||||
}}
|
||||
>
|
||||
{(close) => {
|
||||
const pick = (fn: () => void) => () => {
|
||||
close()
|
||||
fn()
|
||||
}
|
||||
return (
|
||||
<YStack gap="$0.5">
|
||||
<MenuLabel>Organization</MenuLabel>
|
||||
|
||||
<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}
|
||||
|
||||
{/* 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 role="radiogroup" aria-label="Organizations" gap="$0.5">
|
||||
{orgRows.map((o) => (
|
||||
<MenuRow
|
||||
key={o.name}
|
||||
label={orgLabel(o)}
|
||||
icon={<OrgMark org={o} size={18} />}
|
||||
active={scoped === o.name}
|
||||
onPress={pick(() => {
|
||||
if (o.name !== scoped) switchOrg(o.name)
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</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}
|
||||
|
||||
{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))}
|
||||
label="Organization settings"
|
||||
icon={<SlidersHorizontal size={14} />}
|
||||
onPress={pick(() => router.push('/settings/branding'))}
|
||||
/>
|
||||
|
||||
{projects.map((p) => (
|
||||
<MenuRow label="All organizations" icon={<Plus size={14} />} onPress={pick(leaveOrg)} />
|
||||
|
||||
<MenuRule />
|
||||
|
||||
<MenuLabel>Project</MenuLabel>
|
||||
|
||||
<YStack role="radiogroup" aria-label="Projects" gap="$0.5">
|
||||
{/* Org-level scope — no X-Project-Id sent. */}
|
||||
<MenuRow
|
||||
key={p.name}
|
||||
label={p.displayName || p.name}
|
||||
active={scope.project === p.name}
|
||||
onPress={pick(() => selectProject(p.name))}
|
||||
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>
|
||||
|
||||
{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>
|
||||
)
|
||||
}}
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,10 +10,11 @@
|
||||
* to that org (X-Org-Id) and drops into it; the sidebar "Home" affordance
|
||||
* ({@link leaveOrg}) returns here and de-scopes.
|
||||
*
|
||||
* Data source mirrors {@link OrgSwitcher}: a global admin lists all orgs via the
|
||||
* gated `/admin/iam` proxy; a tenant (who 403s that list) sees just their own org,
|
||||
* synthesized from the session — so the picker is always honest and never fabricates
|
||||
* an org. All decisions (sort, filter, paginate, card view-model) live in the pure
|
||||
* Data source: a global admin lists every org via the gated `/admin/iam` proxy;
|
||||
* everyone else lists the orgs they are a MEMBER of — their membership rows
|
||||
* unioned with their home org, each read as its own record so a card carries the
|
||||
* ORG's name and logo. It never fabricates an org, and it never labels one with
|
||||
* the signed-in person. All decisions (sort, filter, paginate, card view-model) live in the pure
|
||||
* `org-picker/logic.ts`; this file is a thin render of it with honest loading /
|
||||
* empty / error states.
|
||||
*/
|
||||
@@ -25,7 +26,7 @@ import { getBrand } from '~/lib/branding/brands'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { enterOrg } from '~/lib/org-scope'
|
||||
import { IamAdminApi, type Organization } from '~/lib/api'
|
||||
import { IamAdminApi, MembershipApi, TeamApi, orgNamesFor, type Organization } from '~/lib/api'
|
||||
import { BrandMark } from '~/components/ui/BrandLogo'
|
||||
import { OrgOnboarding } from '~/components/OrgOnboarding'
|
||||
import { PAGE_SIZE, pickerView, type OrgCard, type PickerContext } from '~/components/org-picker/logic'
|
||||
@@ -109,30 +110,71 @@ export function OrgPicker() {
|
||||
const owner = account?.owner ?? ''
|
||||
|
||||
const [orgs, setOrgs] = useState<Organization[] | null>(null)
|
||||
// org -> the caller's role in it, straight from the membership rows. The card
|
||||
// states the role it was GRANTED rather than one inferred from the account.
|
||||
const [roles, setRoles] = useState<Record<string, string>>({})
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
// The caller's OWN org, synthesized from the session — the single org that IS a
|
||||
// tenant's identity, and the honest fallback if the cross-tenant list can't load.
|
||||
// The caller's HOME org, named by its own slug — the last-resort fallback when
|
||||
// even the membership read fails.
|
||||
//
|
||||
// Its displayName used to be `account.displayName`, which is the signed-in
|
||||
// PERSON. An org card then announced a human ("Dave Lorenzini") where the
|
||||
// organization belongs, and the monogram it derived was the person's initials
|
||||
// rather than the org's mark. A person is not an org; when the org's own row
|
||||
// cannot be read, its slug is the honest label.
|
||||
const ownOrgOnly = useMemo<Organization[]>(
|
||||
() =>
|
||||
owner
|
||||
? [{ owner: 'admin', name: owner, displayName: account?.displayName?.trim() || titleCase(owner) } as Organization]
|
||||
: [],
|
||||
[owner, account?.displayName],
|
||||
() => (owner ? [{ owner: 'admin', name: owner, displayName: titleCase(owner) } as Organization] : []),
|
||||
[owner],
|
||||
)
|
||||
|
||||
// Load the caller's visible orgs. A global admin gets the full cross-tenant list
|
||||
// (paged large, then this component client-paginates); a tenant 403s that list, so
|
||||
// it sees just its own org — honest, never fabricated.
|
||||
// (paged large, then this component client-paginates). Everyone else gets the
|
||||
// orgs they are a MEMBER of — their memberships unioned with their home org,
|
||||
// each read as its own row so the card shows the ORG's name and logo.
|
||||
//
|
||||
// This used to be one card synthesized from the session, which could never show
|
||||
// a second org: a customer with a workspace besides their home tenant simply did
|
||||
// not see it. Reading each row is also what puts a real logo on the card; IAM
|
||||
// authorizes a member to read the orgs they belong to (v1.34.26), so the fetch
|
||||
// that used to 403 for a tenant now answers.
|
||||
useEffect(() => {
|
||||
if (!owner) return
|
||||
let live = true
|
||||
if (!isSuperAdmin) {
|
||||
setOrgs(ownOrgOnly)
|
||||
return
|
||||
const me = account?.name ? `${owner}/${account.name}` : ''
|
||||
if (!me) {
|
||||
setOrgs(ownOrgOnly)
|
||||
return
|
||||
}
|
||||
MembershipApi.mine(me)
|
||||
.then((rows) => {
|
||||
if (live) setRoles(Object.fromEntries(rows.map((m) => [m.org, m.role])))
|
||||
return orgNamesFor(owner, rows)
|
||||
})
|
||||
.then((names) =>
|
||||
// One read per org, and a row that cannot be read degrades to its slug
|
||||
// rather than dropping the org off a list the person is entitled to see.
|
||||
Promise.all(
|
||||
names.map((name) =>
|
||||
TeamApi.organization(name).catch(
|
||||
() => ({ owner: 'admin', name, displayName: titleCase(name) }) as Organization,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.then((rows) => {
|
||||
if (live) setOrgs(rows)
|
||||
})
|
||||
.catch(() => {
|
||||
if (live) setOrgs(ownOrgOnly)
|
||||
})
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}
|
||||
setOrgs(null)
|
||||
setError(null)
|
||||
@@ -151,11 +193,11 @@ export function OrgPicker() {
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [owner, isSuperAdmin, ownOrgOnly])
|
||||
}, [owner, isSuperAdmin, ownOrgOnly, account?.name])
|
||||
|
||||
const ctx: PickerContext = useMemo(
|
||||
() => ({ ownOrg: owner, isSuperAdmin, callerIsAdmin: Boolean(account?.isAdmin) }),
|
||||
[owner, isSuperAdmin, account?.isAdmin],
|
||||
() => ({ ownOrg: owner, isSuperAdmin, callerIsAdmin: Boolean(account?.isAdmin), roles }),
|
||||
[owner, isSuperAdmin, account?.isAdmin, roles],
|
||||
)
|
||||
|
||||
const view = useMemo(
|
||||
|
||||
@@ -31,6 +31,7 @@ import { BookOpen, Globe, Info, SlidersHorizontal } from '@hanzogui/lucide-icons
|
||||
import { config } from '~/config'
|
||||
import { getBrand } from '~/lib/branding/brands'
|
||||
import { useOrgIdentity } from '~/components/ui/BrandLogo'
|
||||
import { orgLabel } from '~/lib/account/org-state'
|
||||
import { Z } from '~/lib/z'
|
||||
import { OrgMark } from '@hanzo/ui/product'
|
||||
|
||||
@@ -113,7 +114,9 @@ export function SidebarBrand({ collapsed, onNavigate }: { collapsed: boolean; on
|
||||
// The tenant leads the chrome: its own logo when set, else its monogram — never
|
||||
// the house mark. `useOrgIdentity` is the ONE cached org-identity source.
|
||||
const org = useOrgIdentity()
|
||||
const orgLabel = org.displayName || org.name
|
||||
// ONE naming rule, shared with the context switcher, so the collapsed rail and
|
||||
// the expanded one cannot call the same org two different things.
|
||||
const label = orgLabel(org)
|
||||
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null)
|
||||
|
||||
const go = useCallback(
|
||||
@@ -149,8 +152,8 @@ export function SidebarBrand({ collapsed, onNavigate }: { collapsed: boolean; on
|
||||
onClick={() => go('/')}
|
||||
onContextMenu={onContextMenu}
|
||||
role="link"
|
||||
aria-label={`${orgLabel} — home (right-click for brand menu)`}
|
||||
title={orgLabel}
|
||||
aria-label={`${label} — home (right-click for brand menu)`}
|
||||
title={label}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -161,8 +164,14 @@ export function SidebarBrand({ collapsed, onNavigate }: { collapsed: boolean; on
|
||||
color: 'var(--color12)',
|
||||
}}
|
||||
>
|
||||
{/* A logo may be a wordmark, so it is allowed to run wide; the monogram
|
||||
stays the square tile the account avatar wears.
|
||||
{/* The TENANT's mark, always — its uploaded logo when it has one, else its
|
||||
own monogram. It used to fall back to the HOST's glyph, so an org with
|
||||
no logo wore the Hanzo mark here and its own initials in the switcher:
|
||||
one org, two marks, depending on whether the rail was collapsed.
|
||||
`OrgMark` already resolves logo-else-monogram, which is the whole rule.
|
||||
|
||||
A logo may be a wordmark, so it is allowed to run wide; the monogram
|
||||
stays the square tile the account mark wears.
|
||||
|
||||
`data-monogram`: OrgMark is a DISTRIBUTED component that sizes its
|
||||
monogram glyph proportionally to its tile, so it paints text off the
|
||||
|
||||
@@ -182,3 +182,36 @@ describe('pickerView — filter → sort → paginate → cards', () => {
|
||||
expect(p2.hasMore).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ONE PERSON, MANY ORGS — and each card states the role it was actually granted.
|
||||
//
|
||||
// roleFor's last branch used to carry a comment calling itself unreachable
|
||||
// ("a non-global-admin only ever sees their own org"). The picker now lists
|
||||
// memberships, so that branch runs for every joined org — and it answered
|
||||
// "Member" for all of them, including one the person administers. Measured
|
||||
// live: dave, admin of maxpower, read "Member" on the maxpower card.
|
||||
describe('roleFor — the membership is the truth once you can see more than one org', () => {
|
||||
const org = (name: string) => ({ owner: 'admin', name }) as Organization
|
||||
const base = { ownOrg: 'hanzo', isSuperAdmin: false, callerIsAdmin: false }
|
||||
|
||||
it('states the granted role for a joined org, not a guess', () => {
|
||||
expect(roleFor(org('maxpower'), { ...base, roles: { maxpower: 'admin' } })).toBe('Admin')
|
||||
expect(roleFor(org('acme'), { ...base, roles: { acme: 'owner' } })).toBe('Owner')
|
||||
expect(roleFor(org('other'), { ...base, roles: { other: 'member' } })).toBe('Member')
|
||||
})
|
||||
|
||||
it('prefers the membership over the own-org inference', () => {
|
||||
// Their home org, where the account flag says plain member but the
|
||||
// membership says admin — the membership is the grant.
|
||||
expect(roleFor(org('hanzo'), { ...base, roles: { hanzo: 'admin' } })).toBe('Admin')
|
||||
})
|
||||
|
||||
it('falls back to the own-org inference when no membership row exists', () => {
|
||||
expect(roleFor(org('hanzo'), base)).toBe('Member')
|
||||
expect(roleFor(org('hanzo'), { ...base, callerIsAdmin: true })).toBe('Admin')
|
||||
})
|
||||
|
||||
it('still calls a super admin viewing another tenant what they are', () => {
|
||||
expect(roleFor(org('maxpower'), { ...base, isSuperAdmin: true })).toBe('Super admin')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -44,6 +44,12 @@ export type PickerContext = {
|
||||
isSuperAdmin: boolean
|
||||
/** Whether the caller is an admin of their OWN org (`account.isAdmin`). */
|
||||
callerIsAdmin: boolean
|
||||
/**
|
||||
* The caller's role in each org they hold a MEMBERSHIP in (`org -> role`),
|
||||
* which is the only place the truth lives once a person can see more than one
|
||||
* org. Optional so a caller that has not loaded memberships still renders.
|
||||
*/
|
||||
roles?: Record<string, string>
|
||||
}
|
||||
|
||||
/** The full picker view for a query + page — everything the UI renders. */
|
||||
@@ -79,16 +85,24 @@ export function initialsOf(org: Organization): string {
|
||||
/**
|
||||
* The caller's HONEST role in this org — derived from real context, never guessed:
|
||||
* - super admin viewing another org → "Super admin" (masquerade access)
|
||||
* - the caller's own org, they admin it → "Admin"
|
||||
* - the caller's own org otherwise → "Member"
|
||||
* - super admin viewing their own org → "Admin"
|
||||
* - an org they hold a MEMBERSHIP in → that membership's role
|
||||
* - the caller's own org → "Admin" when they admin it, else "Member"
|
||||
*
|
||||
* The membership is consulted BEFORE the own-org fallback, because once a person
|
||||
* can see more than one org the fallback is a guess. It used to end with a
|
||||
* comment calling its own last branch unreachable — "a non-global-admin only
|
||||
* ever sees their own org" — and that stopped being true the moment the picker
|
||||
* started listing memberships: every joined org then rendered "Member",
|
||||
* including ones the person administers.
|
||||
*/
|
||||
export function roleFor(org: Organization, ctx: PickerContext): string {
|
||||
const isOwn = org.name === ctx.ownOrg
|
||||
if (ctx.isSuperAdmin && !isOwn) return 'Super admin'
|
||||
const membership = ctx.roles?.[org.name]
|
||||
if (membership) return titleCase(membership)
|
||||
if (isOwn) return ctx.callerIsAdmin ? 'Admin' : 'Member'
|
||||
// A non-global-admin only ever sees their own org, so this is unreachable in
|
||||
// practice; be honest rather than invent a role if it ever isn't.
|
||||
// No membership row and not their own org: say the weaker thing rather than
|
||||
// invent authority the caller may not have.
|
||||
return ctx.isSuperAdmin ? 'Admin' : 'Member'
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,9 @@ import { useSession } from '~/lib/auth/session'
|
||||
import { AccountApi, ApiError } from '~/lib/api'
|
||||
import { MfaApi, type MfaSetup } from '~/lib/api/mfa'
|
||||
import { ApiKeysView } from './ApiKeysModule'
|
||||
import { FieldRow, PageHeader } from '@hanzo/ui/product'
|
||||
import { FieldRow, FieldSwitch, PageHeader } from '@hanzo/ui/product'
|
||||
import { usePreferences } from '~/lib/products/preferences'
|
||||
import { NAV_CATALOG_PREF } from '~/lib/products/nav'
|
||||
|
||||
/** A labeled read-only value row; dim em-dash when empty. */
|
||||
function InfoRow({ label, value }: { label: string; value?: string | number | boolean | null }) {
|
||||
@@ -161,6 +163,30 @@ function PhotoCard() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What the sidebar lists. Every product is available to every org, so the rail
|
||||
* shows all of them by default; a person who works in six can put the catalog
|
||||
* away and keep their pins. Nothing goes out of reach either way — the search box
|
||||
* at the top of the rail and "All products" at its foot both stay whole.
|
||||
*/
|
||||
function SidebarCard() {
|
||||
const prefs = usePreferences()
|
||||
const catalog = prefs.get<boolean>(NAV_CATALOG_PREF, true)
|
||||
return (
|
||||
<Card p="$4" gap="$3.5" borderWidth={1} borderColor="$borderColor" maxWidth={720}>
|
||||
<FieldRow label="Show every product">
|
||||
<XStack items="center" gap="$3">
|
||||
<FieldSwitch checked={catalog} onChange={(v) => prefs.set(NAV_CATALOG_PREF, v)} />
|
||||
<Text fontSize="$2" color="$color10">
|
||||
List the whole catalog in the sidebar. Off, it shows what you pinned and
|
||||
wherever you are; search and All products still reach everything.
|
||||
</Text>
|
||||
</XStack>
|
||||
</FieldRow>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function AccountTab() {
|
||||
const { account, signOut } = useSession()
|
||||
|
||||
@@ -180,6 +206,8 @@ function AccountTab() {
|
||||
</YStack>
|
||||
</Card>
|
||||
|
||||
<SidebarCard />
|
||||
|
||||
<XStack>
|
||||
<Button icon={<LogOut size={16} />} onPress={() => void signOut()}>Sign out</Button>
|
||||
</XStack>
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Menu — the ONE anchored menu the console's two identity controls wear.
|
||||
*
|
||||
* The rail asks two questions at its two ends: WHERE you are (organization and
|
||||
* project, top-left) and WHO you are (account, bottom-left). They are peers, and
|
||||
* they must read as peers. They did not: the org control was a @hanzo/gui popover
|
||||
* with a 20px rounded-square mark, a two-letter monogram and a chevron, while the
|
||||
* account control was `@hanzo/iam`'s `UserMenu` — a second rendering system inside
|
||||
* one rail, drawing raw DOM through an injected global `hz-iam-*` stylesheet into
|
||||
* its own portal, with a 28px CIRCLE, a one-letter initial and no chevron. Two
|
||||
* component systems, two marks, two type scales, two sets of a11y roles, for one
|
||||
* shape. This is that shape once: the trigger, the sheet, and the rows beneath it.
|
||||
*
|
||||
* The MARK is `OrgMark` for both, because a person and an organization wear the
|
||||
* same thing — an uploaded image when there is one, their own initials when there
|
||||
* is not. One component means the two can no longer disagree about which.
|
||||
*
|
||||
* `children` receives `close`, so a row can dismiss the menu before it navigates
|
||||
* without every call site keeping its own copy of the open state.
|
||||
*/
|
||||
import { useCallback, useState, type ReactNode } from 'react'
|
||||
import { Button, Popover, Text, XStack } from '@hanzo/gui'
|
||||
import { ChevronsUpDown } from '@hanzogui/lucide-icons-2'
|
||||
import { OrgMark, type Org } from '@hanzo/ui/product'
|
||||
|
||||
import { Z } from '~/lib/z'
|
||||
import { paper } from './paper'
|
||||
|
||||
/** The mark both controls wear. One number, so they cannot drift. */
|
||||
export const MARK = 28
|
||||
|
||||
export function Menu({
|
||||
mark,
|
||||
label,
|
||||
aria,
|
||||
testId,
|
||||
up,
|
||||
onOpen,
|
||||
children,
|
||||
}: {
|
||||
/** Who or what this names — an org, or a person as `{ name, logo: avatar }`. */
|
||||
mark: Org
|
||||
/** The one line in the trigger. Never empty: a nameless control reads as a bug. */
|
||||
label: string
|
||||
/** The accessible name (says what the control DOES, which the label alone cannot). */
|
||||
aria: string
|
||||
testId: string
|
||||
/** Open UPWARD — for the control at the FOOT of the rail, where a downward sheet
|
||||
* would fall off the bottom of the viewport. */
|
||||
up?: boolean
|
||||
/** Fired the first time the sheet opens — for a list that is fetched on demand. */
|
||||
onOpen?: () => void
|
||||
children: (close: () => void) => ReactNode
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const close = useCallback(() => setOpen(false), [])
|
||||
const change = useCallback(
|
||||
(next: boolean) => {
|
||||
setOpen(next)
|
||||
if (next) onOpen?.()
|
||||
},
|
||||
[onOpen],
|
||||
)
|
||||
return (
|
||||
// `up` is a PREFERENCE, not a promise: `allowFlip`/`stayInFrame` let the sheet
|
||||
// turn over and slide back into view when the side it wants has no room. The
|
||||
// account control is at the foot of the desktop rail (so: upward) and near the
|
||||
// TOP of the phone's account sheet, where upward is 365px off the screen.
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={change}
|
||||
placement={up ? 'top-start' : 'bottom-start'}
|
||||
allowFlip
|
||||
stayInFrame
|
||||
>
|
||||
<Popover.Trigger asChild>
|
||||
<Button
|
||||
size="$3"
|
||||
height={44}
|
||||
chromeless
|
||||
justify="flex-start"
|
||||
px="$2"
|
||||
data-testid={testId}
|
||||
iconAfter={<ChevronsUpDown size={13} opacity={0.6} />}
|
||||
aria-label={aria}
|
||||
>
|
||||
<XStack items="center" gap="$2" flex={1} minW={0}>
|
||||
<OrgMark org={mark} size={MARK} />
|
||||
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1} flex={1}>
|
||||
{label}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
|
||||
{/* `Z.popover` — the ladder's layer for a popover anchored inside a modal.
|
||||
On a phone this menu opens from inside the account SHEET, which sits at
|
||||
`Z.modal`; without a layer of its own it was measurable and unclickable. */}
|
||||
<Popover.Content {...paper} role="menu" p="$2" width={280} style={{ zIndex: Z.popover }}>
|
||||
{children(close)}
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
/** A quiet heading over a group of rows — "Organization", "Project", "Theme". */
|
||||
export function MenuLabel({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="500">
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
/** The hairline between two groups of rows. */
|
||||
export function MenuRule() {
|
||||
return <XStack height={1} bg="$borderColor" my="$1" />
|
||||
}
|
||||
@@ -20,7 +20,10 @@
|
||||
*/
|
||||
export const paper = {
|
||||
className: 'hz-paper hz-menu-in',
|
||||
// `bordered` alone declares the intent and leaves the width at 0, so the sheet
|
||||
// met the page with no edge at all. The hairline is stated.
|
||||
bordered: true,
|
||||
borderWidth: 1,
|
||||
bg: '$color2',
|
||||
borderColor: '$borderColor',
|
||||
} as const
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* The cloud shell — a REAL terminal in the Developers dock.
|
||||
*
|
||||
* What was here before was an explorer: a prompt that took `GET /v1/models` and
|
||||
* printed the response. It looked like a shell and answered like a form, and the
|
||||
* gap between the two is the whole reason this exists — there was no way to run
|
||||
* anything, so the `$` in the dock was a promise the dock could not keep.
|
||||
*
|
||||
* THE SHELL IS A SANDBOX. Not a simulator, not a command allow-list: a login
|
||||
* shell on a pseudo-terminal inside the org's own gVisor pod. Whatever the image
|
||||
* carries — the hanzo CLI is on its PATH — is a command the user types, and
|
||||
* nothing here decides what may run. That decision belongs to the runtime
|
||||
* boundary the pod already has, and a second one in a browser tab would only be a
|
||||
* fiction.
|
||||
*
|
||||
* THE TERMINAL IS NOT BUILT HERE. Cloud serves it, whole, at the same address as
|
||||
* the socket, and this frames it. That is not laziness about an emulator — it is
|
||||
* that the console is one of several hosts that show a shell, and a terminal
|
||||
* built per host is a terminal that is subtly different in each of them. One
|
||||
* implementation, one place a fix lands, and this file is left with the only part
|
||||
* that is genuinely the console's: which sandbox, and what to say while it is
|
||||
* coming up.
|
||||
*
|
||||
* WHAT THIS STILL OWNS is the credential. A frame carries no Authorization
|
||||
* header any more than a socket does, so the ticket is fetched through the
|
||||
* same-origin `/v1` proxy — where identity lives — and handed to the page in its
|
||||
* URL. Single-use, thirty seconds, bound to one sandbox: that is what makes
|
||||
* putting it in a URL safe, and why nothing long-lived ever goes there.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Button, Text, XStack, YStack } from '@hanzo/gui'
|
||||
|
||||
import { ApiError, cloudProxyV1Url, restGet, restPost } from '~/lib/api/client'
|
||||
import { config } from '~/config'
|
||||
import { toneColor } from '~/components/ui/tone'
|
||||
import { terminalFor } from './logic'
|
||||
|
||||
/**
|
||||
* The project the dock's shell holds. A `dev` sandbox is attached to a project
|
||||
* and the project names the VOLUME, so this constant is what makes the shell the
|
||||
* same shell tomorrow: the checkout and the caches are still there when the lease
|
||||
* has long since ended. One live sandbox per project is the server's rule, which
|
||||
* is also why reopening the dock finds the running one instead of leasing a
|
||||
* second.
|
||||
*/
|
||||
const PROJECT = 'console'
|
||||
|
||||
/** The tmux session this dock attaches to, so reopening it finds the same shell. */
|
||||
const SESSION = 'dock'
|
||||
|
||||
/**
|
||||
* How long to wait for the terminal to say it is up.
|
||||
*
|
||||
* The page posts `{source:'hanzo-term'}` when its socket opens. Without a
|
||||
* deadline a frame that failed into something else — an expired ticket, an
|
||||
* origin that refused to be framed — is indistinguishable from one that is still
|
||||
* loading, and the dock would sit on "Starting…" forever rather than offering the
|
||||
* reconnect that fixes it.
|
||||
*/
|
||||
const READY_BY = 6000
|
||||
|
||||
type Phase = 'starting' | 'live' | 'gone'
|
||||
|
||||
type Sandbox = { id: string; status: string; project?: string }
|
||||
|
||||
/**
|
||||
* The org's running dock sandbox, or a freshly leased one.
|
||||
*
|
||||
* Asking the server which one is live is what makes the shell survive a reload
|
||||
* without remembering anything: there is exactly one live sandbox per project by
|
||||
* the server's own rule, so the answer to "which one is mine" is a query and
|
||||
* never a stored id that can go stale. The match is re-checked here rather than
|
||||
* trusted from the query string — a filter is the server's convenience, and the
|
||||
* sandbox this reattaches to had better be the right one.
|
||||
*/
|
||||
async function sandbox(): Promise<Sandbox> {
|
||||
const live = await restGet<{ sandboxes?: Sandbox[] }>(
|
||||
cloudProxyV1Url(`sandboxes?project=${PROJECT}&status=running`),
|
||||
)
|
||||
const held = live.sandboxes?.find((m) => m.status === 'running' && m.project === PROJECT)
|
||||
if (held) return held
|
||||
return restPost<Sandbox>(cloudProxyV1Url('sandboxes'), { class: 'dev', project: PROJECT })
|
||||
}
|
||||
|
||||
const reason = (err: unknown): string =>
|
||||
err instanceof ApiError
|
||||
? `${err.message}${err.status ? ` (${err.status})` : ''}`
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err)
|
||||
|
||||
export function Terminal() {
|
||||
const [phase, setPhase] = useState<Phase>('starting')
|
||||
const [why, setWhy] = useState('')
|
||||
const [src, setSrc] = useState('')
|
||||
// A change to this is the ONE way a session restarts: the effect below owns the
|
||||
// whole lifetime — sandbox, ticket, frame — and reruns as a unit, so there is
|
||||
// no half-torn-down session to reason about. A ticket is spent once, so a
|
||||
// reconnect is a new ticket and never the old frame reloaded.
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
const frame = useRef<HTMLIFrameElement>(null)
|
||||
|
||||
const retry = useCallback(() => {
|
||||
setPhase('starting')
|
||||
setWhy('')
|
||||
setSrc('')
|
||||
setAttempt((n) => n + 1)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// `alive` is the barrier for everything this effect started. React mounts an
|
||||
// effect twice in development, and a ticket fetched by the first pass would
|
||||
// otherwise land in a frame the second pass has replaced.
|
||||
let alive = true
|
||||
let waiting: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const end = (message: string) => {
|
||||
if (!alive) return
|
||||
setWhy(message)
|
||||
setPhase('gone')
|
||||
}
|
||||
|
||||
// The readiness handshake. Only the frame we opened may speak for it: the
|
||||
// origin is checked against the API host, so another page cannot post its way
|
||||
// into a terminal that is not there.
|
||||
const heard = (e: MessageEvent) => {
|
||||
if (!alive || e.source !== frame.current?.contentWindow) return
|
||||
if (new URL(config.apiUrl).origin !== e.origin) return
|
||||
const d = e.data as { source?: string } | null
|
||||
if (d && d.source === 'hanzo-term') {
|
||||
if (waiting) clearTimeout(waiting)
|
||||
setPhase('live')
|
||||
}
|
||||
}
|
||||
window.addEventListener('message', heard)
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const m = await sandbox()
|
||||
if (!alive) return
|
||||
const pass = await restPost<{ ticket: string }>(
|
||||
cloudProxyV1Url(`sandboxes/${m.id}/terminal/ticket`),
|
||||
)
|
||||
if (!alive) return
|
||||
setSrc(terminalFor(config.apiUrl, m.id, pass.ticket, SESSION))
|
||||
waiting = setTimeout(() => end('The terminal did not come up.'), READY_BY)
|
||||
} catch (err) {
|
||||
end(reason(err))
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
alive = false
|
||||
if (waiting) clearTimeout(waiting)
|
||||
window.removeEventListener('message', heard)
|
||||
}
|
||||
}, [attempt])
|
||||
|
||||
// The frame is ALWAYS laid out and the status covers it, because a frame that
|
||||
// is display:none has no size — and a terminal sized to nothing measures 80x24
|
||||
// and never corrects.
|
||||
return (
|
||||
<YStack flex={1} minH={0} position="relative" bg="#000">
|
||||
{src ? (
|
||||
<iframe
|
||||
ref={frame}
|
||||
src={src}
|
||||
title="Cloud shell"
|
||||
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', border: 0 }}
|
||||
/>
|
||||
) : null}
|
||||
{phase === 'live' ? null : (
|
||||
<YStack position="absolute" t={0} l={0} r={0} b={0} items="center" justify="center" gap="$2" p="$4" bg="$color1">
|
||||
{phase === 'starting' ? (
|
||||
<Text fontSize="$2" color="$color10">
|
||||
Starting your cloud shell…
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<XStack items="center" gap="$2">
|
||||
<Text fontSize="$2" color={toneColor('critical')}>
|
||||
Disconnected
|
||||
</Text>
|
||||
<Button size="$2" onPress={retry} aria-label="Reconnect the cloud shell">
|
||||
Reconnect
|
||||
</Button>
|
||||
</XStack>
|
||||
{why ? (
|
||||
<Text fontSize="$1" color="$color10" className="hz-mono">
|
||||
{why}
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</YStack>
|
||||
)}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -289,7 +289,7 @@ export function WorkbenchDock() {
|
||||
$
|
||||
</Text>
|
||||
<Text flex={1} fontSize="$2" color="$color10" className="hz-mono" numberOfLines={1}>
|
||||
Run a /v1 command — models, agents, logs…
|
||||
Open a cloud shell — a real terminal in your sandbox
|
||||
</Text>
|
||||
</XStack>
|
||||
) : (
|
||||
|
||||
@@ -1,32 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { curlFor, eventsFrom, hanzoCli, inspectorRoute, parseCommand, renderOutput } from './logic'
|
||||
|
||||
describe('parseCommand', () => {
|
||||
it('accepts every documented form and normalizes to a /v1-relative path', () => {
|
||||
expect(parseCommand('GET /v1/models')).toEqual({ path: 'models' })
|
||||
expect(parseCommand('get v1/models')).toEqual({ path: 'models' })
|
||||
expect(parseCommand('/v1/models')).toEqual({ path: 'models' })
|
||||
expect(parseCommand('models')).toEqual({ path: 'models' })
|
||||
expect(parseCommand(' agents?limit=5 ')).toEqual({ path: 'agents?limit=5' })
|
||||
})
|
||||
|
||||
it('is read-only — every mutating method is refused', () => {
|
||||
for (const m of ['POST', 'PUT', 'PATCH', 'DELETE']) {
|
||||
expect(parseCommand(`${m} /v1/agents`)).toHaveProperty('error')
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses empty, multi-path, traversal, and smuggled inputs', () => {
|
||||
expect(parseCommand('')).toHaveProperty('error')
|
||||
expect(parseCommand('GET')).toHaveProperty('error')
|
||||
expect(parseCommand('/v1')).toHaveProperty('error')
|
||||
expect(parseCommand('GET /v1/a /v1/b')).toHaveProperty('error')
|
||||
expect(parseCommand('/v1/../admin')).toHaveProperty('error')
|
||||
expect(parseCommand('//evil.com/x')).toHaveProperty('error')
|
||||
expect(parseCommand('https://evil.com/x')).toHaveProperty('error')
|
||||
})
|
||||
})
|
||||
import { curlFor, eventsFrom, inspectorRoute, renderOutput, terminalFor } from './logic'
|
||||
|
||||
describe('renderOutput', () => {
|
||||
it('pretty-prints JSON and passes strings through', () => {
|
||||
@@ -77,10 +51,6 @@ describe('show code', () => {
|
||||
expect(curlFor('models')).toBe('curl https://api.hanzo.ai/v1/models \\\n -H "Authorization: Bearer $HANZO_API_KEY"')
|
||||
expect(curlFor('/v1/agents')).toContain('https://api.hanzo.ai/v1/agents')
|
||||
})
|
||||
it('builds the Hanzo CLI form', () => {
|
||||
expect(hanzoCli('/v1/models')).toBe('hanzo api get /v1/models')
|
||||
expect(hanzoCli('agents')).toBe('hanzo api get /v1/agents')
|
||||
})
|
||||
})
|
||||
|
||||
describe('eventsFrom', () => {
|
||||
@@ -105,3 +75,26 @@ describe('eventsFrom', () => {
|
||||
expect(eventsFrom([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('cloud shell — where the terminal is', () => {
|
||||
it('addresses the API host, not the console origin — a frame cannot ride the /v1 proxy', () => {
|
||||
expect(terminalFor('https://api.hanzo.ai', 'm_abc', 'tok', 'dock')).toBe(
|
||||
'https://api.hanzo.ai/v1/sandboxes/m_abc/terminal?ticket=tok&arg=dock',
|
||||
)
|
||||
})
|
||||
|
||||
it('tolerates a trailing slash and escapes what it interpolates', () => {
|
||||
expect(terminalFor('https://api.hanzo.ai/', 'm_1', 't', 'dock')).toContain(
|
||||
'api.hanzo.ai/v1/sandboxes/m_1/terminal',
|
||||
)
|
||||
// An id is hex and a ticket is base64url, but neither is trusted to be: an
|
||||
// unescaped `&` would silently truncate the credential.
|
||||
expect(terminalFor('https://api.hanzo.ai', 'a/b', 'x&y=z', 'p 1')).toBe(
|
||||
'https://api.hanzo.ai/v1/sandboxes/a%2Fb/terminal?ticket=x%26y%3Dz&arg=p%201',
|
||||
)
|
||||
})
|
||||
|
||||
it('names a session, so reopening the dock reattaches instead of starting over', () => {
|
||||
expect(terminalFor('https://api.hanzo.ai', 'm_1', 't', 'dock')).toContain('arg=dock')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,41 +1,10 @@
|
||||
/**
|
||||
* Workbench logic — the PURE half of the bottom Developers dock (parse a shell
|
||||
* command into a safe same-origin `/v1` read; render a response for the terminal).
|
||||
* No React/Gui/registry imports so it is node-testable in isolation.
|
||||
* Workbench logic — the PURE half of the bottom Developers dock: where the cloud
|
||||
* shell's socket lives and what goes over it, how a response renders, and how an
|
||||
* id routes to the `/v1` read that explains it. No React/Gui/registry imports, so
|
||||
* every decision here is node-testable on its own.
|
||||
*/
|
||||
|
||||
export type Command = { path: string } | { error: string }
|
||||
|
||||
const METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'])
|
||||
/** Path charset — segments, query string; nothing that can smuggle a scheme/host. */
|
||||
const PATH_OK = /^[A-Za-z0-9\-_./?=&%,:+]+$/
|
||||
|
||||
/**
|
||||
* Parse a workbench shell line into a `/v1`-relative GET path. Accepted forms:
|
||||
* `GET /v1/models` · `/v1/models` · `v1/models` · `models` (a bare head). The
|
||||
* shell is deliberately READ-ONLY (GET) — a mutation belongs in the product UI,
|
||||
* where it gets its confirm/undo affordances — and `/v1`-only (the one API root).
|
||||
*/
|
||||
export function parseCommand(input: string): Command {
|
||||
const words = input.trim().split(/\s+/).filter(Boolean)
|
||||
if (words.length === 0) return { error: 'Enter a path — e.g. GET /v1/models' }
|
||||
let rest = words
|
||||
const head = words[0].toUpperCase()
|
||||
if (METHODS.has(head)) {
|
||||
if (head !== 'GET') return { error: 'The workbench shell is read-only — only GET is supported.' }
|
||||
rest = words.slice(1)
|
||||
}
|
||||
if (rest.length !== 1) return { error: 'One path per command — e.g. GET /v1/models' }
|
||||
if (/^([a-z][a-z0-9+.-]*:)?\/\//i.test(rest[0])) return { error: 'Only a /v1 path is allowed — e.g. /v1/models' }
|
||||
let path = rest[0].replace(/^\/+/, '')
|
||||
if (path === 'v1' || path === 'v1/') return { error: 'Name a resource — e.g. /v1/models' }
|
||||
if (path.startsWith('v1/')) path = path.slice(3)
|
||||
if (!path || !PATH_OK.test(path) || path.includes('..') || path.includes('//')) {
|
||||
return { error: 'Only a /v1 path is allowed — e.g. /v1/models' }
|
||||
}
|
||||
return { path }
|
||||
}
|
||||
|
||||
/** Pretty-print a shell response, bounded so a huge payload never wedges the DOM. */
|
||||
export function renderOutput(value: unknown, maxChars = 20000): string {
|
||||
let text: string
|
||||
@@ -50,6 +19,9 @@ export function renderOutput(value: unknown, maxChars = 20000): string {
|
||||
|
||||
// ── Inspector — route an object id to its `/v1` GET ───────────────────────────
|
||||
|
||||
/** Path charset — segments and a query string; nothing that can smuggle a scheme/host. */
|
||||
const PATH_OK = /^[A-Za-z0-9\-_./?=&%,:+]+$/
|
||||
|
||||
/** A resolved inspect target (a same-origin `/v1` GET) or an honest parse error. */
|
||||
export type InspectTarget = { path: string; kind: string; label: string } | { error: string }
|
||||
|
||||
@@ -110,11 +82,6 @@ export function curlFor(path: string, origin = 'https://api.hanzo.ai'): string {
|
||||
return `curl ${origin}/v1/${bareResource(path)} \\\n -H "Authorization: Bearer $HANZO_API_KEY"`
|
||||
}
|
||||
|
||||
/** The Hanzo CLI form of the same `/v1` GET. */
|
||||
export function hanzoCli(path: string): string {
|
||||
return `hanzo api get /v1/${bareResource(path)}`
|
||||
}
|
||||
|
||||
// ── Events — project the usage ledger into a platform-event stream ────────────
|
||||
|
||||
/** One platform event, projected from a real charged ledger row (never fabricated). */
|
||||
@@ -152,3 +119,27 @@ export function eventsFrom(records: EventSource[]): PlatformEvent[] {
|
||||
})
|
||||
.sort((a, b) => (b.at ?? -Infinity) - (a.at ?? -Infinity))
|
||||
}
|
||||
|
||||
// ── Cloud shell — where the terminal is ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The terminal's address on the API host.
|
||||
*
|
||||
* Cloud SERVES the terminal — emulator, socket, resize and reconnect, one
|
||||
* self-contained page — so a host that wants a shell frames this rather than
|
||||
* building one. It is the one address in the console that is not same-origin, and
|
||||
* that is forced rather than chosen: the same-origin `/v1` proxy is a Next route
|
||||
* handler, and a route handler forwards requests, not sockets and not frames.
|
||||
* What crosses instead of the session is the single-use ticket the proxy just
|
||||
* fetched, which is the only credential a URL can safely hold.
|
||||
*
|
||||
* `arg` names a tmux session, so reopening the dock reattaches to the shell it
|
||||
* left instead of opening a fresh one over the user's work.
|
||||
*/
|
||||
export function terminalFor(apiBase: string, id: string, ticket: string, session: string): string {
|
||||
const base = apiBase.trim().replace(/\/+$/, '')
|
||||
return (
|
||||
`${base}/v1/sandboxes/${encodeURIComponent(id)}/terminal` +
|
||||
`?ticket=${encodeURIComponent(ticket)}&arg=${encodeURIComponent(session)}`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -51,7 +51,8 @@ import { config } from '~/config'
|
||||
import { MetricCard } from '~/components/ui/Metric'
|
||||
import { RuntimeNotice } from '~/components/products/observability/RuntimeNotice'
|
||||
import { TracesModule } from '~/components/products/TracesModule'
|
||||
import { curlFor, eventsFrom, hanzoCli, inspectorRoute, parseCommand, renderOutput, type PlatformEvent } from './logic'
|
||||
import { curlFor, eventsFrom, inspectorRoute, renderOutput, type PlatformEvent } from './logic'
|
||||
import { Terminal } from './Terminal'
|
||||
import { toneColor } from '~/components/ui/tone'
|
||||
|
||||
const usd = (cents: number, dp = 2): string => `$${(cents / 100).toFixed(dp)}`
|
||||
@@ -903,119 +904,13 @@ export function TracesTab() {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Shell / API Explorer ─────────────────────────────────────────────────────────
|
||||
|
||||
type ShellEntry = { cmd: string; path: string; output: string; ok: boolean }
|
||||
|
||||
/** Quick GET resources — the common `/v1` reads, run with one tap. */
|
||||
const SHELL_RESOURCES = ['models', 'agents', 'prompts', 'functions', 'automations/flows', 'billing/usage'] as const
|
||||
// ── Shell — the cloud shell ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The Shell tab IS a terminal. The component that carries it lives on its own
|
||||
* (Terminal.tsx) because it loads xterm in the browser and owns a socket, and
|
||||
* neither belongs in a file of render-only tabs.
|
||||
*/
|
||||
export function ShellTab() {
|
||||
const [entries, setEntries] = useState<ShellEntry[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
const [running, setRunning] = useState(false)
|
||||
const endRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
endRef.current?.scrollIntoView({ block: 'nearest' })
|
||||
}, [entries])
|
||||
|
||||
const run = useCallback(async (line: string) => {
|
||||
const cmd = line.trim()
|
||||
if (!cmd || running) return
|
||||
setInput('')
|
||||
const parsed = parseCommand(cmd)
|
||||
if ('error' in parsed) {
|
||||
setEntries((e) => [...e, { cmd, path: '', output: parsed.error, ok: false }])
|
||||
return
|
||||
}
|
||||
setRunning(true)
|
||||
try {
|
||||
const data = await restGet<unknown>(cloudProxyV1Url(parsed.path))
|
||||
setEntries((e) => [...e, { cmd, path: parsed.path, output: renderOutput(data), ok: true }])
|
||||
} catch (err) {
|
||||
const output =
|
||||
err instanceof ApiError ? `HTTP ${err.status} — ${err.message}` : err instanceof Error ? err.message : String(err)
|
||||
setEntries((e) => [...e, { cmd, path: parsed.path, output, ok: false }])
|
||||
} finally {
|
||||
setRunning(false)
|
||||
}
|
||||
}, [running])
|
||||
|
||||
return (
|
||||
<YStack flex={1} minH={0}>
|
||||
{/* Resource picker — run a common /v1 GET with one tap. */}
|
||||
<XStack items="center" gap="$1" px="$2" height={34} borderBottomWidth={1} borderColor="$borderColor" flexWrap="wrap">
|
||||
<Text fontSize="$1" color="$color10">
|
||||
GET
|
||||
</Text>
|
||||
{SHELL_RESOURCES.map((r) => (
|
||||
<Button
|
||||
key={r}
|
||||
size="$1"
|
||||
chromeless
|
||||
bg="$color3"
|
||||
rounded="$4"
|
||||
px="$2"
|
||||
onPress={() => void run(`GET /v1/${r}`)}
|
||||
aria-label={`Run GET /v1/${r}`}
|
||||
>
|
||||
<Text fontSize="$1" color="$color11" className="hz-mono">
|
||||
{r}
|
||||
</Text>
|
||||
</Button>
|
||||
))}
|
||||
</XStack>
|
||||
|
||||
<ScrollView flex={1} minH={0}>
|
||||
<YStack p="$3" gap="$2">
|
||||
{entries.length === 0 ? (
|
||||
<Text fontSize="$1" color="$color10" className="hz-mono">
|
||||
Read-only /v1 explorer — pick a resource above or type `GET /v1/models`. Runs as you, in your org.
|
||||
</Text>
|
||||
) : null}
|
||||
{entries.map((e, i) => (
|
||||
<YStack key={i} gap="$1">
|
||||
<XStack items="center" gap="$2">
|
||||
<Text fontSize="$1" color="$color11" className="hz-mono" flex={1} numberOfLines={1}>
|
||||
$ {e.cmd}
|
||||
</Text>
|
||||
{e.ok && e.path ? (
|
||||
<>
|
||||
<CopyBtn value={curlFor(e.path)} label="curl" />
|
||||
<CopyBtn value={hanzoCli(e.path)} label="CLI" />
|
||||
</>
|
||||
) : null}
|
||||
</XStack>
|
||||
<Text fontSize="$1" color={e.ok ? '$color12' : toneColor('critical')} className="hz-mono" style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{e.output}
|
||||
</Text>
|
||||
</YStack>
|
||||
))}
|
||||
<div ref={endRef} />
|
||||
</YStack>
|
||||
</ScrollView>
|
||||
|
||||
<XStack items="center" gap="$2" px="$3" height={44} borderTopWidth={1} borderColor="$borderColor">
|
||||
<Text fontSize="$2" color="$color10" className="hz-mono">
|
||||
$
|
||||
</Text>
|
||||
<Input
|
||||
flex={1}
|
||||
unstyled
|
||||
autoFocus
|
||||
value={input}
|
||||
onChangeText={setInput}
|
||||
onSubmitEditing={() => void run(input)}
|
||||
placeholder={running ? 'Running…' : 'Enter a /v1 command…'}
|
||||
fontSize="$2"
|
||||
color="$color12"
|
||||
className="hz-mono"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
aria-label="Workbench shell command"
|
||||
/>
|
||||
</XStack>
|
||||
</YStack>
|
||||
)
|
||||
return <Terminal />
|
||||
}
|
||||
|
||||
+242
-274
@@ -1,32 +1,35 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Dashboard shell — a TWO-LEVEL sidebar (products, each expanding its own sub-pages
|
||||
* in place) + top bar + content, responsive across phone / tablet / laptop / desktop.
|
||||
* Dashboard shell — a TWO-LEVEL sidebar + top bar + content, responsive across
|
||||
* phone / tablet / laptop / desktop.
|
||||
*
|
||||
* Level 1 (the product list) renders from the catalog: fixed Overview/Docs, a
|
||||
* ONE LEVEL SHOWS AT A TIME, and the ROUTE picks which:
|
||||
*
|
||||
* Level 1 (anywhere outside a product) is the catalog: fixed Overview/Docs, a
|
||||
* Pinned section the user curates, then every product grouped by category. Each
|
||||
* CATEGORY is an INDEPENDENTLY collapsible section that renders EXPANDED by default
|
||||
* (nothing auto-collapses); the header is flush-left with the top-level items and
|
||||
* carries an OPTIONAL collapse chevron whose state persists per-user.
|
||||
*
|
||||
* Level 2 — a product's sub-pages (Overview + specifics + the uniform base set:
|
||||
* Settings · Status · Logs · Metrics) — expands BENEATH that product's own row, so
|
||||
* its options appear without the rest of the catalog going away. The label
|
||||
* navigates; the chevron beside it only expands or collapses, and that choice
|
||||
* persists per-user (the product you are IN is open unless you closed it). Sub-pages
|
||||
* with no backend yet are dimmed and open an honest placeholder, never a dead link.
|
||||
* Level 2 (inside a product) is that product's pages — Overview + specifics + the
|
||||
* uniform base set (Settings · Status · Logs · Metrics) — sitting FLUSH where the
|
||||
* catalog was, under the category you came through, with the rest of that category
|
||||
* beneath them so a sibling stays one click away. A page with no backend yet is
|
||||
* dimmed and opens an honest placeholder, never a dead link.
|
||||
*
|
||||
* This replaced a DRILL: clicking a product used to swap the whole rail for that
|
||||
* product's sub-nav, behind a "Back to all products" button. The options were the
|
||||
* same either way — what the drill took away was every OTHER product, which is
|
||||
* precisely what someone needs when the reason they opened the rail was to go
|
||||
* somewhere else.
|
||||
* Both halves of that used to be true at once: the pages appeared INDENTED under the
|
||||
* product's row while the whole catalog stayed painted below, so two levels shared
|
||||
* the screen and a pinned product carried its pages in one place while its own
|
||||
* category row sat inert in another — the same product twice, one of them dead. The
|
||||
* level is a REPLACEMENT now, which is the only reading of "level" that stays true
|
||||
* when the list is long.
|
||||
*
|
||||
* Level 2 is DECLARED once, in the registry (`subpages` + `indexLabel`), and read
|
||||
* here and by `SubNav` (the same nav, for the viewports where this sidebar is a
|
||||
* drawer). No module carries its own tab list. The level itself is carried by the
|
||||
* URL and nothing else, so a reload, a deep link and Back all agree.
|
||||
* URL and nothing else, so a reload, a deep link and Back all agree — there is no
|
||||
* remembered expansion to disagree with them.
|
||||
*
|
||||
* The WHOLE sidebar collapses to an icon RAIL (the topbar panel toggle, persisted).
|
||||
* When collapsed, HOVER reveals the full sidebar as an OVERLAY flyout (it doesn't
|
||||
@@ -57,6 +60,7 @@ import {
|
||||
BarChart3,
|
||||
Bell,
|
||||
BookOpen,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Circle,
|
||||
CircleHelp,
|
||||
@@ -93,18 +97,15 @@ import { ConsoleFooter } from '~/components/ConsoleFooter'
|
||||
import { openProduct } from '~/lib/products/open'
|
||||
import { entryMatches } from '~/lib/products/search'
|
||||
import { usePins, useProductColors } from '~/lib/products/pins'
|
||||
import { useAppsBeta } from '~/lib/products/beta'
|
||||
import { orderEntries } from '~/lib/products/order'
|
||||
import {
|
||||
categoryIsOpen,
|
||||
toggleCategory,
|
||||
productIsOpen,
|
||||
toggleProduct,
|
||||
NAV_OPEN_PREF,
|
||||
NAV_PRODUCT_OPEN_PREF,
|
||||
NAV_CATALOG_PREF,
|
||||
EMPTY_OPEN,
|
||||
type CategoryOpen,
|
||||
} from '~/lib/products/nav-accordion'
|
||||
} from '~/lib/products/nav'
|
||||
import { usePreferences } from '~/lib/products/preferences'
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { useEntitlements } from '~/lib/entitlements-context'
|
||||
@@ -138,18 +139,6 @@ const CONTENT_MAX = 1680
|
||||
/** Collapsed-rail icon size — large enough to be a comfortable hit target. */
|
||||
const ICON = 20
|
||||
|
||||
/** Icons for the Billing Center tabs — used by the billing-only shell nav. */
|
||||
const BILLING_SUBPAGE_ICON: Record<string, ComponentType<{ size?: number }>> = {
|
||||
'': House,
|
||||
reports: BarChart3,
|
||||
budgets: Bell,
|
||||
invoices: ScrollText,
|
||||
subscriptions: Repeat,
|
||||
'payment-methods': CreditCard,
|
||||
credits: Wallet,
|
||||
}
|
||||
const billingSubpageIcon = (slug: string): ComponentType<{ size?: number }> => BILLING_SUBPAGE_ICON[slug] ?? Circle
|
||||
|
||||
/** The active in-console module id for a path, or null (home / external / unknown). */
|
||||
function activeModuleId(pathname: string): string | null {
|
||||
const seg = pathname.split('/').filter(Boolean)[0]
|
||||
@@ -310,55 +299,49 @@ function NavRow({
|
||||
}
|
||||
|
||||
/**
|
||||
* Level 2 — a product's sub-pages, expanded IN PLACE beneath its own row. The list
|
||||
* is `productSubpages(entry)` (Overview + specifics + the uniform base set), with an
|
||||
* unwired sub-page dimmed but honest: it opens a placeholder, never a dead link.
|
||||
* ONE level-2 row — a single page of the product the rail is currently showing.
|
||||
*
|
||||
* Indented under the product and hung on a hairline, so the nesting is legible
|
||||
* without a second heading — the product's own row above IS the heading. Collapsed,
|
||||
* the rows are `inert`, so hidden options leave the tab order.
|
||||
* Level 2 is a REPLACEMENT, not a nesting: when a product is open the rail lists
|
||||
* ITS pages, so these rows sit FLUSH with the level-1 rows they stand in for. They
|
||||
* used to be indented under the product's row while the whole catalog stayed
|
||||
* painted below, which put two levels on screen at once and left the reader to work
|
||||
* out which list they were in.
|
||||
*
|
||||
* An unwired page is dimmed but honest — it opens a placeholder, never a dead link.
|
||||
* Both faces render through this one row: the full console's level 2 and the
|
||||
* product-shell face, whose nav IS its root module's pages.
|
||||
*/
|
||||
function SubRows({
|
||||
entry,
|
||||
subs,
|
||||
pathname,
|
||||
open,
|
||||
function SubRow({
|
||||
id,
|
||||
sub,
|
||||
active,
|
||||
collapsed,
|
||||
onGo,
|
||||
}: {
|
||||
entry: CatalogEntry
|
||||
subs: ProductSubpage[]
|
||||
pathname: string
|
||||
open: boolean
|
||||
id: string
|
||||
sub: ProductSubpage
|
||||
active: boolean
|
||||
collapsed: boolean
|
||||
onGo: (path: string) => void
|
||||
}) {
|
||||
const activeSlug = activeSubpage(pathname, entry.id)
|
||||
const wired = subpageWired(id, sub.slug)
|
||||
const Icon = sub.icon ?? subpageIcon(sub.slug)
|
||||
return (
|
||||
<div className="hz-acc" data-open={open ? 'true' : 'false'} id={`nav-sub-${entry.id}`} inert={!open}>
|
||||
<div className="hz-acc-inner">
|
||||
<YStack gap="$0.5" ml="$4" pl="$2" pt="$0.5" borderLeftWidth={1} borderColor="$borderColor">
|
||||
{subs.map((sp) => {
|
||||
const wired = subpageWired(entry.id, sp.slug)
|
||||
const active = sp.slug === activeSlug
|
||||
const SubIcon = sp.icon ?? subpageIcon(sp.slug)
|
||||
return (
|
||||
<Button
|
||||
key={sp.slug || 'overview'}
|
||||
onPress={() => onGo(sp.slug ? `/${entry.id}/${sp.slug}` : `/${entry.id}`)}
|
||||
bg={active ? '$color4' : 'transparent'}
|
||||
justify="flex-start"
|
||||
icon={<SubIcon size={15} />}
|
||||
iconAfter={!wired ? <Circle size={7} opacity={0.5} /> : undefined}
|
||||
size="$2"
|
||||
opacity={wired ? 1 : 0.6}
|
||||
aria-label={wired ? sp.label : `${sp.label} (not available yet)`}
|
||||
>
|
||||
{sp.label}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</YStack>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onPress={() => onGo(sub.slug ? `/${id}/${sub.slug}` : `/${id}`)}
|
||||
bg={active ? '$color4' : 'transparent'}
|
||||
justify={collapsed ? 'center' : 'flex-start'}
|
||||
px={collapsed ? '$0' : '$2.5'}
|
||||
height={collapsed ? 44 : undefined}
|
||||
icon={<Icon size={collapsed ? ICON : 17} />}
|
||||
iconAfter={!collapsed && !wired ? <Circle size={7} opacity={0.5} /> : undefined}
|
||||
size="$3"
|
||||
opacity={wired ? 1 : 0.6}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
aria-label={wired ? sub.label : `${sub.label} (not available yet)`}
|
||||
>
|
||||
{collapsed ? undefined : sub.label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -473,11 +456,9 @@ function SidebarNav({
|
||||
const { colorOf } = useProductColors()
|
||||
const detail = useDetailPane()
|
||||
const showAdmin = useIsSuperAdmin()
|
||||
const showBeta = useAppsBeta(showAdmin)
|
||||
// Entitlement scope: currently ungated in prod (the endpoint 404s → `enabled` is
|
||||
// null → the full catalog shows), matching "every product is always available".
|
||||
const { enabled } = useEntitlements()
|
||||
const [filter, setFilter] = useState('')
|
||||
|
||||
// Collapsible-category accordion state — the user's EXPLICIT per-category collapse
|
||||
// choices, persisted per-user. Everything is EXPANDED by default; a collapsed
|
||||
@@ -485,75 +466,52 @@ function SidebarNav({
|
||||
const prefs = usePreferences()
|
||||
const navOpen = prefs.get<CategoryOpen>(NAV_OPEN_PREF, EMPTY_OPEN)
|
||||
const toggleSection = (category: string) => prefs.set(NAV_OPEN_PREF, toggleCategory(navOpen, category))
|
||||
// Does the rail list the whole catalog, or only what you keep? (Profile → Account.)
|
||||
const catalogInRail = prefs.get<boolean>(NAV_CATALOG_PREF, true)
|
||||
|
||||
// ── Level 2, in place ─────────────────────────────────────────────────────
|
||||
// A product's sub-pages expand beneath its own row; nothing replaces the list.
|
||||
// ── Which level the rail is on ────────────────────────────────────────────
|
||||
// The ROUTE decides, and nothing else: inside a product the rail shows THAT
|
||||
// product's pages plus the rest of its category (level 2); anywhere else it shows
|
||||
// the catalog (level 1). One level on screen at a time, and a reload, a deep link
|
||||
// and Back all land on the same rail because none of it is remembered state.
|
||||
const activeId = activeModuleId(pathname)
|
||||
const isActive = (id: string) => pathname === `/${id}` || pathname.startsWith(`/${id}/`)
|
||||
const productOpen = prefs.get<CategoryOpen>(NAV_PRODUCT_OPEN_PREF, EMPTY_OPEN)
|
||||
const toggleExpand = (id: string) =>
|
||||
prefs.set(NAV_PRODUCT_OPEN_PREF, toggleProduct(productOpen, id, { active: id === activeId }))
|
||||
|
||||
// Navigate to a LEAF (a sub-page or a no-sub-page product) — closes the drawer.
|
||||
const go = (path: string) => {
|
||||
router.push(path)
|
||||
onNavigate()
|
||||
}
|
||||
// Open a product from the list. One with sub-pages keeps the drawer open, because
|
||||
// becoming active expands it in place and its options are the next thing to read;
|
||||
// a leaf navigates and closes. An external launch tile opens its deployed app in a
|
||||
// new tab.
|
||||
// Open a product from the list. One with pages keeps the drawer open, because the
|
||||
// rail is about to become that product's pages and they are the next thing to
|
||||
// read; a leaf navigates and closes. An external tile opens its app in a new tab.
|
||||
const open = (entry: CatalogEntry) => {
|
||||
if (entry.kind === 'external') {
|
||||
openProduct(entry, go)
|
||||
onNavigate()
|
||||
return
|
||||
}
|
||||
setFilter('')
|
||||
const subs = productSubpages(entry, showAdmin)
|
||||
if (subs.length > 1) {
|
||||
router.push(`/${entry.id}`) // its sub-pages open beneath it
|
||||
if (productSubpages(entry, showAdmin).length > 1) {
|
||||
router.push(`/${entry.id}`) // the rail becomes its pages
|
||||
} else {
|
||||
go(`/${entry.id}`) // leaf — navigate + close
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ONE product row — the row itself plus, for a product that has them, its sub-pages
|
||||
* expanded beneath. Both the Pinned group and the category groups render through
|
||||
* this, so a product looks and behaves identically wherever it appears.
|
||||
*/
|
||||
const productRow = (entry: CatalogEntry, opts: { pinned?: boolean } = {}) => {
|
||||
const subs = productSubpages(entry, showAdmin)
|
||||
// A pinned product appears TWICE — once under Pinned, once in its category — and
|
||||
// only ONE of those may carry the sub-pages. Two copies of the same list is two
|
||||
// navs painting at once, which is the very thing this rail exists to avoid, and
|
||||
// it doubles the rail's height for no information. The PINNED copy owns it: the
|
||||
// user put it up there, and it is the one they read first.
|
||||
const owns = opts.pinned || !isPinned(entry.id)
|
||||
const expandable = owns && entry.kind === 'module' && subs.length > 1
|
||||
const expanded = expandable && productIsOpen(productOpen, entry.id, { filtering, active: entry.id === activeId })
|
||||
return (
|
||||
<YStack key={`${opts.pinned ? 'pin' : 'cat'}-${entry.id}`} gap="$0.5">
|
||||
<NavRow
|
||||
entry={entry}
|
||||
active={isActive(entry.id)}
|
||||
color={colorOf(entry.id)}
|
||||
collapsed={false}
|
||||
pinned={opts.pinned ?? isPinned(entry.id)}
|
||||
expandable={expandable}
|
||||
expanded={expanded}
|
||||
onExpand={expandable ? () => toggleExpand(entry.id) : undefined}
|
||||
onOpen={() => open(entry)}
|
||||
onToggle={opts.pinned ? undefined : () => toggle(entry.id)}
|
||||
onCustomize={opts.pinned ? () => customize(entry) : undefined}
|
||||
/>
|
||||
{expandable ? (
|
||||
<SubRows entry={entry} subs={subs} pathname={pathname} open={expanded} onGo={go} />
|
||||
) : null}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
/** ONE product row. Pinned rows carry the customize dot, catalog rows the pin star. */
|
||||
const productRow = (entry: CatalogEntry, opts: { pinned?: boolean } = {}) => (
|
||||
<NavRow
|
||||
key={`${opts.pinned ? 'pin' : 'cat'}-${entry.id}`}
|
||||
entry={entry}
|
||||
active={isActive(entry.id)}
|
||||
color={colorOf(entry.id)}
|
||||
collapsed={false}
|
||||
pinned={opts.pinned ?? isPinned(entry.id)}
|
||||
onOpen={() => open(entry)}
|
||||
onToggle={opts.pinned ? undefined : () => toggle(entry.id)}
|
||||
onCustomize={opts.pinned ? () => customize(entry) : undefined}
|
||||
/>
|
||||
)
|
||||
const openDocs = () => {
|
||||
if (typeof window !== 'undefined') window.open(config.docsUrl, '_blank', 'noopener')
|
||||
onNavigate()
|
||||
@@ -580,9 +538,6 @@ function SidebarNav({
|
||||
content: <AddProductPanel />,
|
||||
})
|
||||
|
||||
const q = (collapsed ? '' : filter).trim().toLowerCase()
|
||||
const filtering = q.length > 0
|
||||
|
||||
// Grouped pins, gated so a customer never sees an admin-only surface.
|
||||
const pinnedGroups = useMemo(
|
||||
() =>
|
||||
@@ -591,28 +546,43 @@ function SidebarNav({
|
||||
...g,
|
||||
entries: g.entries.filter((e) => {
|
||||
const found = findEntry(e.id)
|
||||
return Boolean(found) && (showAdmin || !found!.admin) && (showBeta || !found!.beta)
|
||||
return Boolean(found) && (showAdmin || !found!.admin)
|
||||
}),
|
||||
}))
|
||||
.filter((g) => g.entries.length > 0),
|
||||
[view, showAdmin, showBeta],
|
||||
[view, showAdmin],
|
||||
)
|
||||
|
||||
// Within-scope ordering is CONTINUOUS ALPHABETICAL with the SELECTED product pinned
|
||||
// first — via the ONE shared `orderEntries` helper. Categories stay in their
|
||||
// canonical order; only the items inside each are alphabetized + selected-first.
|
||||
//
|
||||
// "Show every product" opens the ENTITLEMENT scope, exactly as the All-products
|
||||
// panel already does: every product is available to every org on demand, so what
|
||||
// an org has ENABLED is a statement about use, not about permission. Off, the rail
|
||||
// narrows to that enabled set. PERMISSION — admin surfaces, brand scope — holds
|
||||
// either way, so nothing on the rail is ever a surface the viewer cannot open.
|
||||
const groups = useMemo(
|
||||
() =>
|
||||
// Search is DISCOVERY: while a query is typed the entitlement scope opens
|
||||
// to the whole catalog — the point of searching is finding what you do
|
||||
// not have yet — while the admin and beta gates keep holding. The resting
|
||||
// rail stays scoped to the org's enabled set.
|
||||
visibleCatalogByCategory(showAdmin, filtering ? null : enabled, showBeta)
|
||||
.map((g) => ({ category: g.category, entries: orderEntries(g.entries.filter((e) => entryMatches(e, q)), activeId) }))
|
||||
visibleCatalogByCategory(showAdmin, catalogInRail ? null : enabled)
|
||||
.map((g) => ({ category: g.category, entries: orderEntries(g.entries, activeId) }))
|
||||
.filter((g) => g.entries.length > 0),
|
||||
[q, filtering, showAdmin, showBeta, enabled, activeId],
|
||||
[showAdmin, catalogInRail, enabled, activeId],
|
||||
)
|
||||
|
||||
// ── Level 2 — the product the route is inside ─────────────────────────────
|
||||
// Its pages become the rail, and the rest of its category follows them, so moving
|
||||
// to a sibling stays one click.
|
||||
const level = activeId ? findEntry(activeId) : undefined
|
||||
// A product with no pages of its own is a LEAF: there is no second level to show,
|
||||
// so the rail stays on the catalog with that row lit. Same rule `open` navigates by.
|
||||
const here =
|
||||
level && (showAdmin || !level.admin) && productSubpages(level, showAdmin).length > 1 ? level : undefined
|
||||
const siblings =
|
||||
here && catalogInRail
|
||||
? (groups.find((g) => g.category === here.category)?.entries ?? []).filter((e) => e.id !== here.id)
|
||||
: []
|
||||
|
||||
// ── Product-shell face — the nav IS the root module's sub-pages ────────────
|
||||
if (isProductShell(config.shell)) {
|
||||
const shell = shellFor(config.shell)
|
||||
@@ -650,25 +620,16 @@ function SidebarNav({
|
||||
) : null}
|
||||
<ScrollView flex={1}>
|
||||
<YStack gap="$1">
|
||||
{subs.map((sp) => {
|
||||
const active = sp.slug === activeSlug
|
||||
const SubIcon = sp.icon ?? billingSubpageIcon(sp.slug)
|
||||
return (
|
||||
<Button
|
||||
key={sp.slug || 'overview'}
|
||||
onPress={() => go(sp.slug ? `/${rootId}/${sp.slug}` : `/${rootId}`)}
|
||||
bg={active ? '$color4' : 'transparent'}
|
||||
justify={collapsed ? 'center' : 'flex-start'}
|
||||
px={collapsed ? '$0' : '$2.5'}
|
||||
height={collapsed ? 44 : undefined}
|
||||
icon={<SubIcon size={collapsed ? ICON : 17} />}
|
||||
size="$3"
|
||||
aria-label={sp.label}
|
||||
>
|
||||
{collapsed ? undefined : sp.label}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
{subs.map((sp) => (
|
||||
<SubRow
|
||||
key={sp.slug || 'overview'}
|
||||
id={rootId}
|
||||
sub={sp}
|
||||
active={sp.slug === activeSlug}
|
||||
collapsed={collapsed}
|
||||
onGo={go}
|
||||
/>
|
||||
))}
|
||||
</YStack>
|
||||
</ScrollView>
|
||||
<SidebarAccount collapsed={collapsed} />
|
||||
@@ -684,7 +645,7 @@ function SidebarNav({
|
||||
const seen = new Set<string>()
|
||||
for (const id of [...pinnedIds, ...(activeId ? [activeId] : [])]) {
|
||||
const e = findEntry(id)
|
||||
if (e && !seen.has(id) && (showAdmin || !e.admin) && (showBeta || !e.beta)) {
|
||||
if (e && !seen.has(id) && (showAdmin || !e.admin)) {
|
||||
seen.add(id)
|
||||
railIds.push(id)
|
||||
}
|
||||
@@ -725,9 +686,8 @@ function SidebarNav({
|
||||
)
|
||||
}
|
||||
|
||||
// ── The product list: brand; filter; Overview/Docs; Pinned; every category
|
||||
// (EXPANDED by default, collapsible), each product expanding its own sub-pages
|
||||
// in place; All-products; identity + wallet. ──
|
||||
// ── The rail: switcher; filter; Overview/Docs; then ONE level — the catalog, or
|
||||
// the open product's pages followed by its category; All-products; identity. ──
|
||||
return (
|
||||
// The product rail is a NAVIGATION LANDMARK. It had no role at all, so a
|
||||
// screen-reader user had no way to jump to the product list and no way to
|
||||
@@ -744,109 +704,139 @@ function SidebarNav({
|
||||
no switcher to carry the identity there. */}
|
||||
<ContextSwitcher />
|
||||
|
||||
{/* Product filter — narrows the whole list; a match from any category jumps
|
||||
straight there. Typing hides the section chrome so the list stays scannable. */}
|
||||
<XStack
|
||||
items="center"
|
||||
gap="$2"
|
||||
px="$2.5"
|
||||
mb="$2"
|
||||
height={34}
|
||||
rounded="$3"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
bg="$color2"
|
||||
>
|
||||
<Search size={14} opacity={0.6} />
|
||||
<Input
|
||||
flex={1}
|
||||
unstyled
|
||||
value={filter}
|
||||
onChangeText={setFilter}
|
||||
placeholder="Filter products…"
|
||||
fontSize="$3"
|
||||
color="$color12"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
{filter ? (
|
||||
<Button size="$1" chromeless icon={<X size={13} />} onPress={() => setFilter('')} aria-label="Clear filter" />
|
||||
) : null}
|
||||
{/* Search — the SAME palette the header and the drawer open, so the whole
|
||||
catalog (every product AND every page inside one) is one query away from
|
||||
the rail, at either level. The rail used to carry its own text filter that
|
||||
narrowed only the rows it had already drawn: a second search, answering a
|
||||
smaller question. */}
|
||||
<XStack mb="$2">
|
||||
<CommandSearchBox />
|
||||
</XStack>
|
||||
|
||||
<ScrollView flex={1} minH={0}>
|
||||
<YStack gap="$3.5">
|
||||
{!filtering ? (
|
||||
<YStack gap="$1">
|
||||
<FixedRow icon={House} label="Overview" active={pathname === '/'} collapsed={false} onPress={() => go('/')} />
|
||||
<FixedRow icon={BookOpen} label="Docs" external collapsed={false} onPress={openDocs} />
|
||||
</YStack>
|
||||
) : null}
|
||||
<YStack gap="$1">
|
||||
<FixedRow icon={House} label="Overview" active={pathname === '/'} collapsed={false} onPress={() => go('/')} />
|
||||
<FixedRow icon={BookOpen} label="Docs" external collapsed={false} onPress={openDocs} />
|
||||
</YStack>
|
||||
|
||||
{!filtering && pinnedGroups.length > 0 ? (
|
||||
<YStack gap="$1.5">
|
||||
<XStack items="center" justify="space-between" px="$2.5">
|
||||
<Text fontSize="$1" color="$color10" fontWeight="500">
|
||||
Pinned
|
||||
</Text>
|
||||
<Button size="$1" chromeless onPress={manage} aria-label="Manage pins">
|
||||
<Text fontSize="$1" color="$color10" fontWeight="700">
|
||||
Manage
|
||||
{here ? (
|
||||
/* LEVEL 2 — the open product's pages, flush, then the rest of its
|
||||
category. The catalog is not painted underneath: one level at a
|
||||
time, and the category row above is the way back up to it. */
|
||||
<>
|
||||
<YStack gap="$1">
|
||||
<Button
|
||||
size="$2"
|
||||
chromeless
|
||||
height={32}
|
||||
px="$2.5"
|
||||
justify="flex-start"
|
||||
onPress={() => go(`/category/${categorySlug(here.category)}`)}
|
||||
icon={<ChevronLeft size={14} opacity={0.7} />}
|
||||
aria-label={`Back to ${here.category}`}
|
||||
>
|
||||
<Text fontSize="$1" color="$color10" fontWeight="500">
|
||||
{here.category}
|
||||
</Text>
|
||||
</Button>
|
||||
</XStack>
|
||||
{pinnedGroups.map((group) => (
|
||||
<YStack key={group.name || 'default'} gap="$1">
|
||||
{group.name ? (
|
||||
<Text px="$2.5" fontSize="$1" color="$color9" fontWeight="700">
|
||||
{group.label}
|
||||
</Text>
|
||||
) : null}
|
||||
{group.entries.map((e) => {
|
||||
const entry = findEntry(e.id)
|
||||
if (!entry) return null
|
||||
return productRow(entry, { pinned: true })
|
||||
})}
|
||||
{/* The product names the list below it — a heading, not a link:
|
||||
its index is the first row, and two ways to the same page is
|
||||
the duplication this level exists to remove. */}
|
||||
<XStack items="center" gap="$2" px="$2.5" pb="$0.5">
|
||||
<ProductIcon icon={here.icon} color={colorOf(here.id)} size={18} />
|
||||
<Text fontSize="$3" fontWeight="700" color="$color12" numberOfLines={1}>
|
||||
{here.label}
|
||||
</Text>
|
||||
</XStack>
|
||||
{productSubpages(here, showAdmin).map((sp) => (
|
||||
<SubRow
|
||||
key={sp.slug || 'overview'}
|
||||
id={here.id}
|
||||
sub={sp}
|
||||
active={sp.slug === activeSubpage(pathname, here.id)}
|
||||
collapsed={false}
|
||||
onGo={go}
|
||||
/>
|
||||
))}
|
||||
</YStack>
|
||||
|
||||
{siblings.length > 0 ? (
|
||||
<YStack gap="$1">
|
||||
<Text px="$2.5" fontSize="$1" color="$color10" fontWeight="500">
|
||||
More in {here.category}
|
||||
</Text>
|
||||
{siblings.map((entry) => productRow(entry))}
|
||||
</YStack>
|
||||
))}
|
||||
</YStack>
|
||||
) : null}
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
/* LEVEL 1 — what you pinned, then every category. */
|
||||
<>
|
||||
{pinnedGroups.length > 0 ? (
|
||||
<YStack gap="$1.5">
|
||||
<XStack items="center" justify="space-between" px="$2.5">
|
||||
<Text fontSize="$1" color="$color10" fontWeight="500">
|
||||
Pinned
|
||||
</Text>
|
||||
<Button size="$1" chromeless onPress={manage} aria-label="Manage pins">
|
||||
<Text fontSize="$1" color="$color10" fontWeight="700">
|
||||
Manage
|
||||
</Text>
|
||||
</Button>
|
||||
</XStack>
|
||||
{pinnedGroups.map((group) => (
|
||||
<YStack key={group.name || 'default'} gap="$1">
|
||||
{group.name ? (
|
||||
<Text px="$2.5" fontSize="$1" color="$color9" fontWeight="700">
|
||||
{group.label}
|
||||
</Text>
|
||||
) : null}
|
||||
{group.entries.map((e) => {
|
||||
const entry = findEntry(e.id)
|
||||
if (!entry) return null
|
||||
return productRow(entry, { pinned: true })
|
||||
})}
|
||||
</YStack>
|
||||
))}
|
||||
</YStack>
|
||||
) : null}
|
||||
|
||||
{groups.map((group) => (
|
||||
<CategorySection
|
||||
key={group.category}
|
||||
category={group.category}
|
||||
count={group.entries.length}
|
||||
open={categoryIsOpen(navOpen, group.category, { filtering })}
|
||||
onToggle={() => toggleSection(group.category)}
|
||||
>
|
||||
{group.entries.map((entry) => productRow(entry))}
|
||||
</CategorySection>
|
||||
))}
|
||||
|
||||
{filtering && groups.length === 0 ? (
|
||||
<Text px="$2.5" py="$3" fontSize="$2" color="$color10">
|
||||
No products match “{filter.trim()}”.
|
||||
</Text>
|
||||
) : null}
|
||||
{catalogInRail ? (
|
||||
groups.map((group) => (
|
||||
<CategorySection
|
||||
key={group.category}
|
||||
category={group.category}
|
||||
count={group.entries.length}
|
||||
open={categoryIsOpen(navOpen, group.category)}
|
||||
onToggle={() => toggleSection(group.category)}
|
||||
>
|
||||
{group.entries.map((entry) => productRow(entry))}
|
||||
</CategorySection>
|
||||
))
|
||||
) : level && !isPinned(level.id) ? (
|
||||
/* The catalog is put away, so the rail is the pins — plus wherever
|
||||
you are, which would otherwise be the one place with no row. */
|
||||
<YStack gap="$1">{productRow(level)}</YStack>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Browse the full catalog — pin/unpin + find what's in use. Always
|
||||
available (no enable gate; every product is always on). */}
|
||||
{!filtering ? (
|
||||
<Button
|
||||
size="$2"
|
||||
chromeless
|
||||
justify="flex-start"
|
||||
icon={<LayoutGrid size={16} />}
|
||||
onPress={allProducts}
|
||||
aria-label="All products"
|
||||
mt="$1"
|
||||
>
|
||||
<Text fontSize="$2" color="$color11" fontWeight="600">
|
||||
All products
|
||||
</Text>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="$2"
|
||||
chromeless
|
||||
justify="flex-start"
|
||||
icon={<LayoutGrid size={16} />}
|
||||
onPress={allProducts}
|
||||
aria-label="All products"
|
||||
mt="$1"
|
||||
>
|
||||
<Text fontSize="$2" color="$color11" fontWeight="600">
|
||||
All products
|
||||
</Text>
|
||||
</Button>
|
||||
</YStack>
|
||||
</ScrollView>
|
||||
|
||||
@@ -868,29 +858,7 @@ function NavDrawer({ open, onOpenChange }: { open: boolean; onOpenChange: (o: bo
|
||||
on phones/tablets (see globals.css); the desktop sidebar stays dense. */}
|
||||
<YStack flex={1} minH={0} p="$3" gap="$2.5" className="hz-touch-target">
|
||||
<XStack gap="$2" items="center">
|
||||
<XStack
|
||||
flex={1}
|
||||
onPress={() => {
|
||||
onOpenChange(false)
|
||||
palette.open()
|
||||
}}
|
||||
cursor="pointer"
|
||||
items="center"
|
||||
gap="$2"
|
||||
px="$3"
|
||||
height={44}
|
||||
bg="$color2"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
rounded="$4"
|
||||
hoverStyle={{ borderColor: '$color8' }}
|
||||
>
|
||||
<Search size={15} opacity={0.6} />
|
||||
<Text flex={1} fontSize="$3" color="$color10" numberOfLines={1}>
|
||||
Search or ask AI…
|
||||
</Text>
|
||||
<Command size={13} opacity={0.5} />
|
||||
</XStack>
|
||||
<CommandSearchBox height={44} onOpen={() => onOpenChange(false)} />
|
||||
{/* No second "Apps" trigger here either — the search row above opens the
|
||||
very same palette, so one row is the whole affordance. */}
|
||||
{/* Explicit close — right-aligned INSIDE the drawer header, always reachable
|
||||
|
||||
@@ -27,6 +27,17 @@ export function titleCase(name: string): string {
|
||||
return name ? name.charAt(0).toUpperCase() + name.slice(1) : name
|
||||
}
|
||||
|
||||
/**
|
||||
* What to CALL an org — its IAM display name when it has one, else its slug
|
||||
* titled. ONE rule, so the switcher's trigger, the row for that same org inside
|
||||
* it, and the rail's brand all say the same word. They used to disagree: the
|
||||
* trigger titled the slug and the rows printed it raw, so the control could read
|
||||
* "Acme" over an active row reading "acme".
|
||||
*/
|
||||
export function orgLabel(org: { name?: string; displayName?: string }): string {
|
||||
return org.displayName?.trim() || titleCase(org.name ?? '')
|
||||
}
|
||||
|
||||
/**
|
||||
* The one-line summary the context switcher shows: the org, then the project
|
||||
* slice of it. Org-level scope has no project, and says so by omission rather
|
||||
|
||||
@@ -110,7 +110,7 @@ export {
|
||||
type RawValidator,
|
||||
type RawPeer,
|
||||
} from './nodes'
|
||||
export { TeamApi } from './team'
|
||||
export { TeamApi, MembershipApi, orgNamesFor, type Membership } from './team'
|
||||
export {
|
||||
PlaygroundApi,
|
||||
type ChatMessage,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { orgNamesFor } from './team'
|
||||
|
||||
describe('orgNamesFor — the orgs a person may act in', () => {
|
||||
it('leads with the home org, then the memberships', () => {
|
||||
expect(
|
||||
orgNamesFor('hanzo', [
|
||||
{ user: 'hanzo/dave', org: 'maxpower', role: 'admin' },
|
||||
{ user: 'hanzo/dave', org: 'acme', role: 'member' },
|
||||
]),
|
||||
).toEqual(['hanzo', 'maxpower', 'acme'])
|
||||
})
|
||||
|
||||
it('never repeats the home org when it is also a membership row', () => {
|
||||
expect(
|
||||
orgNamesFor('hanzo', [
|
||||
{ user: 'hanzo/dave', org: 'hanzo', role: 'member' },
|
||||
{ user: 'hanzo/dave', org: 'maxpower', role: 'admin' },
|
||||
]),
|
||||
).toEqual(['hanzo', 'maxpower'])
|
||||
})
|
||||
|
||||
it('is the home org alone when there are no memberships', () => {
|
||||
expect(orgNamesFor('hanzo', [])).toEqual(['hanzo'])
|
||||
})
|
||||
|
||||
it('drops blank names rather than rendering a nameless card', () => {
|
||||
expect(
|
||||
orgNamesFor('hanzo', [
|
||||
{ user: 'hanzo/dave', org: '', role: 'member' },
|
||||
{ user: 'hanzo/dave', org: ' ', role: 'member' },
|
||||
{ user: 'hanzo/dave', org: 'maxpower', role: 'admin' },
|
||||
]),
|
||||
).toEqual(['hanzo', 'maxpower'])
|
||||
})
|
||||
|
||||
// A signed-in person with no resolvable home org still gets an honest empty
|
||||
// list rather than a card named "".
|
||||
it('is empty when there is no home org and no membership', () => {
|
||||
expect(orgNamesFor('', [])).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -21,6 +21,46 @@ import type { Organization, IamUser, Role } from './admin'
|
||||
* email/OTP is not wired on this deployment). */
|
||||
export type InviteLink = { link: string; org: string; name: string; email: string }
|
||||
|
||||
/** One org a person may act in, with the role they hold there. */
|
||||
export type Membership = { user: string; org: string; role: string }
|
||||
|
||||
/**
|
||||
* The organizations THIS person may act in.
|
||||
*
|
||||
* A person's account lives in ONE tenant; the organizations they work in are a
|
||||
* SET, and the membership rows are that set — the same one the token's `orgs`
|
||||
* claim carries and the same one IAM's policy authorizes reads with. Anything
|
||||
* that lists "your orgs" reads this: deriving the list from the account's owner
|
||||
* instead is how a second org became invisible and a card ended up titled with
|
||||
* the signed-in person's name.
|
||||
*
|
||||
* The caller's HOME org is not necessarily a row here (it is implicit), so
|
||||
* callers union it in — {@link orgNamesFor} does.
|
||||
*/
|
||||
export const MembershipApi = {
|
||||
mine: async (userId: string): Promise<Membership[]> => {
|
||||
const { rows } = await iamList<Membership>('memberships', { user: userId })
|
||||
return rows.filter((m) => m && typeof m.org === 'string' && m.org !== '')
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Every org name the caller can act in, home org FIRST and duplicates removed.
|
||||
* Pure, so the ordering rule is testable without a network: the home org leads
|
||||
* because it is the one a person lands in by default.
|
||||
*/
|
||||
export function orgNamesFor(homeOrg: string, memberships: Membership[]): string[] {
|
||||
const seen = new Set<string>()
|
||||
const out: string[] = []
|
||||
for (const name of [homeOrg, ...memberships.map((m) => m.org)]) {
|
||||
const n = (name ?? '').trim()
|
||||
if (!n || seen.has(n)) continue
|
||||
seen.add(n)
|
||||
out.push(n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export const TeamApi = {
|
||||
/** Members of `orgName` (the caller's own org, or any for a global admin). */
|
||||
members: (orgName: string, params: ListParams = {}): Promise<Paged<IamUser>> =>
|
||||
|
||||
@@ -54,46 +54,6 @@ export const ALWAYS_ON_PRODUCTS: readonly string[] = [
|
||||
'captable', // the org's capitalization ledger — foundational company surface, every org (peer of 'company')
|
||||
]
|
||||
|
||||
/**
|
||||
* THE LAUNCH SET — what a new signup sees on console.hanzo.ai tonight.
|
||||
*
|
||||
* We are launching with hanzo.chat, hanzo.app and the console, so the console
|
||||
* shows exactly what those need and nothing else. Every other product in the
|
||||
* catalog (the whole cloud: compute, data, network, security, web3, the app
|
||||
* suite, the fleet admin) is BETA — present, routable, and invisible until an
|
||||
* org holds the beta flag. A superadmin always sees everything.
|
||||
*
|
||||
* This is an ALLOW-LIST on purpose: a new product added to the catalog is
|
||||
* hidden by DEFAULT and joins the launch only when someone names it here. The
|
||||
* inverse (a deny-list) leaks every future addition onto a customer's first
|
||||
* screen.
|
||||
*
|
||||
* The set: the AI plane the two products run on, the credential to call it,
|
||||
* the money surfaces, org/account management, and the beta door itself.
|
||||
*/
|
||||
export const LAUNCH_PRODUCTS: readonly string[] = [
|
||||
// the console itself
|
||||
'overview', // the home board
|
||||
'beta-features', // the door to everything else — never behind its own flag
|
||||
// the AI plane hanzo.chat + hanzo.app run on
|
||||
'chat', // hanzo.chat
|
||||
'models', // the model catalog
|
||||
'playground', // try a model
|
||||
'api-keys', // the credential both products call with
|
||||
'usage', // what the AI cost
|
||||
'logs', // the request log for those calls
|
||||
// money
|
||||
'billing',
|
||||
'plans',
|
||||
// org + account
|
||||
'settings',
|
||||
'team',
|
||||
'profile',
|
||||
]
|
||||
|
||||
/** True when a product is part of tonight's launch surface. */
|
||||
export const isLaunchProduct = (id: string): boolean => LAUNCH_PRODUCTS.includes(id)
|
||||
|
||||
/** True when a product is always-on (implicit, never stored in `enabled`). */
|
||||
export const isAlwaysOn = (id: string): boolean => ALWAYS_ON_PRODUCTS.includes(id)
|
||||
|
||||
@@ -130,23 +90,6 @@ export function filterEntitled<T extends { id: string }>(
|
||||
* semantics so the UI can optimistically preview a change with the same result the
|
||||
* backend would compute. Remove wins over add for the same id in one patch.
|
||||
*/
|
||||
/**
|
||||
* Keep only the entries a viewer's BETA standing admits. Pure and generic like
|
||||
* `filterEntitled`, and the same one-predicate rule: a superadmin sees
|
||||
* everything, a beta org sees everything, everyone else loses `beta: true`
|
||||
* entries. Fails CLOSED — callers that have not asked the enablement plane
|
||||
* pass `showBeta: false` and beta surfaces stay hidden.
|
||||
*/
|
||||
export function filterBeta<T extends { id: string; beta?: boolean }>(
|
||||
entries: readonly T[],
|
||||
showBeta: boolean,
|
||||
showAdmin: boolean,
|
||||
): T[] {
|
||||
if (showAdmin || showBeta) return [...entries]
|
||||
// Beta is the COMPLEMENT of the launch set: outside it, or stamped.
|
||||
return entries.filter((e) => isLaunchProduct(e.id) && e.beta !== true)
|
||||
}
|
||||
|
||||
export function nextEnabled(current: readonly string[], patch: EntitlementPatch): string[] {
|
||||
const set = new Set<string>(current)
|
||||
for (const id of patch.add ?? []) if (id) set.add(id)
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { filterBeta, LAUNCH_PRODUCTS } from '~/lib/entitlements'
|
||||
|
||||
// We launch with hanzo.chat, hanzo.app and the console: the launch set is an
|
||||
// ALLOW-LIST, so everything else is beta by default and a NEW catalog entry is
|
||||
// hidden the day it lands. That default is the whole point — pin it.
|
||||
describe('filterBeta — the launch gate', () => {
|
||||
const entries = [
|
||||
{ id: 'chat' },
|
||||
{ id: 'models' },
|
||||
{ id: 'api-keys' },
|
||||
{ id: 'beta-features' },
|
||||
{ id: 'crm' },
|
||||
{ id: 'gpus' },
|
||||
{ id: 'lux-bridge' },
|
||||
{ id: 'a-product-nobody-has-written-yet' },
|
||||
]
|
||||
|
||||
it('shows the launch set and hides everything else', () => {
|
||||
expect(filterBeta(entries, false, false).map((e) => e.id)).toEqual([
|
||||
'chat',
|
||||
'models',
|
||||
'api-keys',
|
||||
'beta-features',
|
||||
])
|
||||
})
|
||||
|
||||
it('a brand-new catalog entry is hidden by DEFAULT, not by remembering to stamp it', () => {
|
||||
const shown = filterBeta([{ id: 'something-new-2027' }], false, false)
|
||||
expect(shown).toEqual([])
|
||||
})
|
||||
|
||||
it('the flag reveals everything; a superadmin never needed it', () => {
|
||||
expect(filterBeta(entries, true, false)).toHaveLength(entries.length)
|
||||
expect(filterBeta(entries, false, true)).toHaveLength(entries.length)
|
||||
})
|
||||
|
||||
it('the beta door itself is in the launch set — otherwise nobody can opt in', () => {
|
||||
expect(LAUNCH_PRODUCTS).toContain('beta-features')
|
||||
})
|
||||
|
||||
it('a stamped entry inside the launch set can still ship dark', () => {
|
||||
expect(filterBeta([{ id: 'chat', beta: true }], false, false)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,67 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* useAppsBeta — does this viewer see the beta (Apps) surfaces?
|
||||
*
|
||||
* ONE source of truth: the enablement plane (`/v1/enablement`), the same
|
||||
* self-service opt-in the Beta features module manages, scoped server-side to
|
||||
* the caller's validated org. The gate looks for the `apps` feature (kind
|
||||
* `feature`, id `apps`) being EFFECTIVE for the org — an admin sets it to
|
||||
* `beta` (optionally granting orgs), users opt in where allowed, and this hook
|
||||
* simply reads the resulting truth.
|
||||
*
|
||||
* Fails CLOSED: until the read answers — and whenever it refuses — beta
|
||||
* surfaces stay hidden. A superadmin always sees them (mirror of the `admin`
|
||||
* gate, and the only way the flag surface itself can be administered when the
|
||||
* plane is down). Cached for the session like the org identity is: every nav
|
||||
* surface asks, one request answers.
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { EnablementApi } from '~/lib/api/admin-cockpit'
|
||||
|
||||
const APPS_KIND = 'feature'
|
||||
const APPS_ID = 'apps'
|
||||
|
||||
let cached: boolean | null = null
|
||||
let inflight: Promise<boolean> | null = null
|
||||
|
||||
async function readAppsBeta(): Promise<boolean> {
|
||||
if (cached !== null) return cached
|
||||
if (!inflight) {
|
||||
inflight = EnablementApi.view()
|
||||
.then((v) => {
|
||||
const all = [...v.items, ...v.betas]
|
||||
const hit = all.find((i) => i.kind === APPS_KIND && i.id === APPS_ID)
|
||||
cached = Boolean(hit?.effective)
|
||||
return cached
|
||||
})
|
||||
.catch(() => {
|
||||
// A refusal is not an entitlement. Do not cache it — the next mount
|
||||
// may be after sign-in or after the plane recovers.
|
||||
inflight = null
|
||||
return false
|
||||
})
|
||||
}
|
||||
return inflight
|
||||
}
|
||||
|
||||
export function useAppsBeta(isSuperAdmin: boolean): boolean {
|
||||
const [on, setOn] = useState<boolean>(() => isSuperAdmin || cached === true)
|
||||
|
||||
useEffect(() => {
|
||||
if (isSuperAdmin) {
|
||||
setOn(true)
|
||||
return
|
||||
}
|
||||
let live = true
|
||||
readAppsBeta().then((v) => {
|
||||
if (live) setOn(v)
|
||||
})
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [isSuperAdmin])
|
||||
|
||||
return on
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
categoryIsOpen,
|
||||
toggleCategory,
|
||||
productIsOpen,
|
||||
toggleProduct,
|
||||
NAV_OPEN_PREF,
|
||||
NAV_PRODUCT_OPEN_PREF,
|
||||
type CategoryOpen,
|
||||
} from './nav-accordion'
|
||||
|
||||
const ctx = (filtering = false) => ({ filtering })
|
||||
|
||||
describe('categoryIsOpen (expand-by-default)', () => {
|
||||
it('defaults to EXPANDED for an untouched category (nothing auto-collapses)', () => {
|
||||
expect(categoryIsOpen({}, 'AI', ctx())).toBe(true)
|
||||
expect(categoryIsOpen({}, 'Observe', ctx())).toBe(true)
|
||||
expect(categoryIsOpen({ Data: false }, 'AI', ctx())).toBe(true) // untouched section stays open
|
||||
})
|
||||
|
||||
it('respects an explicit COLLAPSE, and stays where the user left it', () => {
|
||||
expect(categoryIsOpen({ Observe: false }, 'Observe', ctx())).toBe(false)
|
||||
// a re-opened section (explicit true) stays open
|
||||
expect(categoryIsOpen({ Observe: true }, 'Observe', ctx())).toBe(true)
|
||||
})
|
||||
|
||||
it('each section is INDEPENDENT — collapsing one leaves the others expanded', () => {
|
||||
const stored: CategoryOpen = { Observe: false }
|
||||
expect(categoryIsOpen(stored, 'Observe', ctx())).toBe(false)
|
||||
expect(categoryIsOpen(stored, 'AI', ctx())).toBe(true)
|
||||
expect(categoryIsOpen(stored, 'Platform', ctx())).toBe(true)
|
||||
})
|
||||
|
||||
it('opens every group while filtering, so a search match is never hidden', () => {
|
||||
expect(categoryIsOpen({ AI: false }, 'AI', ctx(true))).toBe(true)
|
||||
expect(categoryIsOpen({ Observe: false }, 'Observe', ctx(true))).toBe(true)
|
||||
})
|
||||
|
||||
it('restores the stored/default state once filtering clears', () => {
|
||||
const stored: CategoryOpen = { AI: false, Data: true }
|
||||
expect(categoryIsOpen(stored, 'AI', ctx(false))).toBe(false)
|
||||
expect(categoryIsOpen(stored, 'Data', ctx(false))).toBe(true)
|
||||
expect(categoryIsOpen(stored, 'Compute', ctx(false))).toBe(true) // untouched → open
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggleCategory (independent per-section)', () => {
|
||||
it('collapses a default-open (untouched) category on first toggle', () => {
|
||||
expect(toggleCategory({}, 'AI')).toEqual({ AI: false })
|
||||
})
|
||||
|
||||
it('re-opens an explicitly-collapsed category', () => {
|
||||
expect(toggleCategory({ AI: false }, 'AI')).toEqual({ AI: true })
|
||||
})
|
||||
|
||||
it('round-trips: toggling twice returns to the default-open state (stored true)', () => {
|
||||
const once = toggleCategory({}, 'Observe')
|
||||
expect(once).toEqual({ Observe: false })
|
||||
const twice = toggleCategory(once, 'Observe')
|
||||
expect(twice).toEqual({ Observe: true })
|
||||
expect(categoryIsOpen(twice, 'Observe', ctx())).toBe(true)
|
||||
})
|
||||
|
||||
it('NEVER touches other sections (independent — not single-open)', () => {
|
||||
// collapsing AI leaves an already-collapsed Observe collapsed and everything else default-open
|
||||
expect(toggleCategory({ Observe: false }, 'AI')).toEqual({ Observe: false, AI: false })
|
||||
// opening a second section does NOT collapse the first (the old single-open invariant is gone)
|
||||
expect(toggleCategory({ Observe: false, Data: false }, 'AI')).toEqual({ Observe: false, Data: false, AI: false })
|
||||
})
|
||||
|
||||
it('is immutable — never mutates the input state', () => {
|
||||
const stored: CategoryOpen = { AI: false }
|
||||
const next = toggleCategory(stored, 'AI')
|
||||
expect(stored).toEqual({ AI: false })
|
||||
expect(next).not.toBe(stored)
|
||||
})
|
||||
})
|
||||
|
||||
describe('productIsOpen / toggleProduct', () => {
|
||||
// Where you are is the one product whose options you are certain to want.
|
||||
it('opens the ACTIVE product and leaves the others closed', () => {
|
||||
expect(productIsOpen({}, 'agents', { filtering: false, active: true })).toBe(true)
|
||||
expect(productIsOpen({}, 'models', { filtering: false, active: false })).toBe(false)
|
||||
})
|
||||
|
||||
it('respects an explicit choice over the active default, in both directions', () => {
|
||||
expect(productIsOpen({ agents: false }, 'agents', { filtering: false, active: true })).toBe(false)
|
||||
expect(productIsOpen({ models: true }, 'models', { filtering: false, active: false })).toBe(true)
|
||||
})
|
||||
|
||||
// The filter narrows PRODUCTS; a matched product's sub-pages are not themselves
|
||||
// matches, so expanding them would push the other hits off screen.
|
||||
it('closes everything while filtering, active or not', () => {
|
||||
expect(productIsOpen({}, 'agents', { filtering: true, active: true })).toBe(false)
|
||||
expect(productIsOpen({ agents: true }, 'agents', { filtering: true, active: true })).toBe(false)
|
||||
})
|
||||
|
||||
// Whichever way the chevron points, the click does that.
|
||||
it('first click on the active one collapses it; on any other one expands it', () => {
|
||||
expect(toggleProduct({}, 'agents', { active: true })).toEqual({ agents: false })
|
||||
expect(toggleProduct({}, 'models', { active: false })).toEqual({ models: true })
|
||||
})
|
||||
|
||||
it('leaves every other product untouched and never mutates the input', () => {
|
||||
const stored = { models: true }
|
||||
expect(toggleProduct(stored, 'agents', { active: false })).toEqual({ models: true, agents: true })
|
||||
expect(stored).toEqual({ models: true })
|
||||
})
|
||||
|
||||
// Products and categories share a preference SHAPE but not a default, and they are
|
||||
// stored under different keys — a product must never inherit a category's open-by-default.
|
||||
it('is keyed apart from the category accordion', () => {
|
||||
expect(NAV_PRODUCT_OPEN_PREF).not.toBe(NAV_OPEN_PREF)
|
||||
})
|
||||
})
|
||||
@@ -1,105 +0,0 @@
|
||||
/**
|
||||
* Sidebar category accordion — the pure open/collapse model for the level-1
|
||||
* product nav. Each CATEGORY is an INDEPENDENTLY collapsible section; this module
|
||||
* holds the tiny decision logic (what renders open, how a toggle mutates it) with
|
||||
* NO React, so it is unit-testable in isolation and the shell (`Dashboard`)
|
||||
* stays a thin binding over it.
|
||||
*
|
||||
* EXPAND-BY-DEFAULT: every category renders EXPANDED by default — nothing
|
||||
* auto-collapses, so the whole product catalog reads at a glance (OBSERVE, PLATFORM,
|
||||
* DEV, APPS, SETTINGS, … all open). A user may explicitly COLLAPSE any section (the
|
||||
* optional per-section chevron); that ONE choice is persisted per-user (account-backed
|
||||
* + localStorage cache) via `usePreferences` under `NAV_OPEN_PREF` and RESPECTED on
|
||||
* every render — the section stays exactly where the user left it. While FILTERING,
|
||||
* every section opens so a search match is never hidden behind a collapsed section.
|
||||
*
|
||||
* This is NOT a single-open accordion: collapsing one section leaves the others
|
||||
* untouched (each is independent), so the default is a fully-expanded nav.
|
||||
*/
|
||||
|
||||
/** The user's EXPLICIT per-section open/closed choices (sparse). A MISSING key = the
|
||||
* default (OPEN). A stored `false` = the user collapsed that section; `true` = the
|
||||
* user re-opened one they'd collapsed. Kept as a Record for preference-shape stability. */
|
||||
export type CategoryOpen = Partial<Record<string, boolean>>
|
||||
|
||||
/** Preference key (account-backed prefs) for the accordion open-state. */
|
||||
export const NAV_OPEN_PREF = 'navCategoriesOpen'
|
||||
|
||||
/** A stable empty reference for the prefs fallback (avoids a fresh object per read,
|
||||
* which would otherwise re-trigger memo/effect deps downstream). */
|
||||
export const EMPTY_OPEN: CategoryOpen = {}
|
||||
|
||||
/**
|
||||
* Whether a category renders EXPANDED, given the user's stored choices + context:
|
||||
* - while FILTERING: always open, so a match is never hidden behind a collapsed
|
||||
* section (the group list is already narrowed to non-empty matches);
|
||||
* - otherwise: the user's EXPLICIT choice if they made one, else the DEFAULT (OPEN).
|
||||
* A section the user never touched is open; one they collapsed stays collapsed
|
||||
* (and one they re-opened stays open) — it stays where the user left it.
|
||||
*/
|
||||
export function categoryIsOpen(
|
||||
stored: CategoryOpen,
|
||||
category: string,
|
||||
ctx: { filtering: boolean },
|
||||
): boolean {
|
||||
if (ctx.filtering) return true
|
||||
const v = stored[category]
|
||||
return v === undefined ? true : v
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a single category (pure + immutable — never mutates the input). Each
|
||||
* section is INDEPENDENT (NOT single-open): toggling one leaves every other one
|
||||
* exactly as it was. A section with no stored choice is OPEN by default, so its
|
||||
* first toggle COLLAPSES it (stores `false`); toggling again re-opens it (`true`).
|
||||
*/
|
||||
export function toggleCategory(stored: CategoryOpen, category: string): CategoryOpen {
|
||||
const current = stored[category] === undefined ? true : stored[category]
|
||||
return { ...stored, [category]: !current }
|
||||
}
|
||||
|
||||
// ── Products, which expand IN PLACE ─────────────────────────────────────────
|
||||
//
|
||||
// A product with sub-pages expands beneath its own row, so its options appear
|
||||
// without the rest of the catalog disappearing. This replaced a DRILL — clicking a
|
||||
// product used to swap the entire rail for that product's sub-nav, behind a "Back to
|
||||
// all products" button. The options were the same either way; what the drill took
|
||||
// away was every other product, which is exactly what a person needs to see when the
|
||||
// reason they clicked was to compare or to move on somewhere else.
|
||||
//
|
||||
// The default here is the OPPOSITE of a category's, and deliberately: categories are
|
||||
// few and describe the whole catalog, so they open; products are many and each brings
|
||||
// four to eight rows, so opening them all would bury the catalog under its own detail.
|
||||
|
||||
/** Preference key for which products are expanded in the rail. */
|
||||
export const NAV_PRODUCT_OPEN_PREF = 'navProductsOpen'
|
||||
|
||||
/**
|
||||
* Whether a product's sub-pages render EXPANDED:
|
||||
* - while FILTERING: closed. The filter narrows PRODUCTS, and a matched product's
|
||||
* sub-pages are not themselves matches — expanding them would push the other hits
|
||||
* off screen;
|
||||
* - the ACTIVE product: open, unless the user explicitly collapsed it. Where you are
|
||||
* is the one place whose options you are certain to want;
|
||||
* - otherwise: the user's explicit choice, else CLOSED.
|
||||
*/
|
||||
export function productIsOpen(
|
||||
stored: CategoryOpen,
|
||||
id: string,
|
||||
ctx: { filtering: boolean; active: boolean },
|
||||
): boolean {
|
||||
if (ctx.filtering) return false
|
||||
const v = stored[id]
|
||||
return v === undefined ? ctx.active : v
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle one product's expansion (pure + immutable). The stored value is what the
|
||||
* product is being toggled AWAY from, so the first click on the active product
|
||||
* collapses it and the first click on any other one expands it — in both cases the
|
||||
* click does the thing the chevron was pointing at.
|
||||
*/
|
||||
export function toggleProduct(stored: CategoryOpen, id: string, ctx: { active: boolean }): CategoryOpen {
|
||||
const current = stored[id] === undefined ? ctx.active : stored[id]
|
||||
return { ...stored, [id]: !current }
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { categoryIsOpen, toggleCategory, type CategoryOpen } from './nav'
|
||||
|
||||
describe('categoryIsOpen (expand-by-default)', () => {
|
||||
it('defaults to EXPANDED for an untouched category (nothing auto-collapses)', () => {
|
||||
expect(categoryIsOpen({}, 'AI')).toBe(true)
|
||||
expect(categoryIsOpen({}, 'Observe')).toBe(true)
|
||||
expect(categoryIsOpen({ Data: false }, 'AI')).toBe(true) // untouched section stays open
|
||||
})
|
||||
|
||||
it('respects an explicit COLLAPSE, and stays where the user left it', () => {
|
||||
expect(categoryIsOpen({ Observe: false }, 'Observe')).toBe(false)
|
||||
// a re-opened section (explicit true) stays open
|
||||
expect(categoryIsOpen({ Observe: true }, 'Observe')).toBe(true)
|
||||
})
|
||||
|
||||
it('each section is INDEPENDENT — collapsing one leaves the others expanded', () => {
|
||||
const stored: CategoryOpen = { Observe: false }
|
||||
expect(categoryIsOpen(stored, 'Observe')).toBe(false)
|
||||
expect(categoryIsOpen(stored, 'AI')).toBe(true)
|
||||
expect(categoryIsOpen(stored, 'Platform')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggleCategory (independent per-section)', () => {
|
||||
it('collapses a default-open (untouched) category on first toggle', () => {
|
||||
expect(toggleCategory({}, 'AI')).toEqual({ AI: false })
|
||||
})
|
||||
|
||||
it('re-opens an explicitly-collapsed category', () => {
|
||||
expect(toggleCategory({ AI: false }, 'AI')).toEqual({ AI: true })
|
||||
})
|
||||
|
||||
it('round-trips: toggling twice returns to the default-open state (stored true)', () => {
|
||||
const once = toggleCategory({}, 'Observe')
|
||||
expect(once).toEqual({ Observe: false })
|
||||
const twice = toggleCategory(once, 'Observe')
|
||||
expect(twice).toEqual({ Observe: true })
|
||||
expect(categoryIsOpen(twice, 'Observe')).toBe(true)
|
||||
})
|
||||
|
||||
it('NEVER touches other sections (independent — not single-open)', () => {
|
||||
// collapsing AI leaves an already-collapsed Observe collapsed and everything else default-open
|
||||
expect(toggleCategory({ Observe: false }, 'AI')).toEqual({ Observe: false, AI: false })
|
||||
// opening a second section does NOT collapse the first (the old single-open invariant is gone)
|
||||
expect(toggleCategory({ Observe: false, Data: false }, 'AI')).toEqual({ Observe: false, Data: false, AI: false })
|
||||
})
|
||||
|
||||
it('is immutable — never mutates the input state', () => {
|
||||
const stored: CategoryOpen = { AI: false }
|
||||
const next = toggleCategory(stored, 'AI')
|
||||
expect(stored).toEqual({ AI: false })
|
||||
expect(next).not.toBe(stored)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* The sidebar's persisted view model — what the rail SHOWS and what is OPEN, as
|
||||
* pure values with NO React, so the decisions are unit-testable in isolation and
|
||||
* the shell (`Dashboard`) stays a thin binding over them.
|
||||
*
|
||||
* EXPAND-BY-DEFAULT: every category renders EXPANDED by default — nothing
|
||||
* auto-collapses, so the whole product catalog reads at a glance (OBSERVE, PLATFORM,
|
||||
* DEV, APPS, SETTINGS, … all open). A user may explicitly COLLAPSE any section (the
|
||||
* optional per-section chevron); that ONE choice is persisted per-user (account-backed
|
||||
* + localStorage cache) via `usePreferences` under `NAV_OPEN_PREF` and RESPECTED on
|
||||
* every render — the section stays exactly where the user left it.
|
||||
*
|
||||
* This is NOT a single-open accordion: collapsing one section leaves the others
|
||||
* untouched (each is independent), so the default is a fully-expanded nav.
|
||||
*/
|
||||
|
||||
/** The user's EXPLICIT per-section open/closed choices (sparse). A MISSING key = the
|
||||
* default (OPEN). A stored `false` = the user collapsed that section; `true` = the
|
||||
* user re-opened one they'd collapsed. Kept as a Record for preference-shape stability. */
|
||||
export type CategoryOpen = Partial<Record<string, boolean>>
|
||||
|
||||
/** Preference key (account-backed prefs) for the accordion open-state. */
|
||||
export const NAV_OPEN_PREF = 'navCategoriesOpen'
|
||||
|
||||
/** A stable empty reference for the prefs fallback (avoids a fresh object per read,
|
||||
* which would otherwise re-trigger memo/effect deps downstream). */
|
||||
export const EMPTY_OPEN: CategoryOpen = {}
|
||||
|
||||
/**
|
||||
* Whether a category renders EXPANDED: the user's EXPLICIT choice if they made one,
|
||||
* else the DEFAULT (OPEN). A section the user never touched is open; one they
|
||||
* collapsed stays collapsed (and one they re-opened stays open) — it stays where
|
||||
* the user left it.
|
||||
*/
|
||||
export function categoryIsOpen(stored: CategoryOpen, category: string): boolean {
|
||||
const v = stored[category]
|
||||
return v === undefined ? true : v
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a single category (pure + immutable — never mutates the input). Each
|
||||
* section is INDEPENDENT (NOT single-open): toggling one leaves every other one
|
||||
* exactly as it was. A section with no stored choice is OPEN by default, so its
|
||||
* first toggle COLLAPSES it (stores `false`); toggling again re-opens it (`true`).
|
||||
*/
|
||||
export function toggleCategory(stored: CategoryOpen, category: string): CategoryOpen {
|
||||
const current = stored[category] === undefined ? true : stored[category]
|
||||
return { ...stored, [category]: !current }
|
||||
}
|
||||
|
||||
// ── Whether the rail lists the catalog ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Preference key: does the rail list EVERY product, or only the ones you keep?
|
||||
*
|
||||
* ON (the default) the rail is the whole catalog — every product the viewer may
|
||||
* SEE. Permission decides that (admin surfaces stay hidden; a brand shows only its
|
||||
* own categories); what the org has ENABLED does not, because every product is
|
||||
* available to every org on demand. The All-products panel had already settled this
|
||||
* for itself and the rail had not, so one catalog came back two different sizes
|
||||
* depending which you asked.
|
||||
*
|
||||
* OFF narrows the rail to the org's enabled set, plus what you pinned and wherever
|
||||
* you are — just what you work with. Nothing is unreachable either way: the search
|
||||
* box at the head of the rail asks the whole catalog, and "All products" at its foot
|
||||
* lists all of it.
|
||||
*/
|
||||
export const NAV_CATALOG_PREF = 'navCatalog'
|
||||
@@ -109,7 +109,7 @@ import { Users,
|
||||
} from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { config, type BrandId, type ShellId } from '~/config'
|
||||
import { ALWAYS_ON_PRODUCTS, filterBeta, filterEntitled, isLaunchProduct } from '~/lib/entitlements'
|
||||
import { ALWAYS_ON_PRODUCTS, filterEntitled } from '~/lib/entitlements'
|
||||
import { type ProductCategory, categoryOrder, categoriesForBrand, categoryInBrand } from './brand-scope'
|
||||
import { shellFor, isProductShell } from './shell'
|
||||
import { ProvidersModule } from '~/components/products/ProvidersModule'
|
||||
@@ -421,14 +421,6 @@ type CatalogBase = {
|
||||
docs?: string
|
||||
/** Admin-gated surface (shown with a lock hint; access enforced server-side). */
|
||||
admin?: boolean
|
||||
/**
|
||||
* Beta (early-access) product — hidden from every nav, palette, discovery
|
||||
* panel and search until the caller's ORG holds the `apps` beta through the
|
||||
* enablement plane (kind `feature`, id `apps`) — the same self-service
|
||||
* opt-in the Beta features module manages. Superadmins always see them, and
|
||||
* the gate fails CLOSED: no enablement read, no beta surfaces.
|
||||
*/
|
||||
beta?: boolean
|
||||
/**
|
||||
* Per-brand scope — the brands whose console shows this entry (`entryInBrandScope`).
|
||||
* OMIT for a brand-agnostic entry (the default: shown on every brand its category
|
||||
@@ -3811,17 +3803,6 @@ export const catalogByCategory = (): { category: ProductCategory; entries: Catal
|
||||
/** An admin-only (global / Hanzo-managed) entry — hidden from a customer's nav. */
|
||||
export const isAdminEntry = (e: CatalogEntry): boolean => e.admin === true
|
||||
|
||||
/**
|
||||
* A BETA entry — hidden until the org holds the beta flag (or is a superadmin).
|
||||
*
|
||||
* Beta is the COMPLEMENT of the launch set, not a per-entry stamp: we are
|
||||
* launching with hanzo.chat, hanzo.app and the console, so everything outside
|
||||
* `LAUNCH_PRODUCTS` is beta by default and a new catalog entry is hidden the
|
||||
* day it lands. `beta: true` still forces the flag on for an entry inside the
|
||||
* launch set, which is how a launch surface can ship dark.
|
||||
*/
|
||||
export const isBetaEntry = (e: CatalogEntry): boolean => e.beta === true || !isLaunchProduct(e.id)
|
||||
|
||||
/**
|
||||
* Per-brand category scope — the ONE knob that makes each brand's console show
|
||||
* the right surfaces. `hanzo` is the full AI cloud. The sovereign-chain brands
|
||||
@@ -3855,13 +3836,7 @@ export const inBrand = (e: CatalogEntry): boolean =>
|
||||
// former `BILLING_CENTER_ID`/`MARKETING_ID`/`ADS_ID`/`SOCIAL_ID` per-mode consts were
|
||||
// collapsed into it (a name is a value in one namespace, no parallel id constants).
|
||||
|
||||
export const visibleCatalog = (
|
||||
showAdmin: boolean,
|
||||
enabled?: string[] | null,
|
||||
// Fails CLOSED on purpose: a caller that has not asked the enablement plane
|
||||
// does not show beta surfaces.
|
||||
showBeta = false,
|
||||
): CatalogEntry[] => {
|
||||
export const visibleCatalog = (showAdmin: boolean, enabled?: string[] | null): CatalogEntry[] => {
|
||||
// Product-shell face (billing / marketing / ads / social / sentry host, or an
|
||||
// override): the SAME console image, scoped to ONE product FACE — its root module
|
||||
// surfaced alone. Bypass the brand-category + entitlement scope so the face shows on
|
||||
@@ -3877,7 +3852,7 @@ export const visibleCatalog = (
|
||||
// belong to their face, not the general nav (e.g. the sentry panels are the o11y
|
||||
// surfaces' Sentry twin, shown only on sentry.<brand>). marketing/ads/social carry
|
||||
// NO `e.shell` (normal Apps products), so they ALSO show in the full console.
|
||||
const byAdmin = filterBeta(showAdmin ? catalog : catalog.filter((e) => !isAdminEntry(e)), showBeta, showAdmin)
|
||||
const byAdmin = (showAdmin ? catalog : catalog.filter((e) => !isAdminEntry(e)))
|
||||
.filter((e) => !e.shell)
|
||||
.filter(inBrand)
|
||||
// ENTITLEMENT GATE (customer only): out-of-box an org sees ONLY the products it has
|
||||
@@ -3891,9 +3866,8 @@ export const visibleCatalog = (
|
||||
export const visibleCatalogByCategory = (
|
||||
showAdmin: boolean,
|
||||
enabled?: string[] | null,
|
||||
showBeta = false,
|
||||
): { category: ProductCategory; entries: CatalogEntry[] }[] => {
|
||||
const visible = visibleCatalog(showAdmin, enabled, showBeta)
|
||||
const visible = visibleCatalog(showAdmin, enabled)
|
||||
// In a product-shell face the root module IS the whole catalog — surface it as a
|
||||
// single group regardless of the brand's category order (its category may be
|
||||
// outside the brand's normal set). ONE branch for EVERY face.
|
||||
|
||||
@@ -87,14 +87,14 @@ function scoreDestination(q: string, d: Destination): number {
|
||||
* deep sub-page jumps ("queues" → Compute › Tasks › Queues). `showAdmin` gates
|
||||
* admin-only surfaces so a customer can't jump to what they can't see.
|
||||
*/
|
||||
export function searchDestinations(query: string, showAdmin = true, enabled?: string[] | null, showBeta = false): Destination[] {
|
||||
export function searchDestinations(query: string, showAdmin = true, enabled?: string[] | null): Destination[] {
|
||||
const q = query.trim().toLowerCase()
|
||||
// Scope to the visible catalog (brand + billing-only shell + entitlements), then
|
||||
// gate admin — so ⌘K jumps match exactly what the nav shows (billing-only offers
|
||||
// only billing; a customer only what their org has enabled). A TYPED query is
|
||||
// DISCOVERY: the entitlement scope opens to the whole catalog — searching is
|
||||
// for finding what you do not have yet — while admin and beta keep holding.
|
||||
const all = destinationsFor(visibleCatalog(showAdmin, q ? null : enabled, showBeta), showAdmin)
|
||||
// for finding what you do not have yet — while the admin gate keeps holding.
|
||||
const all = destinationsFor(visibleCatalog(showAdmin, q ? null : enabled), showAdmin)
|
||||
if (!q) return all.filter((d) => d.kind === 'product')
|
||||
return all
|
||||
.map((d) => ({ d, s: scoreDestination(q, d) }))
|
||||
|
||||
@@ -246,6 +246,17 @@ export const CLOUD_HEADS: readonly string[] = [
|
||||
'gpus',
|
||||
'fleet',
|
||||
'clusters',
|
||||
// Sandboxes (cloud apps/sandbox): the org's leased gVisor pods — lease/list/get/
|
||||
// end, exec, fs, and the ticket that opens an interactive terminal
|
||||
// (/v1/sandboxes[/:id[/exec|/fs|/terminal]]). Same gate as the rest: the handler
|
||||
// resolves the org from the Bearer owner and answers 403 without one, and an id
|
||||
// belonging to another org is a 404.
|
||||
//
|
||||
// The terminal's SOCKET does not come through here and cannot: a Next route
|
||||
// handler proxies requests, not upgrades. The browser dials the API host
|
||||
// directly, carrying the single-use ticket this proxy fetched for it — which is
|
||||
// the whole reason the ticket exists.
|
||||
'sandboxes',
|
||||
// DO-native: virtual private clouds and managed load balancers — FULL CRUD
|
||||
// (/v1/vpcs[/:id], /v1/balancers[/:id]).
|
||||
'vpcs',
|
||||
|
||||
Reference in New Issue
Block a user