Skip to content

Commit 8c34607

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(app-shell): discoverable org/member management — direct-to-members, header workspace switcher, single-org trim (#2022)
* fix(app-shell): deep-link single-org users straight to member management The org picker at `/organizations?manage=1` (used by the avatar "My Organizations" entry, and now by the Cloud app's "Members" nav) showed a one-item picker for single-org users — they had to click "Manage" before reaching member management. For >90% of tenants there is no choice to make. In manage mode, when the user belongs to exactly one org, redirect straight to that org's `/organizations/<slug>/members` instead of rendering the picker (multi-org users still get the picker). Mirrors the existing auto-skip that single-org users already get on the non-manage `/organizations` path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(app-shell): header workspace switcher + trim single-org org UI Add a global organization (workspace) switcher in the header-left — the place users expect "which org am I in / switch org" to live — and trim the org chrome that is meaningless for single-org users (the vast majority on cloud, where multi-org creation is disabled). - WorkspaceSwitcher (new): single-org → static org name, no dropdown; multi-org → dropdown listing orgs (switch reloads home so active-org context refreshes app-wide), plus "Manage members" and "Create workspace" (the latter gated on multiOrgEnabled). Mounted in AppHeader after the brand, before the app breadcrumb. - AppHeader: drop the now-redundant "My Organizations" avatar entry (the switcher replaces it); keep "Create workspace" there as the single-org create path. Remove the now-unused Boxes import. - OrganizationLayout: hide "Back to organizations" for single-org users — there is no picker to return to (/organizations auto-skips them to home), so the button only dead-ended on home. Verified on the local rig for both single-org (static name, no back button) and multi-org (switch dropdown works, back button present). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent bb43f74 commit 8c34607

4 files changed

Lines changed: 183 additions & 17 deletions

File tree

packages/app-shell/src/console/organizations/OrganizationsPage.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,10 +120,18 @@ export function OrganizationsPage() {
120120
useEffect(() => {
121121
if (autoSelectedRef.current) return;
122122
if (isOrganizationsLoading) return;
123-
if (manageMode || wantsCreate) return; // came to manage/create — don't bounce
123+
if (wantsCreate) return; // came to create — don't bounce
124124
if (orgList.length !== 1) return;
125125
autoSelectedRef.current = true;
126-
void handleSelect(orgList[0]);
126+
// Single-org users have no real choice to make. In manage mode (`?manage=1`,
127+
// used by the Cloud app "Members" nav and the avatar "My Organizations"
128+
// entry), skip the pointless one-item picker and deep-link straight to that
129+
// org's member management; otherwise switch into the org and land on home.
130+
if (manageMode) {
131+
navigate(`/organizations/${orgList[0].slug}/members`, { replace: true });
132+
} else {
133+
void handleSelect(orgList[0]);
134+
}
127135
// eslint-disable-next-line react-hooks/exhaustive-deps
128136
}, [isOrganizationsLoading, orgList.length, manageMode, wantsCreate]);
129137

@@ -141,7 +149,7 @@ export function OrganizationsPage() {
141149
// because there's only one org. This prevents the picker from briefly
142150
// flashing on screen for single-org users.
143151
const willAutoSelect =
144-
!manageMode && !wantsCreate && !isOrganizationsLoading && orgList.length === 1;
152+
!wantsCreate && !isOrganizationsLoading && orgList.length === 1;
145153
if (isOrganizationsLoading || willAutoSelect) {
146154
return (
147155
<div className="flex flex-1 items-center justify-center py-20">

packages/app-shell/src/console/organizations/manage/OrganizationLayout.tsx

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -85,13 +85,20 @@ export function OrganizationLayout() {
8585

8686
<div className="border-b">
8787
<div className="mx-auto w-full max-w-5xl px-4 sm:px-6 pt-6">
88-
<button
89-
onClick={() => navigate('/organizations')}
90-
className="mb-3 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
91-
>
92-
<ArrowLeft className="h-3.5 w-3.5" />
93-
{t('organization.backToList', { defaultValue: 'Back to organizations' })}
94-
</button>
88+
{/* "Back to organizations" only makes sense when there IS a list to
89+
return to. Single-org users (the vast majority) have no picker —
90+
`/organizations` auto-skips them straight to home — so the button
91+
would just dump them on home. Hide it for them; they leave via the
92+
global header (logo / app switcher). */}
93+
{(organizations ?? []).length > 1 && (
94+
<button
95+
onClick={() => navigate('/organizations')}
96+
className="mb-3 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
97+
>
98+
<ArrowLeft className="h-3.5 w-3.5" />
99+
{t('organization.backToList', { defaultValue: 'Back to organizations' })}
100+
</button>
101+
)}
95102
<div className="flex items-center justify-between gap-4">
96103
<div className="min-w-0">
97104
<h1 className="truncate text-2xl font-bold tracking-tight">{org.name}</h1>

packages/app-shell/src/layout/AppHeader.tsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ import {
4040
Check,
4141
Lock,
4242
LogOut,
43-
Boxes,
4443
Plus,
4544
Layers,
4645
Bot,
@@ -54,6 +53,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
5453
import { useOffline } from '@object-ui/react';
5554
import { PresenceAvatars, useTenantPresence, type PresenceUser } from '@object-ui/collaboration';
5655
import { ModeToggle } from './ModeToggle';
56+
import { WorkspaceSwitcher } from './WorkspaceSwitcher';
5757
import { LocaleSwitcher } from './LocaleSwitcher';
5858
import { ConnectionStatus } from './ConnectionStatus';
5959
import type { ActivityItem } from './ActivityFeed';
@@ -644,6 +644,12 @@ export function AppHeader({
644644
</span>
645645
)}
646646

647+
{/* Workspace (organization) switcher — the global "which org am I in /
648+
switch org" affordance. Renders just the org name for single-org
649+
users, a switch dropdown for multi-org. Sits right after the brand,
650+
before the app/section breadcrumb. */}
651+
<WorkspaceSwitcher />
652+
647653
{resolvedVariant === 'orgs' && (
648654
<>
649655
<PathSep />
@@ -964,12 +970,10 @@ export function AppHeader({
964970
<User className="mr-2 h-4 w-4" />
965971
{t('user.profile', { defaultValue: 'Profile' })}
966972
</DropdownMenuItem>
967-
{hasOrgSection && (
968-
<DropdownMenuItem onClick={() => navigate('/organizations?manage=1')} className="cursor-pointer">
969-
<Boxes className="mr-2 h-4 w-4" />
970-
{t('organizations.mine', { defaultValue: 'My Organizations' })}
971-
</DropdownMenuItem>
972-
)}
973+
{/* "My Organizations" moved to the global WorkspaceSwitcher in the
974+
header-left (switch + manage members live there now). Only the
975+
create-workspace shortcut stays here, so single-org users —
976+
whose switcher has no dropdown — can still create a second org. */}
973977
{hasOrgSection && !multiOrgDisabled && (
974978
<DropdownMenuItem
975979
onClick={() => navigate('/organizations?create=1')}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/**
2+
* WorkspaceSwitcher
3+
*
4+
* Header-left organization (workspace) switcher — the standard place users
5+
* expect "which org am I in / switch org" to live (Linear/Vercel/GitHub style).
6+
*
7+
* - Single-org users (the vast majority): just the org name, NO dropdown. There
8+
* is nothing to switch to, so a one-item menu would be pure friction.
9+
* - Multi-org users: the active org name + a dropdown to switch orgs inline
10+
* (full-page reload so the active-org context refreshes app-wide, mirroring
11+
* OrganizationsPage), plus shortcuts to manage members / create a workspace.
12+
* - No org context at all: renders nothing.
13+
*/
14+
15+
import { useEffect, useState } from 'react';
16+
import { useNavigate } from 'react-router-dom';
17+
import { useAuth } from '@object-ui/auth';
18+
import type { AuthOrganization } from '@object-ui/auth';
19+
import { useObjectTranslation } from '@object-ui/i18n';
20+
import {
21+
DropdownMenu,
22+
DropdownMenuTrigger,
23+
DropdownMenuContent,
24+
DropdownMenuItem,
25+
DropdownMenuLabel,
26+
DropdownMenuSeparator,
27+
} from '@object-ui/components';
28+
import { ChevronsUpDown, Check, Plus, Users } from 'lucide-react';
29+
import { resolveHomeUrl } from '../console/organizations/resolveHomeUrl';
30+
31+
function getOrgInitials(name: string): string {
32+
return name
33+
.split(/[\s_-]+/)
34+
.map((w) => w[0])
35+
.join('')
36+
.toUpperCase()
37+
.slice(0, 2);
38+
}
39+
40+
function OrgBadge({ name }: { name: string }) {
41+
return (
42+
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-muted text-[10px] font-semibold text-muted-foreground">
43+
{getOrgInitials(name)}
44+
</span>
45+
);
46+
}
47+
48+
export function WorkspaceSwitcher() {
49+
const { t } = useObjectTranslation();
50+
const navigate = useNavigate();
51+
const { organizations, activeOrganization, switchOrganization, getAuthConfig } = useAuth();
52+
const [multiOrgDisabled, setMultiOrgDisabled] = useState(false);
53+
54+
useEffect(() => {
55+
let cancelled = false;
56+
getAuthConfig?.()
57+
.then((cfg) => {
58+
if (!cancelled) setMultiOrgDisabled(cfg?.features?.multiOrgEnabled === false);
59+
})
60+
.catch(() => {
61+
/* leave default — create entry stays available */
62+
});
63+
return () => {
64+
cancelled = true;
65+
};
66+
}, [getAuthConfig]);
67+
68+
const orgList = organizations ?? [];
69+
const current = activeOrganization ?? orgList[0] ?? null;
70+
71+
// No organization context (e.g. a brand-new user before provisioning) — show
72+
// nothing rather than an empty switcher.
73+
if (!current) return null;
74+
75+
// Single-org: static label, no dropdown.
76+
if (orgList.length <= 1) {
77+
return (
78+
<span
79+
className="ml-2 hidden max-w-[12rem] items-center gap-1.5 sm:inline-flex"
80+
data-testid="workspace-name"
81+
>
82+
<OrgBadge name={current.name} />
83+
<span className="truncate text-sm font-medium text-foreground/80">{current.name}</span>
84+
</span>
85+
);
86+
}
87+
88+
const handleSwitch = async (org: AuthOrganization) => {
89+
if (org.id === current.id) return;
90+
try {
91+
await switchOrganization(org.id);
92+
// switchOrganization only updates state; reload to home so the new active
93+
// org propagates to every data scope app-wide (same as OrganizationsPage).
94+
window.location.href = resolveHomeUrl();
95+
} catch (err) {
96+
console.error('[WorkspaceSwitcher] switch failed', err);
97+
}
98+
};
99+
100+
return (
101+
<DropdownMenu>
102+
<DropdownMenuTrigger
103+
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"
104+
data-testid="workspace-switcher"
105+
>
106+
<OrgBadge name={current.name} />
107+
<span className="hidden truncate sm:inline">{current.name}</span>
108+
<ChevronsUpDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
109+
</DropdownMenuTrigger>
110+
<DropdownMenuContent align="start" className="w-60">
111+
<DropdownMenuLabel className="text-xs text-muted-foreground">
112+
{t('organization.switcher.label', { defaultValue: 'Switch organization' })}
113+
</DropdownMenuLabel>
114+
{orgList.map((org) => (
115+
<DropdownMenuItem
116+
key={org.id}
117+
onClick={() => handleSwitch(org)}
118+
className="cursor-pointer gap-2"
119+
>
120+
<OrgBadge name={org.name} />
121+
<span className="flex-1 truncate">{org.name}</span>
122+
{org.id === current.id && <Check className="h-4 w-4 shrink-0" />}
123+
</DropdownMenuItem>
124+
))}
125+
<DropdownMenuSeparator />
126+
<DropdownMenuItem
127+
onClick={() => navigate(`/organizations/${current.slug}/members`)}
128+
className="cursor-pointer gap-2"
129+
data-testid="workspace-manage-members"
130+
>
131+
<Users className="h-4 w-4" />
132+
{t('organization.switcher.manageMembers', { defaultValue: 'Manage members' })}
133+
</DropdownMenuItem>
134+
{!multiOrgDisabled && (
135+
<DropdownMenuItem
136+
onClick={() => navigate('/organizations?create=1')}
137+
className="cursor-pointer gap-2"
138+
data-testid="workspace-create"
139+
>
140+
<Plus className="h-4 w-4" />
141+
{t('organizations.create', { defaultValue: 'Create workspace' })}
142+
</DropdownMenuItem>
143+
)}
144+
</DropdownMenuContent>
145+
</DropdownMenu>
146+
);
147+
}

0 commit comments

Comments
 (0)