diff --git a/packages/app-shell/src/console/organizations/OrganizationsPage.tsx b/packages/app-shell/src/console/organizations/OrganizationsPage.tsx index 2070abc97..c09f340ee 100644 --- a/packages/app-shell/src/console/organizations/OrganizationsPage.tsx +++ b/packages/app-shell/src/console/organizations/OrganizationsPage.tsx @@ -120,10 +120,18 @@ export function OrganizationsPage() { useEffect(() => { if (autoSelectedRef.current) return; if (isOrganizationsLoading) return; - if (manageMode || wantsCreate) return; // came to manage/create — don't bounce + if (wantsCreate) return; // came to create — don't bounce if (orgList.length !== 1) return; autoSelectedRef.current = true; - void handleSelect(orgList[0]); + // Single-org users have no real choice to make. In manage mode (`?manage=1`, + // used by the Cloud app "Members" nav and the avatar "My Organizations" + // entry), skip the pointless one-item picker and deep-link straight to that + // org's member management; otherwise switch into the org and land on home. + if (manageMode) { + navigate(`/organizations/${orgList[0].slug}/members`, { replace: true }); + } else { + void handleSelect(orgList[0]); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOrganizationsLoading, orgList.length, manageMode, wantsCreate]); @@ -141,7 +149,7 @@ export function OrganizationsPage() { // because there's only one org. This prevents the picker from briefly // flashing on screen for single-org users. const willAutoSelect = - !manageMode && !wantsCreate && !isOrganizationsLoading && orgList.length === 1; + !wantsCreate && !isOrganizationsLoading && orgList.length === 1; if (isOrganizationsLoading || willAutoSelect) { return (
diff --git a/packages/app-shell/src/console/organizations/manage/OrganizationLayout.tsx b/packages/app-shell/src/console/organizations/manage/OrganizationLayout.tsx index 4d303b474..459c33b9b 100644 --- a/packages/app-shell/src/console/organizations/manage/OrganizationLayout.tsx +++ b/packages/app-shell/src/console/organizations/manage/OrganizationLayout.tsx @@ -85,13 +85,20 @@ export function OrganizationLayout() {
- + {/* "Back to organizations" only makes sense when there IS a list to + return to. Single-org users (the vast majority) have no picker — + `/organizations` auto-skips them straight to home — so the button + would just dump them on home. Hide it for them; they leave via the + global header (logo / app switcher). */} + {(organizations ?? []).length > 1 && ( + + )}

{org.name}

diff --git a/packages/app-shell/src/layout/AppHeader.tsx b/packages/app-shell/src/layout/AppHeader.tsx index 7c8d2a9ff..59ac6bb26 100644 --- a/packages/app-shell/src/layout/AppHeader.tsx +++ b/packages/app-shell/src/layout/AppHeader.tsx @@ -40,7 +40,6 @@ import { Check, Lock, LogOut, - Boxes, Plus, Layers, Bot, @@ -54,6 +53,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { useOffline } from '@object-ui/react'; import { PresenceAvatars, useTenantPresence, type PresenceUser } from '@object-ui/collaboration'; import { ModeToggle } from './ModeToggle'; +import { WorkspaceSwitcher } from './WorkspaceSwitcher'; import { LocaleSwitcher } from './LocaleSwitcher'; import { ConnectionStatus } from './ConnectionStatus'; import type { ActivityItem } from './ActivityFeed'; @@ -644,6 +644,12 @@ export function AppHeader({ )} + {/* Workspace (organization) switcher — the global "which org am I in / + switch org" affordance. Renders just the org name for single-org + users, a switch dropdown for multi-org. Sits right after the brand, + before the app/section breadcrumb. */} + + {resolvedVariant === 'orgs' && ( <> @@ -964,12 +970,10 @@ export function AppHeader({ {t('user.profile', { defaultValue: 'Profile' })} - {hasOrgSection && ( - navigate('/organizations?manage=1')} className="cursor-pointer"> - - {t('organizations.mine', { defaultValue: 'My Organizations' })} - - )} + {/* "My Organizations" moved to the global WorkspaceSwitcher in the + header-left (switch + manage members live there now). Only the + create-workspace shortcut stays here, so single-org users — + whose switcher has no dropdown — can still create a second org. */} {hasOrgSection && !multiOrgDisabled && ( navigate('/organizations?create=1')} diff --git a/packages/app-shell/src/layout/WorkspaceSwitcher.tsx b/packages/app-shell/src/layout/WorkspaceSwitcher.tsx new file mode 100644 index 000000000..25051fdfe --- /dev/null +++ b/packages/app-shell/src/layout/WorkspaceSwitcher.tsx @@ -0,0 +1,147 @@ +/** + * WorkspaceSwitcher + * + * Header-left organization (workspace) switcher — the standard place users + * expect "which org am I in / switch org" to live (Linear/Vercel/GitHub style). + * + * - Single-org users (the vast majority): just the org name, NO dropdown. There + * is nothing to switch to, so a one-item menu would be pure friction. + * - Multi-org users: the active org name + a dropdown to switch orgs inline + * (full-page reload so the active-org context refreshes app-wide, mirroring + * OrganizationsPage), plus shortcuts to manage members / create a workspace. + * - No org context at all: renders nothing. + */ + +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '@object-ui/auth'; +import type { AuthOrganization } from '@object-ui/auth'; +import { useObjectTranslation } from '@object-ui/i18n'; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, +} from '@object-ui/components'; +import { ChevronsUpDown, Check, Plus, Users } from 'lucide-react'; +import { resolveHomeUrl } from '../console/organizations/resolveHomeUrl'; + +function getOrgInitials(name: string): string { + return name + .split(/[\s_-]+/) + .map((w) => w[0]) + .join('') + .toUpperCase() + .slice(0, 2); +} + +function OrgBadge({ name }: { name: string }) { + return ( + + {getOrgInitials(name)} + + ); +} + +export function WorkspaceSwitcher() { + const { t } = useObjectTranslation(); + const navigate = useNavigate(); + const { organizations, activeOrganization, switchOrganization, getAuthConfig } = useAuth(); + const [multiOrgDisabled, setMultiOrgDisabled] = useState(false); + + useEffect(() => { + let cancelled = false; + getAuthConfig?.() + .then((cfg) => { + if (!cancelled) setMultiOrgDisabled(cfg?.features?.multiOrgEnabled === false); + }) + .catch(() => { + /* leave default — create entry stays available */ + }); + return () => { + cancelled = true; + }; + }, [getAuthConfig]); + + const orgList = organizations ?? []; + const current = activeOrganization ?? orgList[0] ?? null; + + // No organization context (e.g. a brand-new user before provisioning) — show + // nothing rather than an empty switcher. + if (!current) return null; + + // Single-org: static label, no dropdown. + if (orgList.length <= 1) { + return ( + + + {current.name} + + ); + } + + const handleSwitch = async (org: AuthOrganization) => { + if (org.id === current.id) return; + try { + await switchOrganization(org.id); + // switchOrganization only updates state; reload to home so the new active + // org propagates to every data scope app-wide (same as OrganizationsPage). + window.location.href = resolveHomeUrl(); + } catch (err) { + console.error('[WorkspaceSwitcher] switch failed', err); + } + }; + + return ( + + + + {current.name} + + + + + {t('organization.switcher.label', { defaultValue: 'Switch organization' })} + + {orgList.map((org) => ( + handleSwitch(org)} + className="cursor-pointer gap-2" + > + + {org.name} + {org.id === current.id && } + + ))} + + navigate(`/organizations/${current.slug}/members`)} + className="cursor-pointer gap-2" + data-testid="workspace-manage-members" + > + + {t('organization.switcher.manageMembers', { defaultValue: 'Manage members' })} + + {!multiOrgDisabled && ( + navigate('/organizations?create=1')} + className="cursor-pointer gap-2" + data-testid="workspace-create" + > + + {t('organizations.create', { defaultValue: 'Create workspace' })} + + )} + + + ); +}