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:
zeekay
2026-07-26 19:32:06 -07:00
parent 07c1982b02
commit b286d48c7f
7 changed files with 281 additions and 100 deletions
+153
View File
@@ -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 orgs 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()
})
+47 -12
View File
@@ -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
View File
@@ -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",
+7
View File
@@ -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
/>
+26 -32
View File
@@ -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}
</>
+42 -53
View File
@@ -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
}
+5 -2
View File
@@ -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>
)
}