fix(chrome): the console wears the ORG's identity, and the switcher is the account control's peer
Two defects in the cloud console's chrome, both fixed in the SHARED control (@hanzo/ui 8.0.11) so every surface inherits them, not just this one. The top-left mark showed the house H whenever an org had set no logo — a customer's console showing OUR brand. It now renders `OrgMark` unconditionally: the org's own logo when IAM carries one, else the org's MONOGRAM, the treatment the account widget already gives a person. Never the house glyph, never the org name as running text. The org switcher was a caption beside a control. Its trigger is now the peer of the account row — 44px tall, a 30px mark, the same type, the same hit area — and `SidebarWorkspace` is a COLUMN so it stretches the sidebar's width the way the account row does (a row container had shrunk it to its text). One org-identity source: `useOrgLogo` (a URL) becomes `useOrgIdentity` (name, display name, logo — one cached read), fed to BOTH the mark and the switcher's new `current` prop, so the two slots can never disagree and a user with no cross-tenant list still gets their own logo. The dead `BrandLogo` component, a second copy of the same logo-else-mark decision, is gone. `e2e/org-identity.spec.ts` measures it off the rendered boxes: without the change the mark paints an SVG with no monogram and the switcher has no trigger to find.
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* e2e: the console's ORG identity in the chrome — mocked-network render proof.
|
||||
*
|
||||
* Two things this pins, both of which the shipped console got wrong:
|
||||
*
|
||||
* 1. The top-left mark is the ORG's, never the house glyph. With a logo it is
|
||||
* that logo; with none it is the org's MONOGRAM — the treatment the account
|
||||
* widget gives a person — and NOT the brand mark, and NOT the org's name set
|
||||
* as running text.
|
||||
* 2. The org switcher is the PEER of the account control: same height, same
|
||||
* mark size, same type, same hit area, same left edge.
|
||||
*
|
||||
* Both are measured off the RENDERED boxes, not off class names, so a styling
|
||||
* regression fails here.
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:4000 npx playwright test org-identity
|
||||
*/
|
||||
import { test, expect, type Page, type Route } from '@playwright/test'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { requireFixtureServer } from './_fixture'
|
||||
import { primeSession } from './_session'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
const SHOTS = join(process.cwd(), 'e2e-shots')
|
||||
|
||||
requireFixtureServer()
|
||||
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
|
||||
|
||||
/** A tenant whose id carries a separator — its monogram must read AL, not A. */
|
||||
const ORG = 'acme-labs'
|
||||
const LOGO = 'https://cdn.example.test/acme-labs.png'
|
||||
const API_RE = /\/(v1|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
|
||||
|
||||
const envelope = (data: unknown) => JSON.stringify({ status: 'ok', msg: '', data, data2: 0 })
|
||||
|
||||
/** Mount the shell as a member of `acme-labs`; `logo` decides which mark shows. */
|
||||
async function openShell(page: Page, logo: string | null) {
|
||||
await page.route('**/*', async (route: Route) => {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
|
||||
// The ONE org read the chrome makes (`useOrgIdentity` → get-organization).
|
||||
if (url.pathname.endsWith('/v1/iam/get-organization')) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: envelope({ owner: 'admin', name: ORG, displayName: 'Acme Labs', logo: logo ?? '' }),
|
||||
})
|
||||
}
|
||||
// The logo bytes — a 1x1 PNG, so the <img> genuinely paints.
|
||||
if (url.href === LOGO) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'image/png',
|
||||
body: Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
),
|
||||
})
|
||||
}
|
||||
if (url.origin === new URL(BASE_URL).origin && !API_RE.test(url.pathname)) return route.continue()
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: envelope([]) })
|
||||
})
|
||||
|
||||
// A plain tenant member (owner !== 'admin'), i.e. NOT a super admin — the case
|
||||
// that has no cross-tenant org list to draw its own row from.
|
||||
await primeSession(page, { owner: ORG, name: 'dave', email: 'dave@acme.test', displayName: 'Dave Lorenzini', isAdmin: false })
|
||||
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.getByRole('button', { name: /account menu/i }).first()).toBeVisible({ timeout: 30_000 })
|
||||
}
|
||||
|
||||
const orgMark = (page: Page) => page.getByRole('link', { name: /— home/ }).first()
|
||||
const orgTrigger = (page: Page) => page.getByRole('button', { name: /switch organization/i }).first()
|
||||
const accountTrigger = (page: Page) => page.getByRole('button', { name: /account menu/i }).first()
|
||||
|
||||
test('the top-left mark is the org monogram — never the house mark, never the name as text', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await openShell(page, null)
|
||||
|
||||
const mark = orgMark(page)
|
||||
await expect(mark).toBeVisible()
|
||||
|
||||
// The monogram of the org's DISPLAY name, by the account widget's own rule.
|
||||
await expect(mark).toHaveText('AL')
|
||||
|
||||
// Not the house glyph: the slot paints no SVG at all.
|
||||
expect(await mark.locator('svg').count()).toBe(0)
|
||||
// Not the org name as running text.
|
||||
await expect(mark).not.toContainText('Acme Labs')
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'org-identity-monogram.png') })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('the org’s OWN logo replaces the mark when IAM carries one', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await openShell(page, LOGO)
|
||||
|
||||
const logo = orgMark(page).locator('img')
|
||||
await expect(logo).toHaveAttribute('src', LOGO)
|
||||
// The logo REPLACES the monogram — one mark, not both.
|
||||
await expect(orgMark(page)).toHaveText('')
|
||||
expect(await orgMark(page).locator('svg').count()).toBe(0)
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'org-identity-logo.png') })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('the org switcher reads as the peer of the account control', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await openShell(page, null)
|
||||
|
||||
const org = orgTrigger(page)
|
||||
const account = accountTrigger(page)
|
||||
await expect(org).toBeVisible()
|
||||
await expect(account).toBeVisible()
|
||||
|
||||
const [orgBox, accountBox] = [await org.boundingBox(), await account.boundingBox()]
|
||||
if (!orgBox || !accountBox) throw new Error('a switcher did not lay out')
|
||||
|
||||
// Same height, same width, same left edge — one hit area, one column.
|
||||
expect(Math.round(orgBox.height)).toBe(Math.round(accountBox.height))
|
||||
expect(Math.abs(orgBox.width - accountBox.width)).toBeLessThanOrEqual(1)
|
||||
expect(Math.abs(orgBox.x - accountBox.x)).toBeLessThanOrEqual(1)
|
||||
// A real target, not a caption.
|
||||
expect(orgBox.height).toBeGreaterThanOrEqual(44)
|
||||
|
||||
// Same type: the org name and the account name are set identically.
|
||||
const type = (root: typeof org, name: string) =>
|
||||
root.locator(`text=${name}`).first().evaluate((el) => {
|
||||
const s = getComputedStyle(el)
|
||||
return { size: s.fontSize, weight: s.fontWeight }
|
||||
})
|
||||
expect(await type(org, 'Acme Labs')).toEqual(await type(account, 'Dave Lorenzini'))
|
||||
|
||||
// Same mark size — the org monogram tile matches the account avatar tile.
|
||||
const tile = async (root: typeof org) => {
|
||||
const b = await root.locator('div,span').filter({ hasText: /^(AL|DL)$/ }).last().boundingBox()
|
||||
return b ? { w: Math.round(b.width), h: Math.round(b.height) } : null
|
||||
}
|
||||
expect(await tile(org)).toEqual(await tile(account))
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'org-identity-peers.png') })
|
||||
// The sidebar column alone — the two controls, top and bottom, side by side.
|
||||
await page.screenshot({ path: join(SHOTS, 'org-identity-sidebar.png'), clip: { x: 0, y: 0, width: 300, height: 900 } })
|
||||
await ctx.close()
|
||||
})
|
||||
Generated
+47
-12
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@hanzo/console",
|
||||
"version": "8.5.24",
|
||||
"version": "8.5.31",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@hanzo/console",
|
||||
"version": "8.5.24",
|
||||
"version": "8.5.31",
|
||||
"hasInstallScript": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
@@ -19,13 +19,13 @@
|
||||
"@hanzo/gui": "7.3.0",
|
||||
"@hanzo/iam": "^0.13.6",
|
||||
"@hanzo/logo": "^1.0.13",
|
||||
"@hanzo/ui": "^8.0.6",
|
||||
"@hanzo/ui": "^8.0.11",
|
||||
"@hanzo/usage": "^0.1.6",
|
||||
"@hanzogui/config": "7.3.0",
|
||||
"@hanzogui/core": "7.3.0",
|
||||
"@hanzogui/lucide-icons-2": "7.3.0",
|
||||
"@hanzogui/next-theme": "7.3.0",
|
||||
"@hanzogui/shell": "^7.5.1",
|
||||
"@hanzogui/shell": "^7.6.3",
|
||||
"@lexical/html": "0.46.0",
|
||||
"@lexical/link": "0.46.0",
|
||||
"@lexical/list": "0.46.0",
|
||||
@@ -2711,13 +2711,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@hanzo/ui": {
|
||||
"version": "8.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@hanzo/ui/-/ui-8.0.6.tgz",
|
||||
"integrity": "sha512-Rgfip8Bf1Y9noTWkz47lUA+PZJQoB3AfAV2Gpl9sjIYVsbI4AJFCK3UcKKXI4gxETZgWABT+lIQGkwcMZOq07Q==",
|
||||
"node_modules/@hanzo/observe": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@hanzo/observe/-/observe-0.1.0.tgz",
|
||||
"integrity": "sha512-VWfMz4F6gexchEaEjK9btxcTOVhWk8km64TN69hWLa1jWB4uTL9VdTj3cWZRDl/OP1lS8YcjpLdSI1q+C7fymg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@hanzo/logo": "^1.0.13"
|
||||
"@hanzo/event": "^0.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@hanzo/ui": {
|
||||
"version": "8.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@hanzo/ui/-/ui-8.0.11.tgz",
|
||||
"integrity": "sha512-ltOSmGHvzIO/waVCP2D9+e6nyQ3cd5Ym9T80WZNHKJfqDmNr5xLD0Hk+v15InGXfUfvoCLmqSWbnlUXyfv2Fvw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@hanzo/logo": "^1.0.13",
|
||||
"@hanzogui/telemetry": "^0.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@hanzo/canvas": ">=0.1.0",
|
||||
@@ -2727,6 +2745,7 @@
|
||||
"@hanzo/gui": ">=7.2.2",
|
||||
"@hanzo/ui-shadcn": ">=5.7.0",
|
||||
"@hanzo/usage": ">=0.1.0",
|
||||
"@hanzogui/next-theme": ">=7.3.0",
|
||||
"react": ">=19"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -2744,6 +2763,9 @@
|
||||
},
|
||||
"@hanzo/usage": {
|
||||
"optional": true
|
||||
},
|
||||
"@hanzogui/next-theme": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4032,9 +4054,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@hanzogui/shell": {
|
||||
"version": "7.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@hanzogui/shell/-/shell-7.5.1.tgz",
|
||||
"integrity": "sha512-+5rvOb2jJQ++H2scgA4Cah+vMUahz8eDznTfDy2wpwQrs82P8A0og+1+1BpxHr3WYYVnjzrb4BD4Bvvmnb6Kmg==",
|
||||
"version": "7.6.3",
|
||||
"resolved": "https://registry.npmjs.org/@hanzogui/shell/-/shell-7.6.3.tgz",
|
||||
"integrity": "sha512-fcMO119cWRz2fnAzMFU1tCdT552brKDVmOJjcdz1bEZWcqGMHrxoob+1d23YnMeB2EEgZZcq/EqUCaL1hfYn2g==",
|
||||
"peerDependencies": {
|
||||
"@hanzo/iam": "^0.13.1",
|
||||
"react": "*",
|
||||
@@ -4206,6 +4228,19 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@hanzogui/telemetry": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@hanzogui/telemetry/-/telemetry-0.1.0.tgz",
|
||||
"integrity": "sha512-jNUMFBMH3iRMdGySNyShzv1/DZ3r/tq7k+m1ZHlweUy+hJXb8oMe0JZwRa2LfKAEtzaWZW02UDN5FHWPh6nEDQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@hanzo/event": "^0.3.1",
|
||||
"@hanzo/observe": "^0.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@hanzogui/text": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@hanzogui/text/-/text-7.3.0.tgz",
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@
|
||||
"@hanzo/gui": "7.3.0",
|
||||
"@hanzo/iam": "^0.13.6",
|
||||
"@hanzo/logo": "^1.0.13",
|
||||
"@hanzo/ui": "^8.0.6",
|
||||
"@hanzo/ui": "^8.0.11",
|
||||
"@hanzo/usage": "^0.1.6",
|
||||
"@hanzogui/config": "7.3.0",
|
||||
"@hanzogui/core": "7.3.0",
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
*/
|
||||
import { OrgSwitcher as Switcher, type Org, type OrgScope } from '@hanzo/ui/product'
|
||||
|
||||
import { useOrgIdentity } from '~/components/ui/BrandLogo'
|
||||
|
||||
import {
|
||||
currentOrg,
|
||||
enterOrg,
|
||||
@@ -67,11 +69,16 @@ export function OrgSwitcher() {
|
||||
// The admin-gated cross-tenant list fires ONLY for a super admin — a regular
|
||||
// user (who would 403 it) gets no loader and sees their current org alone.
|
||||
const isSuperAdmin = useIsSuperAdmin()
|
||||
// The SAME resolved org the chrome's top-left mark wears (one cached read), so
|
||||
// the two identity slots can never disagree — and a regular user, who has no
|
||||
// cross-tenant list to draw the current row from, still gets their own logo.
|
||||
const current = useOrgIdentity()
|
||||
return (
|
||||
<Switcher
|
||||
scope={scope}
|
||||
orgs={isSuperAdmin ? orgPage : undefined}
|
||||
pageSize={ORG_PAGE_SIZE}
|
||||
current={current}
|
||||
create={createOrg}
|
||||
picker
|
||||
/>
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Top-left brand logomark — the ONE brand glyph in the console chrome, matching
|
||||
* the unified Hanzo app-shell (hanzo.app + hanzo.chat): the real geometric mark
|
||||
* ALONE (no wordmark, no product name, no letter-H text), white-labeled by host.
|
||||
* Top-left mark — the identity of the ORGANIZATION the console is scoped to.
|
||||
*
|
||||
* The mark is the host-derived `BrandMark` (Hanzo H / Lux / Zoo / Pars per host,
|
||||
* `currentColor` so it inherits the calm chrome foreground and adapts to the
|
||||
* theme) — never a hardcoded Hanzo asset. On a lux/zoo/pars host it renders THAT
|
||||
* brand's mark. Left-click → product home (`/`); RIGHT-CLICK → a small brand
|
||||
* context menu (Settings · Brand · Docs · About), the same affordance the shared
|
||||
* shell's HanzoMark exposes.
|
||||
* White-label is the point: a customer's console must show the customer's mark,
|
||||
* never ours. So this renders the ONE shared `OrgMark` (@hanzo/ui) — the org's
|
||||
* own logo when IAM carries one, else the org's MONOGRAM, the same treatment the
|
||||
* account widget gives a person. It is never the house glyph and never the org
|
||||
* name set as running text.
|
||||
*
|
||||
* The house `BrandMark` still exists, but for the PRODUCT's own moments (the
|
||||
* assistant trigger, the org picker, onboarding) — not for the tenant's slot.
|
||||
*
|
||||
* Left-click → product home (`/`); RIGHT-CLICK → a small brand context menu
|
||||
* (Settings · Brand · Docs · About), the same affordance the shared shell's
|
||||
* HanzoMark exposes.
|
||||
*
|
||||
* The interactive surface is a plain `<div>` (the console's own escape hatch, as
|
||||
* used for `<div onScroll>` in OrgSwitcher) so the native `contextmenu` event is
|
||||
* guaranteed to fire and the mark still inherits the chrome color via
|
||||
* `currentColor`. The menu reuses the console's one menu surface (a `$color2`
|
||||
* guaranteed to fire. The menu reuses the console's one menu surface (a `$color2`
|
||||
* paper sheet with the shared `hz-paper hz-menu-in` styling) — no new menu
|
||||
* system; it is a cursor-anchored overlay (a right-click has no trigger rect to
|
||||
* anchor a Popover to).
|
||||
@@ -23,11 +26,12 @@
|
||||
import { useCallback, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { OrgMark } from '@hanzo/ui/product'
|
||||
import { BookOpen, Globe, Info, SlidersHorizontal } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { config } from '~/config'
|
||||
import { getBrand } from '~/lib/branding/brands'
|
||||
import { BrandMark, useOrgLogo } from '~/components/ui/BrandLogo'
|
||||
import { useOrgIdentity } from '~/components/ui/BrandLogo'
|
||||
|
||||
type MenuItem = {
|
||||
icon: typeof SlidersHorizontal
|
||||
@@ -99,17 +103,16 @@ function BrandMenu({ x, y, items, onClose }: { x: number; y: number; items: Menu
|
||||
}
|
||||
|
||||
/**
|
||||
* The top-left brand mark. `collapsed` centers it in the icon rail. Left-click →
|
||||
* The top-left org mark. `collapsed` centers it in the icon rail. Left-click →
|
||||
* home; right-click → the brand context menu.
|
||||
*/
|
||||
export function SidebarBrand({ collapsed, onNavigate }: { collapsed: boolean; onNavigate?: () => void }) {
|
||||
const router = useRouter()
|
||||
const brand = getBrand()
|
||||
// White-label: the selected org's OWN logo is the primary chrome identity (the
|
||||
// tenant's brand, front and center). The host BrandMark is only the fallback when
|
||||
// the org has set no logo — the `hanzo` org's logo is the Hanzo mark, so it stays
|
||||
// on-brand. `useOrgLogo` is the ONE cached org-logo source, shared with BrandLogo.
|
||||
const orgLogo = useOrgLogo()
|
||||
// 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
|
||||
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null)
|
||||
|
||||
const go = useCallback(
|
||||
@@ -145,8 +148,8 @@ export function SidebarBrand({ collapsed, onNavigate }: { collapsed: boolean; on
|
||||
onClick={() => go('/')}
|
||||
onContextMenu={onContextMenu}
|
||||
role="link"
|
||||
aria-label={`${brand.brandName} — home (right-click for brand menu)`}
|
||||
title={brand.brandName}
|
||||
aria-label={`${orgLabel} — home (right-click for brand menu)`}
|
||||
title={orgLabel}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -157,18 +160,9 @@ export function SidebarBrand({ collapsed, onNavigate }: { collapsed: boolean; on
|
||||
color: 'var(--color12)',
|
||||
}}
|
||||
>
|
||||
{orgLogo ? (
|
||||
// The org's own IAM logo, at the brand-mark size — the tenant's brand leads
|
||||
// the chrome; the Hanzo H is hidden whenever the org has its own.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={orgLogo}
|
||||
alt={brand.brandName}
|
||||
style={{ height: 24, width: 'auto', maxWidth: 140, objectFit: 'contain', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<BrandMark size={24} />
|
||||
)}
|
||||
{/* A logo may be a wordmark, so it is allowed to run wide; the monogram
|
||||
stays the square tile the account avatar wears. */}
|
||||
<OrgMark org={org} size={24} maxW={140} />
|
||||
</div>
|
||||
{menu ? <BrandMenu x={menu.x} y={menu.y} items={items} onClose={() => setMenu(null)} /> : null}
|
||||
</>
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Brand logo for the console chrome — TWO orthogonal layers:
|
||||
* 1. default: the host-derived BRAND mark (inline SVG, currentColor) + wordmark.
|
||||
* White-label by hostname — a lux/zoo/pars host NEVER renders the Hanzo mark.
|
||||
* 2. override: the selected org's own logo (IAM `organization.logo`) when set,
|
||||
* resolved once per org per session (cached). A tenant reads its OWN org via
|
||||
* the org-scoped `/org/iam` proxy (any member may); a global admin reads via
|
||||
* the cross-tenant `/admin/iam` proxy — so a tenant never fires the admin-gated
|
||||
* `get-organization` that only 403s. Either way it fails safe to the brand mark.
|
||||
* The two identity layers of the console chrome, kept orthogonal:
|
||||
* 1. BRAND — the host-derived mark (inline SVG, currentColor). White-label by
|
||||
* hostname: a lux/zoo/pars host NEVER renders the Hanzo mark. This is the
|
||||
* PRODUCT's identity (the assistant glyph, the org picker, onboarding).
|
||||
* 2. ORG — the tenant the console is scoped to: its own logo when IAM carries
|
||||
* one, else its monogram (`OrgMark`, @hanzo/ui). This is what leads the
|
||||
* chrome, so a customer's console shows the CUSTOMER's identity.
|
||||
*
|
||||
* The org is resolved once per org per session (cached). A tenant reads its OWN
|
||||
* org via the org-scoped `/org/iam` proxy (any member may); a global admin reads
|
||||
* via the cross-tenant `/admin/iam` proxy — so a tenant never fires the
|
||||
* admin-gated `get-organization` that would only 403. Either way it fails safe to
|
||||
* the org's name, which always resolves, so the mark is never empty.
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Text, XStack } from '@hanzo/gui'
|
||||
import type { Org } from '@hanzo/ui/product'
|
||||
|
||||
import { config } from '~/config'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
@@ -19,8 +24,8 @@ import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { IamAdminApi, TeamApi } from '~/lib/api'
|
||||
import { getBrand } from '~/lib/branding/brands'
|
||||
|
||||
/** Per-org logo cache for the session ('' = checked, none). */
|
||||
const orgLogoCache = new Map<string, string>()
|
||||
/** Per-org identity cache for the session (present = resolved, logo may be ''). */
|
||||
const orgCache = new Map<string, Org>()
|
||||
|
||||
/** The host-derived brand mark — inline, build-time-trusted SVG (currentColor).
|
||||
* White-label by hostname: a lux/zoo/pars host renders ITS mark, never Hanzo's.
|
||||
@@ -51,68 +56,52 @@ export function useOrgName(): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* The selected org's own logo URL (IAM `organization.logo`), or '' when none —
|
||||
* the ONE org-logo source (cached per session). Reused by `BrandLogo` and the
|
||||
* sidebar org-brand header so the fetch happens once and both stay in lockstep.
|
||||
* The org the console is scoped to, as `OrgMark` wants it — name, display name
|
||||
* and logo, resolved once per org per session. The ONE org-identity source in the
|
||||
* console: the top-left mark and anything else that shows the tenant read this,
|
||||
* so one fetch serves them all and they can never disagree.
|
||||
*
|
||||
* It resolves synchronously to the org's name, so the mark paints its monogram
|
||||
* immediately and only sharpens into the logo/display name when IAM answers —
|
||||
* there is no empty frame, and a 403/404 simply leaves the monogram standing.
|
||||
*/
|
||||
export function useOrgLogo(): string {
|
||||
export function useOrgIdentity(): Org {
|
||||
const isSuperAdmin = useIsSuperAdmin()
|
||||
const orgName = useOrgName()
|
||||
const [logo, setLogo] = useState<string>(() => orgLogoCache.get(orgName) ?? '')
|
||||
const [org, setOrg] = useState<Org>(() => orgCache.get(orgName) ?? { name: orgName })
|
||||
|
||||
useEffect(() => {
|
||||
if (orgLogoCache.has(orgName)) {
|
||||
setLogo(orgLogoCache.get(orgName) ?? '')
|
||||
const cached = orgCache.get(orgName)
|
||||
if (cached) {
|
||||
setOrg(cached)
|
||||
return
|
||||
}
|
||||
setOrg({ name: orgName })
|
||||
let live = true
|
||||
// A global admin reads any org via the cross-tenant `/admin/iam` proxy; a tenant
|
||||
// reads its OWN org via the org-scoped `/org/iam` proxy (which authorizes any
|
||||
// member). This keeps the tenant's own-org logo working without firing the
|
||||
// member). This keeps the tenant's own-org identity working without firing the
|
||||
// admin-gated `get-organization` that would only 403 in the browser console.
|
||||
const fetchOrg = isSuperAdmin ? IamAdminApi.organization(orgName) : TeamApi.organization(orgName)
|
||||
fetchOrg
|
||||
.then((o) => {
|
||||
const l = typeof o.logo === 'string' ? o.logo : ''
|
||||
orgLogoCache.set(orgName, l)
|
||||
if (live) setLogo(l)
|
||||
const resolved: Org = {
|
||||
name: orgName,
|
||||
displayName: typeof o.displayName === 'string' && o.displayName.trim() ? o.displayName : undefined,
|
||||
logo: typeof o.logo === 'string' && o.logo ? o.logo : undefined,
|
||||
}
|
||||
orgCache.set(orgName, resolved)
|
||||
if (live) setOrg(resolved)
|
||||
})
|
||||
.catch(() => {
|
||||
orgLogoCache.set(orgName, '')
|
||||
if (live) setLogo('')
|
||||
const bare: Org = { name: orgName }
|
||||
orgCache.set(orgName, bare)
|
||||
if (live) setOrg(bare)
|
||||
})
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [orgName, isSuperAdmin])
|
||||
|
||||
return logo
|
||||
}
|
||||
|
||||
export function BrandLogo({ size = 22, wordmark = true }: { size?: number; wordmark?: boolean }) {
|
||||
const logo = useOrgLogo()
|
||||
|
||||
const wordmarkText = `${getBrand().brandName.replace(' Cloud', '')} Console`
|
||||
|
||||
return (
|
||||
<XStack items="center" gap="$2">
|
||||
{logo ? (
|
||||
// Arbitrary external org logo URL — raw <img> (next/image would need a
|
||||
// configured remote allowlist for every tenant domain).
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={logo}
|
||||
alt="Organization logo"
|
||||
style={{ height: size, width: 'auto', maxWidth: size * 5, objectFit: 'contain', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<BrandMark size={size} />
|
||||
)}
|
||||
{wordmark ? (
|
||||
<Text fontWeight="800" fontSize="$5" color="$color12">
|
||||
{wordmarkText}
|
||||
</Text>
|
||||
) : null}
|
||||
</XStack>
|
||||
)
|
||||
return org
|
||||
}
|
||||
|
||||
@@ -474,10 +474,13 @@ function MenuItem({ icon: Icon, label, onPress }: { icon: ComponentType<{ size?:
|
||||
*/
|
||||
function SidebarWorkspace({ collapsed }: { collapsed: boolean }) {
|
||||
if (collapsed) return null
|
||||
// A COLUMN, so the switcher stretches the sidebar's width exactly as the account
|
||||
// row below does — the two are peers, and a row container would have shrunk this
|
||||
// one to its text.
|
||||
return (
|
||||
<XStack px="$1" pb="$1" mb="$1" items="center" borderBottomWidth={1} borderColor="$borderColor">
|
||||
<YStack pb="$1" mb="$1" borderBottomWidth={1} borderColor="$borderColor">
|
||||
<OrgSwitcher />
|
||||
</XStack>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user