Compare commits

..
5 Commits
Author SHA1 Message Date
zooqueen a28b0e796d restore the OrgMark import the two concurrent fixes cancelled
Hanzo CI/CD / cicd (push) Successful in 9m54s
CI/CD / cicd (push) Successful in 10m24s
Two sessions repaired the same duplicate import at once — one dropped
the first declaration, the other dropped the second — and rebasing
applied both, so the switcher used OrgMark twice while importing it
zero times. tsc says it plainly (TS2304 Cannot find name 'OrgMark');
the bundler would have said it again.

Verified AFTER the rebase this time, which is the actual lesson: a
check that ran before the merge proves nothing about what landed.
2026-08-06 21:23:59 -07:00
zooqueen a1a13cdc25 the switcher imports OrgMark once, so the console can build again
Hanzo CI/CD / cicd (push) Failing after 1m41s
CI/CD / cicd (push) Failing after 1m51s
2b5786685 added a second 'import { OrgMark, SearchInput }' beside the
existing OrgMark import in ContextSwitcher, and webpack refuses a
re-declared identifier: 'Module parse failed: Identifier OrgMark has
already been declared'. Every console image since has failed at
build:embed, so nothing has shipped — the tests and typecheck never saw
it because both run before the bundler does.

One import, both names.
2026-08-06 21:22:42 -07:00
hanzo-dev 60b906b448 console: one OrgMark import, so the bundle compiles
Hanzo CI/CD / cicd (push) Successful in 4m43s
CI/CD / cicd (push) Successful in 4m56s
The file already imported OrgMark for the trigger; adding it again for the rows
re-declared the identifier. tsc did not object, webpack did — the bundle is the
gate here.
2026-08-06 21:18:38 -07:00
zooqueen 49d11bb9bc the picker lists the orgs you belong to, and names them by the org
Hanzo CI/CD / cicd (push) Failing after 2m29s
CI/CD / cicd (push) Failing after 2m47s
A non-super caller got ONE card, synthesized from the session, titled
with account.displayName — the signed-in PERSON where the organization
belongs, with the person's initials as its mark. A customer who works
in a second workspace could not see it at all, because the list was
derived from the account's owner rather than from the memberships that
actually say which orgs a person may act in.

Now it reads the membership set (home org first, deduped — pure, so the
ordering is tested without a network) and loads each org's own record,
so a card carries the ORG's name and logo. IAM authorizes a member to
read the orgs they belong to as of v1.34.26, which is what makes the
per-org read answer instead of 403. A row that still cannot be read
degrades to its slug rather than dropping an org the person is entitled
to see.
2026-08-06 21:17:32 -07:00
hanzo-dev 2b5786685d console: the org switcher wears marks, and names the way into its own settings
Hanzo CI/CD / cicd (push) Failing after 1m18s
CI/CD / cicd (push) Failing after 1m27s
The switcher listed orgs as bare text while the account menu beside it wore an
avatar, so two rows of the same chrome read as two different products. Each org
now shows the ONE shared OrgMark the account widget already uses.

An org with no logo wore its initial. It now wears the HOST's brand — the Hanzo
mark on console.hanzo.ai, whatever a white-labelled host resolves to elsewhere —
so a workspace reads as its own from the first sign-in rather than as a letter.
Uploading a logo still overrides it.

Branding was reachable only by knowing the URL; the switcher now says where it
is.
2026-08-06 21:01:41 -07:00
7 changed files with 159 additions and 25 deletions
+2 -2
View File
@@ -1,11 +1,11 @@
{
"name": "@hanzo/console",
"version": "8.5.78",
"version": "8.5.81",
"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",
+9 -3
View File
@@ -27,11 +27,10 @@
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 { ChevronsUpDown, 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'
@@ -39,7 +38,7 @@ import { currentOrg, leaveOrg, switchOrg } from '~/lib/org-scope'
import { contextLabel, scopedOrgRow, titleCase } from '~/lib/account/org-state'
import { MenuRow } from '~/components/ui/MenuRow'
import { paper } from '~/components/ui/paper'
import { SearchInput } from '@hanzo/ui/product'
import { OrgMark, SearchInput } from '@hanzo/ui/product'
export function ContextSwitcher() {
const router = useRouter()
@@ -165,6 +164,7 @@ export function ContextSwitcher() {
<MenuRow
key={o.name}
label={o.displayName || o.name}
icon={<OrgMark org={o} size={18} />}
active={scoped === o.name}
onPress={pick(() => {
if (o.name !== scoped) switchOrg(o.name)
@@ -179,6 +179,12 @@ export function ContextSwitcher() {
</Text>
) : null}
<MenuRow
label="Organization settings"
icon={<SlidersHorizontal size={14} />}
onPress={pick(() => router.push('/settings/branding'))}
/>
<MenuRow label="All organizations" icon={<Plus size={14} />} onPress={pick(leaveOrg)} />
<XStack height={1} bg="$borderColor" my="$1" />
+53 -17
View File
@@ -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'
@@ -114,25 +115,60 @@ export function OrgPicker() {
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) => 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,7 +187,7 @@ 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) }),
+11 -2
View File
@@ -30,7 +30,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 { BrandMark, useOrgIdentity } from '~/components/ui/BrandLogo'
import { Z } from '~/lib/z'
import { OrgMark } from '@hanzo/ui/product'
@@ -170,7 +170,16 @@ export function SidebarBrand({ collapsed, onNavigate }: { collapsed: boolean; on
marker declares that here, at our call site, rather than teaching the
design gate a library's class names. */}
<span data-monogram style={{ display: 'contents' }}>
<OrgMark org={org} size={24} maxW={140} />
{org.logo ? (
<OrgMark org={org} size={24} maxW={140} />
) : (
// An org that has not uploaded a mark yet wears the HOST's brand, not
// its own initial: on console.hanzo.ai that is the Hanzo mark, on a
// white-labelled host the brand that host resolves to. The surface
// reads as the tenant's own from the first sign-in, and uploading a
// logo in Settings → Branding replaces it.
<BrandMark size={24} />
)}
</span>
</div>
{menu ? <BrandMenu x={menu.x} y={menu.y} items={items} onClose={() => setMenu(null)} /> : null}
+1 -1
View File
@@ -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,
+43
View File
@@ -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([])
})
})
+40
View File
@@ -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>> =>