Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
gitpod-io
GitHub Repository: gitpod-io/gitpod
Path: blob/main/components/dashboard/src/teams/TeamSettings.tsx
2501 views
1
/**
2
* Copyright (c) 2021 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 { PlainMessage } from "@bufbuild/protobuf";
8
import { EnvVar } from "@gitpod/gitpod-protocol";
9
import { ErrorCode } from "@gitpod/gitpod-protocol/lib/messaging/error";
10
import { OrganizationSettings } from "@gitpod/public-api/lib/gitpod/v1/organization_pb";
11
import { Button } from "@podkit/buttons/Button";
12
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@podkit/select/Select";
13
import { SwitchInputField } from "@podkit/switch/Switch";
14
import { Heading2, Heading3, Subheading } from "@podkit/typography/Headings";
15
import React, { Children, ReactNode, useCallback, useMemo, useState } from "react";
16
import Alert from "../components/Alert";
17
import ConfirmationModal from "../components/ConfirmationModal";
18
import { InputWithCopy } from "../components/InputWithCopy";
19
import Modal, { ModalBody, ModalFooter, ModalHeader } from "../components/Modal";
20
import { InputField } from "../components/forms/InputField";
21
import { TextInputField } from "../components/forms/TextInputField";
22
import { useToast } from "../components/toasts/Toasts";
23
import { useFeatureFlag } from "../data/featureflag-query";
24
import { useInstallationDefaultWorkspaceImageQuery } from "../data/installation/default-workspace-image-query";
25
import { useIsOwner } from "../data/organizations/members-query";
26
import { useListOrganizationEnvironmentVariables } from "../data/organizations/org-envvar-queries";
27
import { useOrgSettingsQuery } from "../data/organizations/org-settings-query";
28
import { useCurrentOrg, useOrganizationsInvalidator } from "../data/organizations/orgs-query";
29
import { useUpdateOrgMutation } from "../data/organizations/update-org-mutation";
30
import { useUpdateOrgSettingsMutation } from "../data/organizations/update-org-settings-mutation";
31
import { useDocumentTitle } from "../hooks/use-document-title";
32
import { useOnBlurError } from "../hooks/use-onblur-error";
33
import { ReactComponent as Stack } from "../icons/Stack.svg";
34
import { ConfigurationSettingsField } from "../repositories/detail/ConfigurationSettingsField";
35
import { organizationClient } from "../service/public-api";
36
import { gitpodHostUrl } from "../service/service";
37
import { useCurrentUser } from "../user-context";
38
import { OrgSettingsPage } from "./OrgSettingsPage";
39
import { NamedOrganizationEnvvarItem } from "./variables/NamedOrganizationEnvvarItem";
40
41
export default function TeamSettingsPage() {
42
useDocumentTitle("Organization Settings - General");
43
const { toast } = useToast();
44
const user = useCurrentUser();
45
const org = useCurrentOrg().data;
46
const isOwner = useIsOwner();
47
const invalidateOrgs = useOrganizationsInvalidator();
48
49
const [modal, setModal] = useState(false);
50
const [teamNameToDelete, setTeamNameToDelete] = useState("");
51
const [teamName, setTeamName] = useState(org?.name || "");
52
const [updated, setUpdated] = useState(false);
53
54
const orgEnvVars = useListOrganizationEnvironmentVariables(org?.id || "");
55
const gitpodImageAuthEnvVar = orgEnvVars.data?.find((v) => v.name === EnvVar.GITPOD_IMAGE_AUTH_ENV_VAR_NAME);
56
57
const updateOrg = useUpdateOrgMutation();
58
const isCommitAnnotationEnabled = useFeatureFlag("commit_annotation_setting_enabled");
59
60
const close = () => setModal(false);
61
62
const teamNameError = useOnBlurError(
63
teamName.length > 32
64
? "Organization name must not be longer than 32 characters"
65
: "Organization name can not be blank",
66
!!teamName && teamName.length <= 32,
67
);
68
69
const orgFormIsValid = teamNameError.isValid;
70
71
const updateTeamInformation = useCallback(
72
async (e: React.FormEvent) => {
73
if (!isOwner) {
74
return;
75
}
76
e.preventDefault();
77
78
if (!orgFormIsValid) {
79
return;
80
}
81
82
try {
83
await updateOrg.mutateAsync({ name: teamName });
84
setUpdated(true);
85
setTimeout(() => setUpdated(false), 3000);
86
} catch (error) {
87
console.error(error);
88
}
89
},
90
[isOwner, orgFormIsValid, updateOrg, teamName],
91
);
92
93
const deleteTeam = useCallback(async () => {
94
if (!org || !user) {
95
return;
96
}
97
98
await organizationClient.deleteOrganization({ organizationId: org.id });
99
invalidateOrgs();
100
document.location.href = gitpodHostUrl.asDashboard().toString();
101
}, [invalidateOrgs, org, user]);
102
103
const { data: settings, isLoading } = useOrgSettingsQuery();
104
const { data: installationDefaultImage } = useInstallationDefaultWorkspaceImageQuery();
105
const updateTeamSettings = useUpdateOrgSettingsMutation();
106
107
const [showImageEditModal, setShowImageEditModal] = useState(false);
108
109
const handleUpdateTeamSettings = useCallback(
110
async (newSettings: Partial<PlainMessage<OrganizationSettings>>, options?: { throwMutateError?: boolean }) => {
111
if (!org?.id) {
112
throw new Error("no organization selected");
113
}
114
if (!isOwner) {
115
throw new Error("no organization settings change permission");
116
}
117
try {
118
await updateTeamSettings.mutateAsync({
119
...settings,
120
...newSettings,
121
});
122
toast("Organization settings updated");
123
} catch (error) {
124
if (options?.throwMutateError) {
125
throw error;
126
}
127
toast(`Failed to update organization settings: ${error.message}`);
128
console.error(error);
129
}
130
},
131
[updateTeamSettings, org?.id, isOwner, settings, toast],
132
);
133
134
const handleUpdateAnnotatedCommits = useCallback(
135
async (value: boolean) => {
136
try {
137
await handleUpdateTeamSettings({ annotateGitCommits: value });
138
} catch (error) {
139
console.error(error);
140
}
141
},
142
[handleUpdateTeamSettings],
143
);
144
145
return (
146
<>
147
<OrgSettingsPage>
148
<div className="space-y-8">
149
<div>
150
<Heading2>General</Heading2>
151
<Subheading>
152
Set the default role and workspace image, name or delete your organization.
153
</Subheading>
154
</div>
155
<ConfigurationSettingsField>
156
{updateOrg.isError && (
157
<Alert type="error" closable={true} className="mb-2 max-w-xl rounded-md">
158
<span>Failed to update organization information: </span>
159
<span>{updateOrg.error.message || "unknown error"}</span>
160
</Alert>
161
)}
162
{updated && (
163
<Alert type="message" closable={true} className="mb-2 max-w-xl rounded-md">
164
Organization name has been updated.
165
</Alert>
166
)}
167
<TextInputField
168
label="Display Name"
169
value={teamName}
170
error={teamNameError.message}
171
onChange={setTeamName}
172
disabled={!isOwner}
173
topMargin={false}
174
onBlur={teamNameError.onBlur}
175
/>
176
177
{org && (
178
<InputField label="Organization ID">
179
<InputWithCopy value={org.id} tip="Copy Organization ID" />
180
</InputField>
181
)}
182
183
{isOwner && (
184
<Button
185
onClick={updateTeamInformation}
186
className="mt-4"
187
type="submit"
188
disabled={org?.name === teamName || !orgFormIsValid}
189
>
190
Save
191
</Button>
192
)}
193
</ConfigurationSettingsField>
194
195
<ConfigurationSettingsField>
196
<Heading3>Default role for joiners</Heading3>
197
<Subheading className="mb-4">Choose the initial role for new members.</Subheading>
198
<Select
199
value={`${settings?.defaultRole || "member"}`}
200
onValueChange={(value) => handleUpdateTeamSettings({ defaultRole: value })}
201
disabled={isLoading || !isOwner}
202
>
203
<SelectTrigger className="w-60">
204
<SelectValue placeholder="Select a branch filter" />
205
</SelectTrigger>
206
<SelectContent>
207
<SelectItem value={`owner`}>
208
Owner - Can fully manage org and repository settings
209
</SelectItem>
210
<SelectItem value={`member`}>Member - Can view repository settings</SelectItem>
211
<SelectItem value={`collaborator`}>
212
Collaborator - Can only create workspaces
213
</SelectItem>
214
</SelectContent>
215
</Select>
216
</ConfigurationSettingsField>
217
218
<ConfigurationSettingsField>
219
<Heading3>Workspace images</Heading3>
220
<Subheading>Choose a default image for all workspaces in the organization.</Subheading>
221
222
<WorkspaceImageButton
223
disabled={!isOwner}
224
settings={settings}
225
installationDefaultWorkspaceImage={installationDefaultImage}
226
onClick={() => setShowImageEditModal(true)}
227
/>
228
</ConfigurationSettingsField>
229
230
{org?.id && (
231
<ConfigurationSettingsField>
232
<Heading3>Docker Registry authentication</Heading3>
233
<Subheading>Configure Docker registry permissions for the whole organization.</Subheading>
234
235
<NamedOrganizationEnvvarItem
236
disabled={!isOwner}
237
name={EnvVar.GITPOD_IMAGE_AUTH_ENV_VAR_NAME}
238
organizationId={org.id}
239
variable={gitpodImageAuthEnvVar}
240
/>
241
</ConfigurationSettingsField>
242
)}
243
244
{showImageEditModal && (
245
<OrgDefaultWorkspaceImageModal
246
settings={settings}
247
installationDefaultWorkspaceImage={installationDefaultImage}
248
onClose={() => setShowImageEditModal(false)}
249
/>
250
)}
251
252
{isCommitAnnotationEnabled && (
253
<ConfigurationSettingsField>
254
<Heading3>Insights</Heading3>
255
<Subheading className="mb-4">
256
Configure insights into usage of Gitpod in your organization.
257
</Subheading>
258
259
<InputField
260
label="Annotate git commits"
261
hint={
262
<>
263
Add a <code>Tool:</code> field to all git commit messages created from
264
workspaces in your organization to associate them with this Gitpod instance.
265
</>
266
}
267
id="annotate-git-commits"
268
>
269
<SwitchInputField
270
id="annotate-git-commits"
271
checked={settings?.annotateGitCommits || false}
272
disabled={!isOwner || isLoading}
273
onCheckedChange={handleUpdateAnnotatedCommits}
274
label=""
275
/>
276
</InputField>
277
</ConfigurationSettingsField>
278
)}
279
280
{user?.organizationId !== org?.id && isOwner && (
281
<ConfigurationSettingsField>
282
<Heading3>Delete organization</Heading3>
283
<Subheading className="pb-4 max-w-2xl">
284
Deleting this organization will also remove all associated data, including projects and
285
workspaces. Deleted organizations cannot be restored!
286
</Subheading>
287
288
<Button variant="destructive" onClick={() => setModal(true)}>
289
Delete Organization
290
</Button>
291
</ConfigurationSettingsField>
292
)}
293
</div>
294
</OrgSettingsPage>
295
296
<ConfirmationModal
297
title="Delete Organization"
298
buttonText="Delete Organization"
299
buttonDisabled={teamNameToDelete !== org?.name}
300
visible={modal}
301
warningHead="Warning"
302
warningText="This action cannot be reversed."
303
onClose={close}
304
onConfirm={deleteTeam}
305
>
306
<div className="text-pk-content-secondary">
307
<p className="text-base">
308
You are about to permanently delete <b>{org?.name}</b> including all associated data.
309
</p>
310
<ol className="text-m list-outside list-decimal">
311
<li className="ml-5">
312
All <b>projects</b> added in this organization will be deleted and cannot be restored
313
afterwards.
314
</li>
315
<li className="ml-5">
316
All <b>members</b> of this organization will lose access to this organization, associated
317
projects and workspaces.
318
</li>
319
<li className="ml-5">Any free credit allowances granted to this organization will be lost.</li>
320
</ol>
321
<p className="pt-4 pb-2 text-base font-semibold text-pk-content-secondary">
322
Type <code>{org?.name}</code> to confirm
323
</p>
324
<input
325
autoFocus
326
className="w-full"
327
type="text"
328
onChange={(e) => setTeamNameToDelete(e.target.value)}
329
></input>
330
</div>
331
</ConfirmationModal>
332
</>
333
);
334
}
335
function parseDockerImage(image: string) {
336
// https://docs.docker.com/registry/spec/api/
337
let registry, repository, tag;
338
let parts = image.split("/");
339
340
if (parts.length > 1 && parts[0].includes(".")) {
341
registry = parts.shift();
342
} else {
343
registry = "docker.io";
344
}
345
346
const remaining = parts.join("/");
347
[repository, tag] = remaining.split(":");
348
if (!tag) {
349
tag = "latest";
350
}
351
return {
352
registry,
353
repository,
354
tag,
355
};
356
}
357
358
function WorkspaceImageButton(props: {
359
settings?: OrganizationSettings;
360
installationDefaultWorkspaceImage?: string;
361
onClick: () => void;
362
disabled?: boolean;
363
}) {
364
const image = props.settings?.defaultWorkspaceImage || props.installationDefaultWorkspaceImage || "";
365
366
const descList = useMemo(() => {
367
const arr: ReactNode[] = [<span>Default image</span>];
368
if (props.disabled) {
369
arr.push(
370
<>
371
Requires <span className="font-medium">Owner</span> permissions to change
372
</>,
373
);
374
}
375
return arr;
376
}, [props.disabled]);
377
378
const renderedDescription = useMemo(() => {
379
return Children.toArray(descList).reduce((acc: ReactNode[], child, index) => {
380
acc.push(child);
381
if (index < descList.length - 1) {
382
acc.push(<>&nbsp;&middot;&nbsp;</>);
383
}
384
return acc;
385
}, []);
386
}, [descList]);
387
388
return (
389
<InputField disabled={props.disabled} className="w-full max-w-lg">
390
<div className="flex flex-col bg-pk-surface-secondary p-3 rounded-lg">
391
<div className="flex items-center justify-between">
392
<div className="flex-1 flex items-center overflow-hidden h-8" title={image}>
393
<span className="w-5 h-5 mr-1">
394
<Stack />
395
</span>
396
<span className="truncate font-medium text-pk-content-secondary">
397
{parseDockerImage(image).repository}
398
</span>
399
&nbsp;&middot;&nbsp;
400
<span className="truncate text-pk-content-tertiary">{parseDockerImage(image).tag}</span>
401
</div>
402
{!props.disabled && (
403
<Button variant="link" onClick={props.onClick}>
404
Change
405
</Button>
406
)}
407
</div>
408
{descList.length > 0 && (
409
<div className="mx-6 text-pk-content-tertiary truncate">{renderedDescription}</div>
410
)}
411
</div>
412
</InputField>
413
);
414
}
415
416
interface OrgDefaultWorkspaceImageModalProps {
417
installationDefaultWorkspaceImage: string | undefined;
418
settings: OrganizationSettings | undefined;
419
onClose: () => void;
420
}
421
422
function OrgDefaultWorkspaceImageModal(props: OrgDefaultWorkspaceImageModalProps) {
423
const [errorMsg, setErrorMsg] = useState("");
424
const [defaultWorkspaceImage, setDefaultWorkspaceImage] = useState(props.settings?.defaultWorkspaceImage || "");
425
const updateTeamSettings = useUpdateOrgSettingsMutation();
426
427
const handleUpdateTeamSettings = useCallback(
428
async (newSettings: Partial<OrganizationSettings>) => {
429
try {
430
await updateTeamSettings.mutateAsync({
431
...props.settings,
432
...newSettings,
433
});
434
props.onClose();
435
} catch (error) {
436
if (!ErrorCode.isUserError(error["code"])) {
437
console.error(error);
438
}
439
setErrorMsg(error.message);
440
}
441
},
442
[updateTeamSettings, props],
443
);
444
445
return (
446
<Modal
447
visible
448
closeable
449
onClose={props.onClose}
450
onSubmit={() => handleUpdateTeamSettings({ defaultWorkspaceImage })}
451
>
452
<ModalHeader>Workspace Default Image</ModalHeader>
453
<ModalBody>
454
<Alert type="warning" className="mb-2">
455
<span className="font-medium">Warning:</span> You are setting a default image for all workspaces
456
within the organization.
457
</Alert>
458
{errorMsg.length > 0 && (
459
<Alert type="error" className="mb-2">
460
{errorMsg}
461
</Alert>
462
)}
463
<div className="mt-4">
464
<TextInputField
465
label="Default Image"
466
hint="Use any official or custom workspace image from Docker Hub or any private container registry that the Gitpod instance can access."
467
placeholder={props.installationDefaultWorkspaceImage}
468
value={defaultWorkspaceImage}
469
onChange={setDefaultWorkspaceImage}
470
/>
471
</div>
472
</ModalBody>
473
<ModalFooter>
474
<Button type="submit">Update Workspace Default Image</Button>
475
</ModalFooter>
476
</Modal>
477
);
478
}
479
480