Skip to content

Introduce org-level GITPOD_IMAGE_AUTH #20538

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Jan 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions components/dashboard/src/data/organizations/org-envvar-queries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Copyright (c) 2025 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License.AGPL.txt in the project root for license information.
*/

import { OrganizationEnvironmentVariable } from "@gitpod/public-api/lib/gitpod/v1/envvar_pb";
import { envVarClient } from "../../service/public-api";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";

const getListOrgEnvVarQueryKey = (orgId: string) => {
const key: any[] = ["organization", orgId, "envvar", "list"];

return key;
};

const getOrgEnvVarQueryKey = (orgId: string, variableId: string) => {
const key: any[] = ["organization", orgId, "envvar", { variableId }];

return key;
};

export const useListOrganizationEnvironmentVariables = (orgId: string) => {
return useQuery<OrganizationEnvironmentVariable[]>(getListOrgEnvVarQueryKey(orgId), {
queryFn: async () => {
const { environmentVariables } = await envVarClient.listOrganizationEnvironmentVariables({
organizationId: orgId,
});

return environmentVariables;
},
cacheTime: 1000 * 60 * 60 * 24, // one day
});
};

type DeleteEnvironmentVariableArgs = {
variableId: string;
organizationId: string;
};
export const useDeleteOrganizationEnvironmentVariable = () => {
const queryClient = useQueryClient();

return useMutation<void, Error, DeleteEnvironmentVariableArgs>({
mutationFn: async ({ variableId }) => {
void (await envVarClient.deleteOrganizationEnvironmentVariable({
environmentVariableId: variableId,
}));
},
onSuccess: (_, { organizationId, variableId }) => {
queryClient.invalidateQueries({ queryKey: getListOrgEnvVarQueryKey(organizationId) });
queryClient.invalidateQueries({ queryKey: getOrgEnvVarQueryKey(organizationId, variableId) });
},
});
};

type CreateEnvironmentVariableArgs = {
organizationId: string;
name: string;
value: string;
};
export const useCreateOrganizationEnvironmentVariable = () => {
const queryClient = useQueryClient();

return useMutation<OrganizationEnvironmentVariable, Error, CreateEnvironmentVariableArgs>({
mutationFn: async ({ organizationId, name, value }) => {
const { environmentVariable } = await envVarClient.createOrganizationEnvironmentVariable({
organizationId,
name,
value,
});
if (!environmentVariable) {
throw new Error("Failed to create environment variable");
}

return environmentVariable;
},
onSuccess: (_, { organizationId }) => {
queryClient.invalidateQueries({ queryKey: getListOrgEnvVarQueryKey(organizationId) });
},
});
};

type UpdateEnvironmentVariableArgs = CreateEnvironmentVariableArgs & {
variableId: string;
};
export const useUpdateOrganizationEnvironmentVariable = () => {
const queryClient = useQueryClient();

return useMutation<OrganizationEnvironmentVariable, Error, UpdateEnvironmentVariableArgs>({
mutationFn: async ({ variableId, name, value, organizationId }: UpdateEnvironmentVariableArgs) => {
const { environmentVariable } = await envVarClient.updateOrganizationEnvironmentVariable({
environmentVariableId: variableId,
organizationId,
name,
value,
});
if (!environmentVariable) {
throw new Error("Failed to update environment variable");
}

return environmentVariable;
},
onSuccess: (_, { organizationId, variableId }) => {
queryClient.invalidateQueries({ queryKey: getListOrgEnvVarQueryKey(organizationId) });
queryClient.invalidateQueries({ queryKey: getOrgEnvVarQueryKey(organizationId, variableId) });
},
});
};
36 changes: 35 additions & 1 deletion components/dashboard/src/service/json-rpc-envvar-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,29 @@ import { EnvironmentVariableService } from "@gitpod/public-api/lib/gitpod/v1/env
import {
CreateConfigurationEnvironmentVariableRequest,
CreateConfigurationEnvironmentVariableResponse,
CreateOrganizationEnvironmentVariableRequest,
CreateOrganizationEnvironmentVariableResponse,
CreateUserEnvironmentVariableRequest,
CreateUserEnvironmentVariableResponse,
DeleteConfigurationEnvironmentVariableRequest,
DeleteConfigurationEnvironmentVariableResponse,
DeleteOrganizationEnvironmentVariableRequest,
DeleteOrganizationEnvironmentVariableResponse,
DeleteUserEnvironmentVariableRequest,
DeleteUserEnvironmentVariableResponse,
EnvironmentVariableAdmission,
ListConfigurationEnvironmentVariablesRequest,
ListConfigurationEnvironmentVariablesResponse,
ListOrganizationEnvironmentVariablesRequest,
ListOrganizationEnvironmentVariablesResponse,
ListUserEnvironmentVariablesRequest,
ListUserEnvironmentVariablesResponse,
ResolveWorkspaceEnvironmentVariablesRequest,
ResolveWorkspaceEnvironmentVariablesResponse,
UpdateConfigurationEnvironmentVariableRequest,
UpdateConfigurationEnvironmentVariableResponse,
UpdateOrganizationEnvironmentVariableRequest,
UpdateOrganizationEnvironmentVariableResponse,
UpdateUserEnvironmentVariableRequest,
UpdateUserEnvironmentVariableResponse,
} from "@gitpod/public-api/lib/gitpod/v1/envvar_pb";
Expand Down Expand Up @@ -163,7 +171,9 @@ export class JsonRpcEnvvarClient implements PromiseClient<typeof EnvironmentVari
req.configurationId,
req.name ?? projectEnvVarfound.name,
req.value ?? "",
req.admission === EnvironmentVariableAdmission.PREBUILD ?? projectEnvVarfound.censored,
req.admission === EnvironmentVariableAdmission.UNSPECIFIED
? projectEnvVarfound.censored
: req.admission === EnvironmentVariableAdmission.PREBUILD,
req.environmentVariableId,
);

Expand Down Expand Up @@ -224,6 +234,30 @@ export class JsonRpcEnvvarClient implements PromiseClient<typeof EnvironmentVari
return response;
}

async listOrganizationEnvironmentVariables(
req: PartialMessage<ListOrganizationEnvironmentVariablesRequest>,
): Promise<ListOrganizationEnvironmentVariablesResponse> {
throw new ApplicationError(ErrorCodes.BAD_REQUEST, "Unimplemented");
}

async updateOrganizationEnvironmentVariable(
req: PartialMessage<UpdateOrganizationEnvironmentVariableRequest>,
): Promise<UpdateOrganizationEnvironmentVariableResponse> {
throw new ApplicationError(ErrorCodes.BAD_REQUEST, "Unimplemented");
}

async createOrganizationEnvironmentVariable(
req: PartialMessage<CreateOrganizationEnvironmentVariableRequest>,
): Promise<CreateOrganizationEnvironmentVariableResponse> {
throw new ApplicationError(ErrorCodes.BAD_REQUEST, "Unimplemented");
}

async deleteOrganizationEnvironmentVariable(
req: PartialMessage<DeleteOrganizationEnvironmentVariableRequest>,
): Promise<DeleteOrganizationEnvironmentVariableResponse> {
throw new ApplicationError(ErrorCodes.BAD_REQUEST, "Unimplemented");
}

async resolveWorkspaceEnvironmentVariables(
req: PartialMessage<ResolveWorkspaceEnvironmentVariablesRequest>,
): Promise<ResolveWorkspaceEnvironmentVariablesResponse> {
Expand Down
20 changes: 20 additions & 0 deletions components/dashboard/src/teams/TeamSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { useDocumentTitle } from "../hooks/use-document-title";
import { PlainMessage } from "@bufbuild/protobuf";
import { useToast } from "../components/toasts/Toasts";
import { NamedOrganizationEnvvarItem } from "./variables/NamedOrganizationEnvvarItem";
import { useListOrganizationEnvironmentVariables } from "../data/organizations/org-envvar-queries";
import { EnvVar } from "@gitpod/gitpod-protocol";

export default function TeamSettingsPage() {
useDocumentTitle("Organization Settings - General");
Expand All @@ -46,6 +49,9 @@ export default function TeamSettingsPage() {
const [teamName, setTeamName] = useState(org?.name || "");
const [updated, setUpdated] = useState(false);

const orgEnvVars = useListOrganizationEnvironmentVariables(org?.id || "");
const gitpodImageAuthEnvVar = orgEnvVars.data?.find((v) => v.name === EnvVar.GITPOD_IMAGE_AUTH_ENV_VAR_NAME);

const updateOrg = useUpdateOrgMutation();

const close = () => setModal(false);
Expand Down Expand Up @@ -215,6 +221,20 @@ export default function TeamSettingsPage() {
/>
)}

{org?.id && (
<ConfigurationSettingsField>
<Heading3>Docker Registry authentication</Heading3>
<Subheading>Configure Docker registry permissions for the whole organization.</Subheading>

<NamedOrganizationEnvvarItem
disabled={!isOwner}
name={EnvVar.GITPOD_IMAGE_AUTH_ENV_VAR_NAME}
organizationId={org.id}
variable={gitpodImageAuthEnvVar}
/>
</ConfigurationSettingsField>
)}

{user?.organizationId !== org?.id && isOwner && (
<ConfigurationSettingsField>
<Heading3>Delete organization</Heading3>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/**
* Copyright (c) 2025 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License.AGPL.txt in the project root for license information.
*/

import { OrganizationEnvironmentVariable } from "@gitpod/public-api/lib/gitpod/v1/envvar_pb";
import { useCallback, useState } from "react";
import { OrganizationRemoveEnvvarModal } from "./OrganizationRemoveEnvvarModal";
import { InputField } from "../../components/forms/InputField";
import { ReactComponent as Stack } from "../../icons/Repository.svg";
import { Button } from "@podkit/buttons/Button";
import { useCreateOrganizationEnvironmentVariable } from "../../data/organizations/org-envvar-queries";
import Modal, { ModalBody, ModalFooter, ModalFooterAlert, ModalHeader } from "../../components/Modal";
import { TextInputField } from "../../components/forms/TextInputField";
import { useToast } from "../../components/toasts/Toasts";
import { LoadingButton } from "@podkit/buttons/LoadingButton";

type Props = {
disabled?: boolean;
organizationId: string;
name: string;
variable: OrganizationEnvironmentVariable | undefined;
};
export const NamedOrganizationEnvvarItem = ({ disabled, organizationId, name, variable }: Props) => {
const [showRemoveModal, setShowRemoveModal] = useState<boolean>(false);
const [showAddModal, setShowAddModal] = useState<boolean>(false);

const value = variable ? "*****" : "not set";

return (
<>
{variable && showRemoveModal && (
<OrganizationRemoveEnvvarModal
variable={variable}
organizationId={organizationId}
onClose={() => setShowRemoveModal(false)}
/>
)}

{showAddModal && (
<AddOrgEnvironmentVariableModal
organizationId={organizationId}
staticName={name}
onClose={() => setShowAddModal(false)}
/>
)}

<InputField disabled={disabled} className="w-full max-w-lg">
<div className="flex flex-col bg-gray-50 dark:bg-gray-800 p-3 rounded-lg">
<div className="flex items-center justify-between">
<div className="flex-1 flex items-center overflow-hidden h-8 gap-2" title={value}>
<span className="w-5 h-5">
<Stack />
</span>
<span className="truncate font-medium text-gray-700 dark:text-gray-200">{name}</span>
</div>
{!disabled && !variable && (
<Button variant="link" onClick={() => setShowAddModal(true)}>
Add
</Button>
)}
{!disabled && variable && (
<Button variant="link" onClick={() => setShowRemoveModal(true)}>
Delete
</Button>
)}
</div>
<div className="mx-7 text-gray-400 dark:text-gray-500 truncate">
<>{value}</>
{disabled && (
<>
&nbsp;&middot;&nbsp; Requires <span className="font-medium">Owner</span> permissions to
change
</>
)}
</div>
</div>
</InputField>
</>
);
};

type AddOrgEnvironmentVariableModalProps = {
organizationId: string;
staticName?: string;
onClose: () => void;
};
export const AddOrgEnvironmentVariableModal = ({
organizationId,
staticName,
onClose,
}: AddOrgEnvironmentVariableModalProps) => {
const { toast } = useToast();

const [name, setName] = useState(staticName || "");
const [value, setValue] = useState("");
const createVariable = useCreateOrganizationEnvironmentVariable();

const addVariable = useCallback(() => {
createVariable.mutateAsync(
{
organizationId,
name,
value,
},
{
onSuccess: () => {
toast("Variable added");
onClose();
},
},
);
}, [createVariable, organizationId, name, value, onClose, toast]);

return (
<Modal visible onClose={onClose} onSubmit={addVariable}>
<ModalHeader>Add a variable</ModalHeader>
<ModalBody>
<div className="mt-8">
<TextInputField
disabled={staticName !== undefined}
label="Name"
autoComplete={"off"}
className="w-full"
value={name}
placeholder="Variable name"
onChange={(name) => setName(name)}
autoFocus
required
/>
</div>
<div className="mt-4">
<TextInputField
label="Value"
autoComplete={"off"}
className="w-full"
value={value}
placeholder="Variable value"
onChange={(value) => setValue(value)}
required
/>
</div>
</ModalBody>
<ModalFooter
alert={
createVariable.isError ? (
<ModalFooterAlert type="danger">
{String(createVariable.error).replace(/Error: Request \w+ failed with message: /, "")}
</ModalFooterAlert>
) : null
}
>
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<LoadingButton type="submit" loading={createVariable.isLoading}>
Add Variable
</LoadingButton>
</ModalFooter>
</Modal>
);
};
Loading
Loading