Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
gitpod-io
GitHub Repository: gitpod-io/gitpod
Path: blob/main/components/dashboard/src/menu/OrganizationSelector.tsx
2500 views
1
/**
2
* Copyright (c) 2023 Gitpod GmbH. All rights reserved.
3
* Licensed under the GNU Affero General Public License (AGPL).
4
* See License.AGPL.txt in the project root for license information.
5
*/
6
7
import { FunctionComponent, useCallback } from "react";
8
import ContextMenu, { ContextMenuEntry } from "../components/ContextMenu";
9
import { OrgIcon, OrgIconProps } from "../components/org-icon/OrgIcon";
10
import { useCurrentUser } from "../user-context";
11
import { useCurrentOrg, useOrganizations } from "../data/organizations/orgs-query";
12
import { useLocation } from "react-router";
13
import { useOrgBillingMode } from "../data/billing-mode/org-billing-mode-query";
14
import { useIsOwner, useListOrganizationMembers, useHasRolePermission } from "../data/organizations/members-query";
15
import { isAllowedToCreateOrganization } from "@gitpod/public-api-common/lib/user-utils";
16
import { OrganizationRole } from "@gitpod/public-api/lib/gitpod/v1/organization_pb";
17
import { useFeatureFlag } from "../data/featureflag-query";
18
import { PlusIcon } from "lucide-react";
19
import { useInstallationConfiguration } from "../data/installation/installation-config-query";
20
21
export default function OrganizationSelector() {
22
const user = useCurrentUser();
23
const orgs = useOrganizations();
24
const currentOrg = useCurrentOrg();
25
const members = useListOrganizationMembers().data ?? [];
26
const isOwner = useIsOwner();
27
const hasMemberPermission = useHasRolePermission(OrganizationRole.MEMBER);
28
const { data: billingMode } = useOrgBillingMode();
29
const getOrgURL = useGetOrgURL();
30
const { data: installationConfig } = useInstallationConfiguration();
31
const isDedicated = !!installationConfig?.isDedicatedInstallation;
32
const isMultiOrgEnabled = useFeatureFlag("enable_multi_org");
33
34
// we should have an API to ask for permissions, until then we duplicate the logic here
35
const canCreateOrgs = user && isAllowedToCreateOrganization(user, isDedicated, isMultiOrgEnabled);
36
37
const userFullName = user?.name || "...";
38
39
const activeOrgEntry = !currentOrg.data
40
? {
41
title: userFullName,
42
customContent: <CurrentOrgEntry title={userFullName} subtitle="Personal Account" />,
43
active: false,
44
separator: false,
45
tight: true,
46
}
47
: {
48
title: currentOrg.data.name,
49
customContent: (
50
<CurrentOrgEntry
51
title={currentOrg.data.name}
52
subtitle={hasMemberPermission ? `${members.length} member${members.length === 1 ? "" : "s"}` : ""}
53
/>
54
),
55
active: false,
56
separator: false,
57
tight: true,
58
};
59
60
const linkEntries: ContextMenuEntry[] = [];
61
62
// Show members if we have an org selected
63
if (currentOrg.data) {
64
// collaborator can't access projects, members, usage and billing
65
if (hasMemberPermission) {
66
linkEntries.push({
67
title: "Prebuilds",
68
customContent: <LinkEntry>Prebuilds</LinkEntry>,
69
active: false,
70
separator: false,
71
link: "/prebuilds",
72
});
73
linkEntries.push({
74
title: "Members",
75
customContent: <LinkEntry>Members</LinkEntry>,
76
active: false,
77
separator: true,
78
link: "/members",
79
});
80
if (isDedicated) {
81
if (isOwner) {
82
linkEntries.push({
83
title: "Insights",
84
customContent: <LinkEntry>Insights</LinkEntry>,
85
active: false,
86
separator: false,
87
link: "/insights",
88
});
89
}
90
} else {
91
linkEntries.push({
92
title: "Usage",
93
customContent: <LinkEntry>Usage</LinkEntry>,
94
active: false,
95
separator: false,
96
link: "/usage",
97
});
98
}
99
// Show billing if user is an owner of current org
100
if (isOwner) {
101
if (billingMode?.mode === "usage-based") {
102
linkEntries.push({
103
title: "Billing",
104
customContent: <LinkEntry>Billing</LinkEntry>,
105
active: false,
106
separator: false,
107
link: "/billing",
108
});
109
}
110
}
111
112
linkEntries.push({
113
title: "Repository Settings",
114
customContent: <LinkEntry>Repository Settings</LinkEntry>,
115
active: false,
116
separator: false,
117
link: "/repositories",
118
});
119
120
// Org settings is available for all members, but only owner can change them
121
// collaborator can read org setting via API so that other feature like restrict org workspace classes could work
122
// we only hide the menu from dashboard
123
linkEntries.push({
124
title: "Organization Settings",
125
customContent: <LinkEntry>Organization Settings</LinkEntry>,
126
active: false,
127
separator: false,
128
link: "/settings",
129
});
130
131
if (isOwner && isDedicated) {
132
// Add Admin link for owners
133
linkEntries.push({
134
title: "Organization Administration",
135
customContent: <LinkEntry>Organization Administration</LinkEntry>,
136
active: false,
137
separator: false,
138
link: "/org-admin",
139
});
140
}
141
}
142
}
143
144
// Ensure only last link entry has a separator
145
linkEntries.forEach((e, idx) => {
146
e.separator = idx === linkEntries.length - 1;
147
});
148
149
const otherOrgEntries = (orgs.data || [])
150
.filter((org) => org.id !== currentOrg.data?.id)
151
.sort((a, b) => a.name.localeCompare(b.name))
152
.map((org) => ({
153
title: org.name,
154
customContent: <OrgEntry id={org.id} title={org.name} subtitle={""} />,
155
// marking as active for styles
156
active: true,
157
separator: true,
158
link: getOrgURL(org.id),
159
}));
160
161
const entries = [
162
activeOrgEntry,
163
...linkEntries,
164
...otherOrgEntries,
165
...(canCreateOrgs
166
? [
167
{
168
title: "Create a new organization",
169
customContent: (
170
<div className="w-full text-pk-content-secondary flex items-center">
171
<span className="flex-1">New Organization</span>
172
<PlusIcon size={20} className="size-3.5" />
173
</div>
174
),
175
link: "/orgs/new",
176
// marking as active for styles
177
active: true,
178
},
179
]
180
: []),
181
];
182
183
const selectedTitle = currentOrg?.data ? currentOrg.data.name : userFullName;
184
const classes =
185
"flex h-full text-base py-0 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700";
186
return (
187
<ContextMenu customClasses="w-64 left-0 text-left" menuEntries={entries}>
188
<div className={`${classes} rounded-2xl pl-1`}>
189
<div className="py-1 pr-1 flex font-medium max-w-xs truncate">
190
<OrgIcon
191
id={currentOrg?.data?.id || user?.id || "empty"}
192
name={selectedTitle}
193
size="small"
194
className="mr-2"
195
/>
196
{selectedTitle}
197
</div>
198
<div className="flex h-full pl-0 pr-1 py-1.5 text-gray-50">
199
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg">
200
<path
201
fillRule="evenodd"
202
clipRule="evenodd"
203
d="M5.293 7.293a1 1 0 0 1 1.414 0L10 10.586l3.293-3.293a1 1 0 1 1 1.414 1.414l-4 4a1 1 0 0 1-1.414 0l-4-4a1 1 0 0 1 0-1.414Z"
204
fill="#78716C"
205
/>
206
<title>Toggle organization selection menu</title>
207
</svg>
208
</div>
209
</div>
210
</ContextMenu>
211
);
212
}
213
214
const LinkEntry: FunctionComponent = ({ children }) => {
215
return (
216
<div className="w-full text-sm text-gray-500 dark:text-gray-400">
217
<span>{children}</span>
218
</div>
219
);
220
};
221
222
type OrgEntryProps = {
223
id: string;
224
title: string;
225
subtitle: string;
226
iconSize?: OrgIconProps["size"];
227
};
228
export const OrgEntry: FunctionComponent<OrgEntryProps> = ({ id, title, subtitle, iconSize }) => {
229
return (
230
<div className="w-full text-gray-400 flex items-center">
231
<OrgIcon id={id} name={title} className="mr-4" size={iconSize} />
232
<div className="flex flex-col">
233
<span className="text-gray-800 dark:text-gray-300 text-base font-semibold truncate w-40">{title}</span>
234
<span>{subtitle}</span>
235
</div>
236
</div>
237
);
238
};
239
240
type CurrentOrgEntryProps = {
241
title: string;
242
subtitle: string;
243
};
244
const CurrentOrgEntry: FunctionComponent<CurrentOrgEntryProps> = ({ title, subtitle }) => {
245
return (
246
<div className="w-full text-gray-400 flex items-center justify-between">
247
<div className="flex flex-col">
248
<span className="text-gray-800 dark:text-gray-300 text-base font-semibold truncate w-40">{title}</span>
249
<span>{subtitle}</span>
250
</div>
251
252
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" className="dark:hidden" fill="none">
253
<path
254
fill="#78716C"
255
fillRule="evenodd"
256
d="M18.2348 5.8867 7.88699 16.2345l-2.12132-2.1213L16.1135 3.76538l2.1213 2.12132Z"
257
clipRule="evenodd"
258
/>
259
<path
260
fill="#78716C"
261
fillRule="evenodd"
262
d="m3.88695 8.06069 5.00004 5.00001-2.12132 2.1214-5.00005-5.0001 2.12133-2.12131Z"
263
clipRule="evenodd"
264
/>
265
</svg>
266
267
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" className="hidden dark:block" fill="none">
268
<path
269
fill="#E7E5E4"
270
fillRule="evenodd"
271
d="M18.2348 5.8867 7.88699 16.2345l-2.12132-2.1213L16.1135 3.76538l2.1213 2.12132Z"
272
clipRule="evenodd"
273
/>
274
<path
275
fill="#E7E5E4"
276
fillRule="evenodd"
277
d="m3.88695 8.06069 5.00004 5.00001-2.12132 2.1214-5.00005-5.0001 2.12133-2.12131Z"
278
clipRule="evenodd"
279
/>
280
</svg>
281
</div>
282
);
283
};
284
285
// Determine url to use when switching orgs
286
// Maintains the current location & context url (hash) when on the new workspace page
287
const useGetOrgURL = () => {
288
const location = useLocation();
289
290
return useCallback(
291
(orgID: string) => {
292
// Default to root path when switching orgs
293
let path = "/";
294
let hash = "";
295
const search = new URLSearchParams();
296
search.append("org", orgID);
297
298
// If we're on the new workspace page, try to maintain the location and context url
299
if (/^\/new(\/$)?$/.test(location.pathname)) {
300
path = `/new`;
301
hash = location.hash;
302
search.append("autostart", "false");
303
}
304
305
return `${path}?${search.toString()}${hash}`;
306
},
307
[location.hash, location.pathname],
308
);
309
};
310
311