Path: blob/main/components/dashboard/src/teams/TeamSettings.tsx
2501 views
/**1* Copyright (c) 2021 Gitpod GmbH. All rights reserved.2* Licensed under the GNU Affero General Public License (AGPL).3* See License.AGPL.txt in the project root for license information.4*/56import { PlainMessage } from "@bufbuild/protobuf";7import { EnvVar } from "@gitpod/gitpod-protocol";8import { ErrorCode } from "@gitpod/gitpod-protocol/lib/messaging/error";9import { OrganizationSettings } from "@gitpod/public-api/lib/gitpod/v1/organization_pb";10import { Button } from "@podkit/buttons/Button";11import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@podkit/select/Select";12import { SwitchInputField } from "@podkit/switch/Switch";13import { Heading2, Heading3, Subheading } from "@podkit/typography/Headings";14import React, { Children, ReactNode, useCallback, useMemo, useState } from "react";15import Alert from "../components/Alert";16import ConfirmationModal from "../components/ConfirmationModal";17import { InputWithCopy } from "../components/InputWithCopy";18import Modal, { ModalBody, ModalFooter, ModalHeader } from "../components/Modal";19import { InputField } from "../components/forms/InputField";20import { TextInputField } from "../components/forms/TextInputField";21import { useToast } from "../components/toasts/Toasts";22import { useFeatureFlag } from "../data/featureflag-query";23import { useInstallationDefaultWorkspaceImageQuery } from "../data/installation/default-workspace-image-query";24import { useIsOwner } from "../data/organizations/members-query";25import { useListOrganizationEnvironmentVariables } from "../data/organizations/org-envvar-queries";26import { useOrgSettingsQuery } from "../data/organizations/org-settings-query";27import { useCurrentOrg, useOrganizationsInvalidator } from "../data/organizations/orgs-query";28import { useUpdateOrgMutation } from "../data/organizations/update-org-mutation";29import { useUpdateOrgSettingsMutation } from "../data/organizations/update-org-settings-mutation";30import { useDocumentTitle } from "../hooks/use-document-title";31import { useOnBlurError } from "../hooks/use-onblur-error";32import { ReactComponent as Stack } from "../icons/Stack.svg";33import { ConfigurationSettingsField } from "../repositories/detail/ConfigurationSettingsField";34import { organizationClient } from "../service/public-api";35import { gitpodHostUrl } from "../service/service";36import { useCurrentUser } from "../user-context";37import { OrgSettingsPage } from "./OrgSettingsPage";38import { NamedOrganizationEnvvarItem } from "./variables/NamedOrganizationEnvvarItem";3940export default function TeamSettingsPage() {41useDocumentTitle("Organization Settings - General");42const { toast } = useToast();43const user = useCurrentUser();44const org = useCurrentOrg().data;45const isOwner = useIsOwner();46const invalidateOrgs = useOrganizationsInvalidator();4748const [modal, setModal] = useState(false);49const [teamNameToDelete, setTeamNameToDelete] = useState("");50const [teamName, setTeamName] = useState(org?.name || "");51const [updated, setUpdated] = useState(false);5253const orgEnvVars = useListOrganizationEnvironmentVariables(org?.id || "");54const gitpodImageAuthEnvVar = orgEnvVars.data?.find((v) => v.name === EnvVar.GITPOD_IMAGE_AUTH_ENV_VAR_NAME);5556const updateOrg = useUpdateOrgMutation();57const isCommitAnnotationEnabled = useFeatureFlag("commit_annotation_setting_enabled");5859const close = () => setModal(false);6061const teamNameError = useOnBlurError(62teamName.length > 3263? "Organization name must not be longer than 32 characters"64: "Organization name can not be blank",65!!teamName && teamName.length <= 32,66);6768const orgFormIsValid = teamNameError.isValid;6970const updateTeamInformation = useCallback(71async (e: React.FormEvent) => {72if (!isOwner) {73return;74}75e.preventDefault();7677if (!orgFormIsValid) {78return;79}8081try {82await updateOrg.mutateAsync({ name: teamName });83setUpdated(true);84setTimeout(() => setUpdated(false), 3000);85} catch (error) {86console.error(error);87}88},89[isOwner, orgFormIsValid, updateOrg, teamName],90);9192const deleteTeam = useCallback(async () => {93if (!org || !user) {94return;95}9697await organizationClient.deleteOrganization({ organizationId: org.id });98invalidateOrgs();99document.location.href = gitpodHostUrl.asDashboard().toString();100}, [invalidateOrgs, org, user]);101102const { data: settings, isLoading } = useOrgSettingsQuery();103const { data: installationDefaultImage } = useInstallationDefaultWorkspaceImageQuery();104const updateTeamSettings = useUpdateOrgSettingsMutation();105106const [showImageEditModal, setShowImageEditModal] = useState(false);107108const handleUpdateTeamSettings = useCallback(109async (newSettings: Partial<PlainMessage<OrganizationSettings>>, options?: { throwMutateError?: boolean }) => {110if (!org?.id) {111throw new Error("no organization selected");112}113if (!isOwner) {114throw new Error("no organization settings change permission");115}116try {117await updateTeamSettings.mutateAsync({118...settings,119...newSettings,120});121toast("Organization settings updated");122} catch (error) {123if (options?.throwMutateError) {124throw error;125}126toast(`Failed to update organization settings: ${error.message}`);127console.error(error);128}129},130[updateTeamSettings, org?.id, isOwner, settings, toast],131);132133const handleUpdateAnnotatedCommits = useCallback(134async (value: boolean) => {135try {136await handleUpdateTeamSettings({ annotateGitCommits: value });137} catch (error) {138console.error(error);139}140},141[handleUpdateTeamSettings],142);143144return (145<>146<OrgSettingsPage>147<div className="space-y-8">148<div>149<Heading2>General</Heading2>150<Subheading>151Set the default role and workspace image, name or delete your organization.152</Subheading>153</div>154<ConfigurationSettingsField>155{updateOrg.isError && (156<Alert type="error" closable={true} className="mb-2 max-w-xl rounded-md">157<span>Failed to update organization information: </span>158<span>{updateOrg.error.message || "unknown error"}</span>159</Alert>160)}161{updated && (162<Alert type="message" closable={true} className="mb-2 max-w-xl rounded-md">163Organization name has been updated.164</Alert>165)}166<TextInputField167label="Display Name"168value={teamName}169error={teamNameError.message}170onChange={setTeamName}171disabled={!isOwner}172topMargin={false}173onBlur={teamNameError.onBlur}174/>175176{org && (177<InputField label="Organization ID">178<InputWithCopy value={org.id} tip="Copy Organization ID" />179</InputField>180)}181182{isOwner && (183<Button184onClick={updateTeamInformation}185className="mt-4"186type="submit"187disabled={org?.name === teamName || !orgFormIsValid}188>189Save190</Button>191)}192</ConfigurationSettingsField>193194<ConfigurationSettingsField>195<Heading3>Default role for joiners</Heading3>196<Subheading className="mb-4">Choose the initial role for new members.</Subheading>197<Select198value={`${settings?.defaultRole || "member"}`}199onValueChange={(value) => handleUpdateTeamSettings({ defaultRole: value })}200disabled={isLoading || !isOwner}201>202<SelectTrigger className="w-60">203<SelectValue placeholder="Select a branch filter" />204</SelectTrigger>205<SelectContent>206<SelectItem value={`owner`}>207Owner - Can fully manage org and repository settings208</SelectItem>209<SelectItem value={`member`}>Member - Can view repository settings</SelectItem>210<SelectItem value={`collaborator`}>211Collaborator - Can only create workspaces212</SelectItem>213</SelectContent>214</Select>215</ConfigurationSettingsField>216217<ConfigurationSettingsField>218<Heading3>Workspace images</Heading3>219<Subheading>Choose a default image for all workspaces in the organization.</Subheading>220221<WorkspaceImageButton222disabled={!isOwner}223settings={settings}224installationDefaultWorkspaceImage={installationDefaultImage}225onClick={() => setShowImageEditModal(true)}226/>227</ConfigurationSettingsField>228229{org?.id && (230<ConfigurationSettingsField>231<Heading3>Docker Registry authentication</Heading3>232<Subheading>Configure Docker registry permissions for the whole organization.</Subheading>233234<NamedOrganizationEnvvarItem235disabled={!isOwner}236name={EnvVar.GITPOD_IMAGE_AUTH_ENV_VAR_NAME}237organizationId={org.id}238variable={gitpodImageAuthEnvVar}239/>240</ConfigurationSettingsField>241)}242243{showImageEditModal && (244<OrgDefaultWorkspaceImageModal245settings={settings}246installationDefaultWorkspaceImage={installationDefaultImage}247onClose={() => setShowImageEditModal(false)}248/>249)}250251{isCommitAnnotationEnabled && (252<ConfigurationSettingsField>253<Heading3>Insights</Heading3>254<Subheading className="mb-4">255Configure insights into usage of Gitpod in your organization.256</Subheading>257258<InputField259label="Annotate git commits"260hint={261<>262Add a <code>Tool:</code> field to all git commit messages created from263workspaces in your organization to associate them with this Gitpod instance.264</>265}266id="annotate-git-commits"267>268<SwitchInputField269id="annotate-git-commits"270checked={settings?.annotateGitCommits || false}271disabled={!isOwner || isLoading}272onCheckedChange={handleUpdateAnnotatedCommits}273label=""274/>275</InputField>276</ConfigurationSettingsField>277)}278279{user?.organizationId !== org?.id && isOwner && (280<ConfigurationSettingsField>281<Heading3>Delete organization</Heading3>282<Subheading className="pb-4 max-w-2xl">283Deleting this organization will also remove all associated data, including projects and284workspaces. Deleted organizations cannot be restored!285</Subheading>286287<Button variant="destructive" onClick={() => setModal(true)}>288Delete Organization289</Button>290</ConfigurationSettingsField>291)}292</div>293</OrgSettingsPage>294295<ConfirmationModal296title="Delete Organization"297buttonText="Delete Organization"298buttonDisabled={teamNameToDelete !== org?.name}299visible={modal}300warningHead="Warning"301warningText="This action cannot be reversed."302onClose={close}303onConfirm={deleteTeam}304>305<div className="text-pk-content-secondary">306<p className="text-base">307You are about to permanently delete <b>{org?.name}</b> including all associated data.308</p>309<ol className="text-m list-outside list-decimal">310<li className="ml-5">311All <b>projects</b> added in this organization will be deleted and cannot be restored312afterwards.313</li>314<li className="ml-5">315All <b>members</b> of this organization will lose access to this organization, associated316projects and workspaces.317</li>318<li className="ml-5">Any free credit allowances granted to this organization will be lost.</li>319</ol>320<p className="pt-4 pb-2 text-base font-semibold text-pk-content-secondary">321Type <code>{org?.name}</code> to confirm322</p>323<input324autoFocus325className="w-full"326type="text"327onChange={(e) => setTeamNameToDelete(e.target.value)}328></input>329</div>330</ConfirmationModal>331</>332);333}334function parseDockerImage(image: string) {335// https://docs.docker.com/registry/spec/api/336let registry, repository, tag;337let parts = image.split("/");338339if (parts.length > 1 && parts[0].includes(".")) {340registry = parts.shift();341} else {342registry = "docker.io";343}344345const remaining = parts.join("/");346[repository, tag] = remaining.split(":");347if (!tag) {348tag = "latest";349}350return {351registry,352repository,353tag,354};355}356357function WorkspaceImageButton(props: {358settings?: OrganizationSettings;359installationDefaultWorkspaceImage?: string;360onClick: () => void;361disabled?: boolean;362}) {363const image = props.settings?.defaultWorkspaceImage || props.installationDefaultWorkspaceImage || "";364365const descList = useMemo(() => {366const arr: ReactNode[] = [<span>Default image</span>];367if (props.disabled) {368arr.push(369<>370Requires <span className="font-medium">Owner</span> permissions to change371</>,372);373}374return arr;375}, [props.disabled]);376377const renderedDescription = useMemo(() => {378return Children.toArray(descList).reduce((acc: ReactNode[], child, index) => {379acc.push(child);380if (index < descList.length - 1) {381acc.push(<> · </>);382}383return acc;384}, []);385}, [descList]);386387return (388<InputField disabled={props.disabled} className="w-full max-w-lg">389<div className="flex flex-col bg-pk-surface-secondary p-3 rounded-lg">390<div className="flex items-center justify-between">391<div className="flex-1 flex items-center overflow-hidden h-8" title={image}>392<span className="w-5 h-5 mr-1">393<Stack />394</span>395<span className="truncate font-medium text-pk-content-secondary">396{parseDockerImage(image).repository}397</span>398 · 399<span className="truncate text-pk-content-tertiary">{parseDockerImage(image).tag}</span>400</div>401{!props.disabled && (402<Button variant="link" onClick={props.onClick}>403Change404</Button>405)}406</div>407{descList.length > 0 && (408<div className="mx-6 text-pk-content-tertiary truncate">{renderedDescription}</div>409)}410</div>411</InputField>412);413}414415interface OrgDefaultWorkspaceImageModalProps {416installationDefaultWorkspaceImage: string | undefined;417settings: OrganizationSettings | undefined;418onClose: () => void;419}420421function OrgDefaultWorkspaceImageModal(props: OrgDefaultWorkspaceImageModalProps) {422const [errorMsg, setErrorMsg] = useState("");423const [defaultWorkspaceImage, setDefaultWorkspaceImage] = useState(props.settings?.defaultWorkspaceImage || "");424const updateTeamSettings = useUpdateOrgSettingsMutation();425426const handleUpdateTeamSettings = useCallback(427async (newSettings: Partial<OrganizationSettings>) => {428try {429await updateTeamSettings.mutateAsync({430...props.settings,431...newSettings,432});433props.onClose();434} catch (error) {435if (!ErrorCode.isUserError(error["code"])) {436console.error(error);437}438setErrorMsg(error.message);439}440},441[updateTeamSettings, props],442);443444return (445<Modal446visible447closeable448onClose={props.onClose}449onSubmit={() => handleUpdateTeamSettings({ defaultWorkspaceImage })}450>451<ModalHeader>Workspace Default Image</ModalHeader>452<ModalBody>453<Alert type="warning" className="mb-2">454<span className="font-medium">Warning:</span> You are setting a default image for all workspaces455within the organization.456</Alert>457{errorMsg.length > 0 && (458<Alert type="error" className="mb-2">459{errorMsg}460</Alert>461)}462<div className="mt-4">463<TextInputField464label="Default Image"465hint="Use any official or custom workspace image from Docker Hub or any private container registry that the Gitpod instance can access."466placeholder={props.installationDefaultWorkspaceImage}467value={defaultWorkspaceImage}468onChange={setDefaultWorkspaceImage}469/>470</div>471</ModalBody>472<ModalFooter>473<Button type="submit">Update Workspace Default Image</Button>474</ModalFooter>475</Modal>476);477}478479480