Path: blob/main/components/dashboard/src/workspaces/CreateWorkspacePage.tsx
2500 views
/**1* Copyright (c) 2023 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 { SuggestedRepository } from "@gitpod/public-api/lib/gitpod/v1/scm_pb";7import { SelectAccountPayload } from "@gitpod/gitpod-protocol/lib/auth";8import { ApplicationError, ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";9import { Deferred } from "@gitpod/gitpod-protocol/lib/util/deferred";10import { FC, FunctionComponent, useCallback, useContext, useEffect, useMemo, useState, ReactNode } from "react";11import { useHistory, useLocation } from "react-router";12import Alert from "../components/Alert";13import { AuthorizeGit, useNeedsGitAuthorization } from "../components/AuthorizeGit";14import { LinkButton } from "../components/LinkButton";15import Modal, { ModalBody, ModalFooter, ModalHeader } from "../components/Modal";16import RepositoryFinder from "../components/RepositoryFinder";17import SelectIDEComponent from "../components/SelectIDEComponent";18import SelectWorkspaceClassComponent from "../components/SelectWorkspaceClassComponent";19import { UsageLimitReachedModal } from "../components/UsageLimitReachedModal";20import { InputField } from "../components/forms/InputField";21import { Heading1 } from "../components/typography/headings";22import { useAuthProviderDescriptions } from "../data/auth-providers/auth-provider-descriptions-query";23import { useCurrentOrg } from "../data/organizations/orgs-query";24import { useCreateWorkspaceMutation } from "../data/workspaces/create-workspace-mutation";25import { useListWorkspacesQuery } from "../data/workspaces/list-workspaces-query";26import { useWorkspaceContext } from "../data/workspaces/resolve-context-query";27import { useDirtyState } from "../hooks/use-dirty-state";28import { openAuthorizeWindow } from "../provider-utils";29import { gitpodHostUrl } from "../service/service";30import { StartPage, StartWorkspaceError } from "../start/StartPage";31import { VerifyModal } from "../start/VerifyModal";32import { StartWorkspaceOptions } from "../start/start-workspace-options";33import { UserContext, useCurrentUser } from "../user-context";34import { SelectAccountModal } from "../user-settings/SelectAccountModal";35import { settingsPathIntegrations } from "../user-settings/settings.routes";36import { BrowserExtensionBanner } from "./BrowserExtensionBanner";37import { WorkspaceEntry } from "./WorkspaceEntry";38import { AuthProviderType } from "@gitpod/public-api/lib/gitpod/v1/authprovider_pb";39import {40CreateAndStartWorkspaceRequest_ContextURL,41WorkspacePhase_Phase,42} from "@gitpod/public-api/lib/gitpod/v1/workspace_pb";43import { Button } from "@podkit/buttons/Button";44import { LoadingButton } from "@podkit/buttons/LoadingButton";45import { CreateAndStartWorkspaceRequest } from "@gitpod/public-api/lib/gitpod/v1/workspace_pb";46import { PartialMessage } from "@bufbuild/protobuf";47import { User_WorkspaceAutostartOption } from "@gitpod/public-api/lib/gitpod/v1/user_pb";48import { EditorReference } from "@gitpod/public-api/lib/gitpod/v1/editor_pb";49import { converter } from "../service/public-api";50import { useUpdateCurrentUserMutation } from "../data/current-user/update-mutation";51import { useAllowedWorkspaceClassesMemo } from "../data/workspaces/workspace-classes-query";52import Menu from "../menu/Menu";53import { useOrgSettingsQuery } from "../data/organizations/org-settings-query";54import { useAllowedWorkspaceEditorsMemo } from "../data/ide-options/ide-options-query";55import { isGitpodIo } from "../utils";56import { useListConfigurations } from "../data/configurations/configuration-queries";57import { flattenPagedConfigurations } from "../data/git-providers/unified-repositories-search-query";58import { Configuration } from "@gitpod/public-api/lib/gitpod/v1/configuration_pb";59import { useMemberRole } from "../data/organizations/members-query";60import { OrganizationPermission } from "@gitpod/public-api/lib/gitpod/v1/organization_pb";61import { useInstallationConfiguration } from "../data/installation/installation-config-query";62import { MaintenanceModeBanner } from "../org-admin/MaintenanceModeBanner";6364type NextLoadOption = "searchParams" | "autoStart" | "allDone";6566export const StartWorkspaceKeyBinding = `${/(Mac|iPhone|iPod|iPad)/i.test(navigator.platform) ? "⌘" : "Ctrl﹢"}Enter`;6768export function CreateWorkspacePage() {69const { user, setUser } = useContext(UserContext);70const updateUser = useUpdateCurrentUserMutation();71const currentOrg = useCurrentOrg().data;72const workspaces = useListWorkspacesQuery({ limit: 50 });73const location = useLocation();74const history = useHistory();75const props = StartWorkspaceOptions.parseSearchParams(location.search);76const [autostart, setAutostart] = useState<boolean | undefined>(props.autostart);77const createWorkspaceMutation = useCreateWorkspaceMutation();7879// Currently this tracks if the user has selected a project from the dropdown80// Need to make sure we initialize this to a project if the url hash value maps to a project's repo url81// Will need to handle multiple projects w/ same repo url82const [selectedProjectID, setSelectedProjectID] = useState<string | undefined>(undefined);8384const defaultLatestIde =85props.ideSettings?.useLatestVersion !== undefined86? props.ideSettings.useLatestVersion87: user?.editorSettings?.version === "latest";88const defaultPreferToolbox = props.ideSettings?.preferToolbox ?? user?.editorSettings?.preferToolbox ?? false;89const [useLatestIde, setUseLatestIde] = useState(defaultLatestIde);90const [preferToolbox, setPreferToolbox] = useState(defaultPreferToolbox);91// Note: it has data fetching and UI rendering race between the updating of `selectedProjectId` and `selectedIde`92// We have to stored the using repositoryId locally so that we can know selectedIde is updated because if which repo93// so that it doesn't show ide error messages in middle state94const [defaultIdeSource, setDefaultIdeSource] = useState<string | undefined>(selectedProjectID);95const {96computedDefault: computedDefaultEditor,97usingConfigurationId,98availableOptions: availableEditorOptions,99} = useAllowedWorkspaceEditorsMemo(selectedProjectID, {100userDefault: user?.editorSettings?.name,101filterOutDisabled: true,102});103const defaultIde = computedDefaultEditor;104const [selectedIde, setSelectedIde, selectedIdeIsDirty] = useDirtyState<string | undefined>(defaultIde);105const {106computedDefaultClass,107data: allowedWorkspaceClasses,108isLoading: isLoadingWorkspaceClasses,109} = useAllowedWorkspaceClassesMemo(selectedProjectID);110const defaultWorkspaceClass = props.workspaceClass ?? computedDefaultClass;111const showExamples = props.showExamples ?? false;112const { data: orgSettings } = useOrgSettingsQuery();113const memberRole = useMemberRole();114const [selectedWsClass, setSelectedWsClass, selectedWsClassIsDirty] = useDirtyState(defaultWorkspaceClass);115const [errorWsClass, setErrorWsClass] = useState<ReactNode | undefined>(undefined);116const [errorIde, setErrorIde] = useState<ReactNode | undefined>(undefined);117const [warningIde, setWarningIde] = useState<ReactNode | undefined>(undefined);118const [contextURL, setContextURL] = useState<string | undefined>(119StartWorkspaceOptions.parseContextUrl(location.hash),120);121const [nextLoadOption, setNextLoadOption] = useState<NextLoadOption>("searchParams");122const workspaceContext = useWorkspaceContext(contextURL);123const needsGitAuthorization = useNeedsGitAuthorization();124125useEffect(() => {126setContextURL(StartWorkspaceOptions.parseContextUrl(location.hash));127setSelectedProjectID(undefined);128setNextLoadOption("searchParams");129}, [location.hash]);130131const cloneURL = workspaceContext.data?.cloneUrl;132133const paginatedConfigurations = useListConfigurations({134sortBy: "name",135sortOrder: "desc",136pageSize: 100,137searchTerm: cloneURL,138});139const configurations = useMemo<Configuration[]>(140() => flattenPagedConfigurations(paginatedConfigurations.data),141[paginatedConfigurations.data],142);143144const storeAutoStartOptions = useCallback(async () => {145if (!workspaceContext.data || !user || !currentOrg) {146return;147}148if (!cloneURL) {149return;150}151let workspaceAutoStartOptions = (user.workspaceAutostartOptions || []).filter(152(e) => !(e.cloneUrl === cloneURL && e.organizationId === currentOrg.id),153);154155// we only keep the last 40 options156workspaceAutoStartOptions = workspaceAutoStartOptions.slice(-40);157158// remember options159workspaceAutoStartOptions.push(160new User_WorkspaceAutostartOption({161cloneUrl: cloneURL,162organizationId: currentOrg.id,163workspaceClass: selectedWsClass,164editorSettings: new EditorReference({165name: selectedIde,166version: useLatestIde ? "latest" : "stable",167preferToolbox: preferToolbox,168}),169}),170);171const updatedUser = await updateUser.mutateAsync({172additionalData: {173workspaceAutostartOptions: workspaceAutoStartOptions.map((o) =>174converter.fromWorkspaceAutostartOption(o),175),176},177});178setUser(updatedUser);179}, [180workspaceContext.data,181user,182currentOrg,183cloneURL,184selectedWsClass,185selectedIde,186useLatestIde,187preferToolbox,188updateUser,189setUser,190]);191192// see if we have a matching configuration based on context url and configuration's repo url193const configuration = useMemo(() => {194if (!workspaceContext.data || configurations.length === 0) {195return undefined;196}197if (!cloneURL) {198return;199}200// TODO: Account for multiple configurations w/ the same cloneUrl201return configurations.find((p) => p.cloneUrl === cloneURL);202}, [workspaceContext.data, configurations, cloneURL]);203204// Handle the case where the context url in the hash matches a project and we don't have that project selected yet205useEffect(() => {206if (configuration && !selectedProjectID) {207setSelectedProjectID(configuration.id);208}209}, [configuration, selectedProjectID]);210211// In addition to updating state, we want to update the url hash as well212// This allows the contextURL to persist if user changes orgs, or copies/shares url213const handleContextURLChange = useCallback(214(repo: SuggestedRepository) => {215// we disable auto start if the user changes the context URL216setAutostart(false);217// TODO: consider storing SuggestedRepository as state vs. discrete props218setContextURL(repo?.url);219setSelectedProjectID(repo?.configurationId);220// TODO: consider dropping this - it's a lossy conversion221history.replace(`#${repo?.url}`);222// reset load options223setNextLoadOption("searchParams");224},225[history],226);227228const onSelectEditorChange = useCallback(229(ide: string, useLatest: boolean) => {230setSelectedIde(ide);231setUseLatestIde(useLatest);232},233[setSelectedIde, setUseLatestIde],234);235236const existingWorkspaces = useMemo(() => {237if (!workspaces.data || !workspaceContext.data) {238return [];239}240return workspaces.data.filter(241(ws) =>242ws.status?.phase?.name === WorkspacePhase_Phase.RUNNING &&243workspaceContext.data &&244ws.status.gitStatus?.cloneUrl === workspaceContext.data.cloneUrl &&245ws.status?.gitStatus?.latestCommit === workspaceContext.data.revision,246);247}, [workspaces.data, workspaceContext.data]);248const [selectAccountError, setSelectAccountError] = useState<SelectAccountPayload | undefined>(undefined);249250const createWorkspace = useCallback(251/**252* options will omit253* - source.url254* - source.workspaceClass255* - metadata.organizationId256* - metadata.configurationId257*/258async (options?: PartialMessage<CreateAndStartWorkspaceRequest>) => {259// add options from search params260const opts = options || {};261262if (!contextURL) {263return;264}265266const organizationId = currentOrg?.id;267if (!organizationId) {268// We need an organizationId for this group of users269console.error("Skipping createWorkspace");270return;271}272273// if user received an INVALID_GITPOD_YML yml for their contextURL they can choose to proceed using default configuration274if (275workspaceContext.error &&276ApplicationError.hasErrorCode(workspaceContext.error) &&277workspaceContext.error.code === ErrorCodes.INVALID_GITPOD_YML278) {279opts.forceDefaultConfig = true;280}281282try {283if (createWorkspaceMutation.isStarting) {284console.log("Skipping duplicate createWorkspace call.");285return;286}287// we wait at least 5 secs288const timeout = new Promise((resolve) => setTimeout(resolve, 5000));289290if (!opts.metadata) {291opts.metadata = {};292}293opts.metadata.organizationId = organizationId;294opts.metadata.configurationId = selectedProjectID;295296const contextUrlSource: PartialMessage<CreateAndStartWorkspaceRequest_ContextURL> =297opts.source?.case === "contextUrl" ? opts.source?.value ?? {} : {};298contextUrlSource.url = contextURL;299contextUrlSource.workspaceClass = selectedWsClass;300if (!contextUrlSource.editor || !contextUrlSource.editor.name) {301contextUrlSource.editor = {302name: selectedIde,303version: useLatestIde ? "latest" : undefined,304preferToolbox: preferToolbox,305};306}307opts.source = {308case: "contextUrl",309value: contextUrlSource,310};311const result = await createWorkspaceMutation.createWorkspace(opts);312await storeAutoStartOptions();313await timeout;314if (result.workspace?.status?.workspaceUrl) {315window.location.href = result.workspace.status.workspaceUrl;316} else if (result.workspace!.id) {317history.push(`/start/#${result.workspace!.id}`);318}319} catch (error) {320console.log(error);321} finally {322// we only auto start once, so we don't run into endless start loops on errors323if (autostart) {324setAutostart(false);325}326}327},328[329workspaceContext.error,330contextURL,331currentOrg?.id,332selectedWsClass,333selectedIde,334useLatestIde,335preferToolbox,336createWorkspaceMutation,337selectedProjectID,338storeAutoStartOptions,339history,340autostart,341],342);343344// listen on auto start changes345useEffect(() => {346if (!autostart || nextLoadOption !== "allDone") {347return;348}349createWorkspace();350}, [autostart, nextLoadOption, createWorkspace]);351352useEffect(() => {353if (nextLoadOption !== "searchParams") {354return;355}356if (props.ideSettings?.defaultIde) {357setSelectedIde(props.ideSettings.defaultIde);358}359if (props.workspaceClass) {360setSelectedWsClass(props.workspaceClass);361}362setNextLoadOption("autoStart");363}, [props, setSelectedIde, setSelectedWsClass, nextLoadOption, setNextLoadOption]);364365// when workspaceContext is available, we look up if options are remembered366useEffect(() => {367if (!workspaceContext.data || !user?.workspaceAutostartOptions || !currentOrg) {368return;369}370const cloneURL = workspaceContext.data.cloneUrl;371if (!cloneURL) {372return undefined;373}374if (nextLoadOption !== "autoStart") {375return;376}377if (isLoadingWorkspaceClasses || allowedWorkspaceClasses.length === 0) {378return;379}380const rememberedOptions = user.workspaceAutostartOptions.find(381(e) => e.cloneUrl === cloneURL && e.organizationId === currentOrg?.id,382);383if (rememberedOptions) {384if (!selectedIdeIsDirty) {385if (386rememberedOptions.editorSettings?.name &&387!availableEditorOptions.includes(rememberedOptions.editorSettings.name)388) {389rememberedOptions.editorSettings.name = "code";390}391setSelectedIde(rememberedOptions.editorSettings?.name, false);392setUseLatestIde(rememberedOptions.editorSettings?.version === "latest");393setPreferToolbox(rememberedOptions.editorSettings?.preferToolbox || false);394}395396if (!selectedWsClassIsDirty) {397if (398allowedWorkspaceClasses.some(399(cls) => cls.id === rememberedOptions.workspaceClass && !cls.isDisabledInScope,400)401) {402setSelectedWsClass(rememberedOptions.workspaceClass, false);403}404}405} else {406// reset the ide settings to the user's default IF they haven't changed it manually407if (!selectedIdeIsDirty) {408setSelectedIde(defaultIde, false);409setUseLatestIde(defaultLatestIde);410setPreferToolbox(defaultPreferToolbox);411}412if (!selectedWsClassIsDirty) {413const projectWsClass = configuration?.workspaceSettings?.workspaceClass;414const targetClass = projectWsClass || defaultWorkspaceClass;415if (allowedWorkspaceClasses.some((cls) => cls.id === targetClass && !cls.isDisabledInScope)) {416setSelectedWsClass(targetClass, false);417}418}419}420setDefaultIdeSource(usingConfigurationId);421setNextLoadOption("allDone");422// we only update the remembered options when the workspaceContext changes423// eslint-disable-next-line react-hooks/exhaustive-deps424}, [workspaceContext.data, nextLoadOption, configuration, isLoadingWorkspaceClasses, allowedWorkspaceClasses]);425426// Need a wrapper here so we call createWorkspace w/o any arguments427const onClickCreate = useCallback(() => createWorkspace(), [createWorkspace]);428429// if the context URL has a referrer prefix, we set the referrerIde as the selected IDE and autostart the workspace.430useEffect(() => {431if (workspaceContext.data && workspaceContext.data.refererIDE) {432if (!selectedIdeIsDirty) {433setSelectedIde(workspaceContext.data.refererIDE, false);434}435setAutostart(true);436}437}, [selectedIdeIsDirty, setSelectedIde, workspaceContext.data]);438439// on error we disable auto start and consider options loaded440useEffect(() => {441if (workspaceContext.error || createWorkspaceMutation.error) {442setAutostart(false);443setNextLoadOption("allDone");444}445}, [workspaceContext.error, createWorkspaceMutation.error]);446447// Derive if the continue button is disabled based on current state448const continueButtonDisabled = useMemo(() => {449if (450autostart ||451workspaceContext.isLoading ||452!contextURL ||453contextURL.length === 0 ||454!!errorIde ||455!!errorWsClass456) {457return true;458}459if (workspaceContext.error) {460// For INVALID_GITPOD_YML we don't want to disable the button461// The user see a warning that their file is invalid, but they can continue and it will be ignored462if (463workspaceContext.error &&464ApplicationError.hasErrorCode(workspaceContext.error) &&465workspaceContext.error.code === ErrorCodes.INVALID_GITPOD_YML466) {467return false;468}469return true;470}471472return false;473}, [autostart, contextURL, errorIde, errorWsClass, workspaceContext.error, workspaceContext.isLoading]);474475useEffect(() => {476const onKeyDown = (event: KeyboardEvent) => {477if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {478if (!continueButtonDisabled) {479event.preventDefault();480onClickCreate();481}482}483};484window.addEventListener("keydown", onKeyDown);485return () => {486window.removeEventListener("keydown", onKeyDown);487};488}, [continueButtonDisabled, onClickCreate]);489490if (SelectAccountPayload.is(selectAccountError)) {491return (492<SelectAccountModal493{...selectAccountError}494close={() => {495history.push(settingsPathIntegrations);496}}497/>498);499}500501if (needsGitAuthorization) {502return (503<div className="flex flex-col mt-32 mx-auto ">504<div className="flex flex-col max-h-screen max-w-xl mx-auto items-center w-full">505<Heading1>New Workspace</Heading1>506<div className="text-gray-500 text-center text-base">507Start a new workspace with the following options.508</div>509<AuthorizeGit510refetch={workspaceContext.refetch}511className="mt-12 border-2 border-gray-100 dark:border-gray-800 rounded-lg"512/>513</div>514</div>515);516}517518if (519(createWorkspaceMutation.isStarting || autostart) &&520!(createWorkspaceMutation.error || workspaceContext.error)521) {522return <StartPage phase={WorkspacePhase_Phase.PREPARING} />;523}524525return (526<div className="container">527<Menu />528<div className="flex flex-col mt-32 mx-auto ">529<div className="flex flex-col max-h-screen max-w-xl mx-auto items-center w-full">530<Heading1>New Workspace</Heading1>531<div className="text-gray-500 text-center text-base">532Create a new workspace in the{" "}533<span className="font-semibold text-gray-600 dark:text-gray-400">{currentOrg?.name}</span>{" "}534organization.535</div>536537<div className="-mx-6 px-6 mt-6 w-full">538<MaintenanceModeBanner />539{createWorkspaceMutation.error || workspaceContext.error ? (540<ErrorMessage541error={542(createWorkspaceMutation.error as StartWorkspaceError) ||543(workspaceContext.error as StartWorkspaceError)544}545setSelectAccountError={setSelectAccountError}546reset={() => {547workspaceContext.refetch();548createWorkspaceMutation.reset();549}}550/>551) : null}552{warningIde && (553<Alert type="warning" className="my-4">554<span className="text-sm">{warningIde}</span>555</Alert>556)}557{workspaceContext.data?.data.metadata?.warnings.map((warning) => (558<Alert type="warning" key={warning}>559<span className="text-sm">{warning}</span>560</Alert>561)) ?? []}562563<InputField>564<RepositoryFinder565onChange={handleContextURLChange}566selectedContextURL={contextURL}567selectedConfigurationId={selectedProjectID}568expanded={!contextURL}569onlyConfigurations={570orgSettings?.roleRestrictions.some(571(roleRestriction) =>572roleRestriction.role === memberRole &&573roleRestriction.permissions.includes(574OrganizationPermission.START_ARBITRARY_REPOS,575),576) ?? false577}578disabled={createWorkspaceMutation.isStarting}579showExamples={showExamples}580/>581</InputField>582583<InputField error={errorIde}>584<SelectIDEComponent585onSelectionChange={onSelectEditorChange}586availableOptions={587defaultIdeSource === selectedProjectID ? availableEditorOptions : undefined588}589setError={setErrorIde}590setWarning={setWarningIde}591selectedIdeOption={selectedIde}592selectedConfigurationId={selectedProjectID}593pinnedEditorVersions={594orgSettings?.pinnedEditorVersions &&595new Map<string, string>(Object.entries(orgSettings.pinnedEditorVersions))596}597useLatest={useLatestIde}598disabled={createWorkspaceMutation.isStarting}599loading={workspaceContext.isLoading}600ignoreRestrictionScopes={undefined}601/>602</InputField>603604<InputField error={errorWsClass}>605<SelectWorkspaceClassComponent606selectedConfigurationId={selectedProjectID}607onSelectionChange={setSelectedWsClass}608setError={setErrorWsClass}609selectedWorkspaceClass={selectedWsClass}610disabled={createWorkspaceMutation.isStarting}611loading={workspaceContext.isLoading}612/>613</InputField>614</div>615<div className="w-full flex justify-end mt-3 space-x-2 px-6">616<LoadingButton617onClick={onClickCreate}618autoFocus={true}619className="w-full"620loading={createWorkspaceMutation.isStarting || !!autostart}621disabled={continueButtonDisabled}622>623{createWorkspaceMutation.isStarting624? "Opening Workspace ..."625: `Continue (${StartWorkspaceKeyBinding})`}626</LoadingButton>627</div>628{existingWorkspaces.length > 0 && !createWorkspaceMutation.isStarting && (629<div className="w-full flex flex-col justify-end px-6">630<p className="mt-6 text-center text-base">Running workspaces on this revision</p>631{existingWorkspaces.map((w) => {632return (633<a634key={w.id}635href={w.status?.workspaceUrl || `/start/${w.id}}`}636className="rounded-xl group hover:bg-gray-100 dark:hover:bg-gray-800 flex"637>638<WorkspaceEntry info={w} shortVersion={true} />639</a>640);641})}642</div>643)}644</div>645</div>646{!autostart && <BrowserExtensionBanner />}647</div>648);649}650651function tryAuthorize(host: string, scopes?: string[]): Promise<SelectAccountPayload | undefined> {652const result = new Deferred<SelectAccountPayload | undefined>();653openAuthorizeWindow({654host,655scopes,656onSuccess: () => {657result.resolve();658},659onError: (error) => {660if (typeof error === "string") {661try {662const payload = JSON.parse(error);663if (SelectAccountPayload.is(payload)) {664result.resolve(payload);665}666} catch (error) {667console.log(error);668}669}670},671}).catch((error) => {672console.log(error);673});674return result.promise;675}676677interface ErrorMessageProps {678error?: StartWorkspaceError;679reset: () => void;680setSelectAccountError: (error?: SelectAccountPayload) => void;681}682const ErrorMessage: FunctionComponent<ErrorMessageProps> = ({ error, reset, setSelectAccountError }) => {683if (!error) {684return null;685}686687switch (error.code) {688case ErrorCodes.INVALID_GITPOD_YML:689return (690<RepositoryInputError691title="Invalid YAML configuration; using default settings."692message={error.message}693/>694);695case ErrorCodes.NOT_AUTHENTICATED:696return (697<RepositoryInputError698title="You are not authenticated."699linkText={`Authorize with ${error.data?.host}`}700linkOnClick={() => {701tryAuthorize(error.data?.host, error.data?.scopes).then((payload) => {702setSelectAccountError(payload);703reset();704});705}}706/>707);708case ErrorCodes.NOT_FOUND:709return <RepositoryNotFound error={error} />;710case ErrorCodes.PERMISSION_DENIED:711return <RepositoryInputError title="Access is not allowed" />;712case ErrorCodes.USER_BLOCKED:713window.location.href = "/blocked";714return null;715case ErrorCodes.TOO_MANY_RUNNING_WORKSPACES:716return <LimitReachedParallelWorkspacesModal />;717case ErrorCodes.INVALID_COST_CENTER:718return <RepositoryInputError title={`The organization '${error.data}' is not valid.`} />;719case ErrorCodes.PAYMENT_SPENDING_LIMIT_REACHED:720return <UsageLimitReachedModal onClose={reset} />;721case ErrorCodes.NEEDS_VERIFICATION:722return <VerifyModal />;723default:724// Catch-All error message725return (726<RepositoryInputError727title="We're sorry, there seems to have been an error."728message={error.message || JSON.stringify(error)}729/>730);731}732};733734type RepositoryInputErrorProps = {735type?: "error" | "warning";736title: string;737message?: string;738linkText?: string;739linkHref?: string;740linkOnClick?: () => void;741};742const RepositoryInputError: FC<RepositoryInputErrorProps> = ({ title, message, linkText, linkHref, linkOnClick }) => {743return (744<Alert type="warning">745<div>746<span className="text-sm font-semibold">{title}</span>747{message && (748<div className="font-mono text-xs">749<span>{message}</span>750</div>751)}752</div>753{linkText && (754<div>755{linkOnClick ? (756<LinkButton className="whitespace-nowrap text-sm font-semibold" onClick={linkOnClick}>757{linkText}758</LinkButton>759) : (760<a className="gp-link whitespace-nowrap text-sm font-semibold" href={linkHref}>761{linkText}762</a>763)}764</div>765)}766</Alert>767);768};769770export const RepositoryNotFound: FC<{ error: StartWorkspaceError }> = ({ error }) => {771const { host, owner, userIsOwner, userScopes = [], lastUpdate } = error.data || {};772773const authProviders = useAuthProviderDescriptions();774const authProvider = authProviders.data?.find((a) => a.host === host);775if (!authProvider) {776return <RepositoryInputError title="The repository was not found in your account." />;777}778779// TODO: this should be aware of already granted permissions780const missingScope =781authProvider.type === AuthProviderType.GITHUB782? "repo"783: authProvider.type === AuthProviderType.GITLAB784? "api"785: "";786const authorizeURL = gitpodHostUrl787.withApi({788pathname: "/authorize",789search: `returnTo=${encodeURIComponent(window.location.toString())}&host=${host}&scopes=${missingScope}`,790})791.toString();792793const errorMessage = error.data?.errorMessage || error.message;794795if (!userScopes.includes(missingScope)) {796return (797<RepositoryInputError798title="The repository may be private. Please authorize Gitpod to access private repositories."799message={errorMessage}800linkText="Grant access"801linkHref={authorizeURL}802/>803);804}805806if (userIsOwner) {807return <RepositoryInputError title="The repository was not found in your account." message={errorMessage} />;808}809810let updatedRecently = false;811if (lastUpdate && typeof lastUpdate === "string") {812try {813const minutes = (Date.now() - Date.parse(lastUpdate)) / 1000 / 60;814updatedRecently = minutes < 5;815} catch {816// ignore817}818}819820if (!updatedRecently) {821return (822<RepositoryInputError823title={`Permission to access private repositories has been granted. If you are a member of '${owner}', please try to request access for Gitpod.`}824message={errorMessage}825linkText="Request access"826linkHref={authorizeURL}827/>828);829}830if (authProvider.id.toLocaleLowerCase() === "public-github" && isGitpodIo()) {831return (832<RepositoryInputError833title={`Although you appear to have the correct authorization credentials, the '${owner}' organization has enabled OAuth App access restrictions, meaning that data access to third-parties is limited. For more information on these restrictions, including how to enable this app, visit https://docs.github.com/articles/restricting-access-to-your-organization-s-data/.`}834message={errorMessage}835linkText="Check Organization Permissions"836linkHref={"https://github.com/settings/connections/applications/484069277e293e6d2a2a"}837/>838);839}840841return (842<RepositoryInputError843title={`Your access token was updated recently. Please try again if the repository exists and Gitpod was approved for '${owner}'.`}844message={errorMessage}845linkText="Authorize again"846linkHref={authorizeURL}847/>848);849};850851export function LimitReachedParallelWorkspacesModal() {852const { data: installationConfig } = useInstallationConfiguration();853const isDedicated = !!installationConfig?.isDedicatedInstallation;854855return (856<LimitReachedModal>857<p className="mt-1 mb-2 text-base dark:text-gray-400">858You have reached the limit of parallel running workspaces for your account.{" "}859{!isDedicated860? "Please, upgrade or stop one of your running workspaces."861: "Please, stop one of your running workspaces or contact your organization owner to change the limit."}862</p>863</LimitReachedModal>864);865}866867export function LimitReachedModal(p: { children: ReactNode }) {868const user = useCurrentUser();869return (870// TODO: Use title and buttons props871<Modal visible={true} closeable={false} onClose={() => {}}>872<ModalHeader>873<div className="flex">874<span className="flex-grow">Limit Reached</span>875<img className="rounded-full w-8 h-8" src={user?.avatarUrl || ""} alt={user?.name || "Anonymous"} />876</div>877</ModalHeader>878<ModalBody>{p.children}</ModalBody>879<ModalFooter>880<a href={gitpodHostUrl.asDashboard().toString()}>881<Button variant="secondary">Go to Dashboard</Button>882</a>883<a href={gitpodHostUrl.with({ pathname: "plans" }).toString()} className="ml-2">884<Button>Upgrade</Button>885</a>886</ModalFooter>887</Modal>888);889}890891892