Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a28b0e796d | ||
|
|
a1a13cdc25 | ||
|
|
60b906b448 | ||
|
|
49d11bb9bc | ||
|
|
2b5786685d |
+2
-2
@@ -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",
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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) }),
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>> =>
|
||||
|
||||
Reference in New Issue
Block a user