-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathWorkspaceSwitcher.tsx
More file actions
163 lines (153 loc) · 6.29 KB
/
Copy pathWorkspaceSwitcher.tsx
File metadata and controls
163 lines (153 loc) · 6.29 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
/**
* 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): render NOTHING. With one org there is
* nothing to switch to and the name isn't actionable, so it's pure chrome —
* the product logo already carries branding / "go home". The switcher is a
* multi-org affordance.
* - 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.
*
* Under `group` tenancy posture (ADR-0105) the switcher's meaning changes:
* the active organization is only the WRITE target — reads span every
* organization the member belongs to — so the dropdown labels it as the
* working organization instead of implying it bounds what the user sees.
*/
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 { resolveRootUrl } from '../console/organizations/resolveHomeUrl';
import { useTenancyPosture } from '../hooks/useTenancyPosture';
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);
const isGroupPosture = useTenancyPosture() === 'group';
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: render nothing. With only one org there's nothing to switch to
// and the name carries no actionable meaning, so it's just noise next to the
// product logo. The switcher appears only once a second org exists.
if (orgList.length <= 1) return null;
const handleSwitch = async (org: AuthOrganization) => {
if (org.id === current.id) return;
try {
await switchOrganization(org.id);
// switchOrganization only updates state; reload to the console root so the
// new active org propagates to every data scope app-wide AND
// RootLandingRedirect resolves the right landing (single-app workspace →
// that app, not the redundant launcher). Same as OrganizationsPage.
window.location.href = resolveRootUrl();
} 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">
{isGroupPosture
? t('organization.switcher.groupLabel', { defaultValue: 'Working organization' })
: t('organization.switcher.label', { defaultValue: 'Switch organization' })}
</DropdownMenuLabel>
{isGroupPosture && (
<p
className="px-2 pb-1.5 text-xs font-normal leading-snug text-muted-foreground"
data-testid="workspace-switcher-group-hint"
>
{t('organization.switcher.groupHint', {
defaultValue:
'New records are created here. Views show data from all your organizations.',
})}
</p>
)}
{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>
);
}