Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions packages/app-shell/src/console/organizations/OrganizationsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

Expand All @@ -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 (
<div className="flex flex-1 items-center justify-center py-20">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,13 +85,20 @@ export function OrganizationLayout() {

<div className="border-b">
<div className="mx-auto w-full max-w-5xl px-4 sm:px-6 pt-6">
<button
onClick={() => navigate('/organizations')}
className="mb-3 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-3.5 w-3.5" />
{t('organization.backToList', { defaultValue: 'Back to organizations' })}
</button>
{/* "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 && (
<button
onClick={() => navigate('/organizations')}
className="mb-3 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-3.5 w-3.5" />
{t('organization.backToList', { defaultValue: 'Back to organizations' })}
</button>
)}
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<h1 className="truncate text-2xl font-bold tracking-tight">{org.name}</h1>
Expand Down
18 changes: 11 additions & 7 deletions packages/app-shell/src/layout/AppHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ import {
Check,
Lock,
LogOut,
Boxes,
Plus,
Layers,
Bot,
Expand All @@ -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';
Expand Down Expand Up @@ -644,6 +644,12 @@ export function AppHeader({
</span>
)}

{/* 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. */}
<WorkspaceSwitcher />

{resolvedVariant === 'orgs' && (
<>
<PathSep />
Expand Down Expand Up @@ -964,12 +970,10 @@ export function AppHeader({
<User className="mr-2 h-4 w-4" />
{t('user.profile', { defaultValue: 'Profile' })}
</DropdownMenuItem>
{hasOrgSection && (
<DropdownMenuItem onClick={() => navigate('/organizations?manage=1')} className="cursor-pointer">
<Boxes className="mr-2 h-4 w-4" />
{t('organizations.mine', { defaultValue: 'My Organizations' })}
</DropdownMenuItem>
)}
{/* "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 && (
<DropdownMenuItem
onClick={() => navigate('/organizations?create=1')}
Expand Down
147 changes: 147 additions & 0 deletions packages/app-shell/src/layout/WorkspaceSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-muted text-[10px] font-semibold text-muted-foreground">
{getOrgInitials(name)}
</span>
);
}

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 (
<span
className="ml-2 hidden max-w-[12rem] items-center gap-1.5 sm:inline-flex"
data-testid="workspace-name"
>
<OrgBadge name={current.name} />
<span className="truncate text-sm font-medium text-foreground/80">{current.name}</span>
</span>
);
}

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 (
<DropdownMenu>
<DropdownMenuTrigger
className="ml-2 inline-flex max-w-[14rem] items-center gap-1.5 rounded-md px-1.5 py-1 text-sm font-medium text-foreground/80 transition-colors hover:bg-accent hover:text-foreground"
data-testid="workspace-switcher"
>
<OrgBadge name={current.name} />
<span className="hidden truncate sm:inline">{current.name}</span>
<ChevronsUpDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-60">
<DropdownMenuLabel className="text-xs text-muted-foreground">
{t('organization.switcher.label', { defaultValue: 'Switch organization' })}
</DropdownMenuLabel>
{orgList.map((org) => (
<DropdownMenuItem
key={org.id}
onClick={() => handleSwitch(org)}
className="cursor-pointer gap-2"
>
<OrgBadge name={org.name} />
<span className="flex-1 truncate">{org.name}</span>
{org.id === current.id && <Check className="h-4 w-4 shrink-0" />}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => navigate(`/organizations/${current.slug}/members`)}
className="cursor-pointer gap-2"
data-testid="workspace-manage-members"
>
<Users className="h-4 w-4" />
{t('organization.switcher.manageMembers', { defaultValue: 'Manage members' })}
</DropdownMenuItem>
{!multiOrgDisabled && (
<DropdownMenuItem
onClick={() => navigate('/organizations?create=1')}
className="cursor-pointer gap-2"
data-testid="workspace-create"
>
<Plus className="h-4 w-4" />
{t('organizations.create', { defaultValue: 'Create workspace' })}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
Loading