diff --git a/components/dashboard/src/components/Modal.tsx b/components/dashboard/src/components/Modal.tsx index 8b9caa668c9e4b..5dba9ede8f76b1 100644 --- a/components/dashboard/src/components/Modal.tsx +++ b/components/dashboard/src/components/Modal.tsx @@ -150,7 +150,7 @@ export const ModalFooter: FC = ({ error, warning, children }) return (
{hasAlert && ( -
+
)} diff --git a/components/dashboard/src/data/oidc-clients/delete-oidc-client-mutation.ts b/components/dashboard/src/data/oidc-clients/delete-oidc-client-mutation.ts new file mode 100644 index 00000000000000..cd20b7c0d15dc3 --- /dev/null +++ b/components/dashboard/src/data/oidc-clients/delete-oidc-client-mutation.ts @@ -0,0 +1,45 @@ +/** + * Copyright (c) 2023 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 { useMutation, useQueryClient } from "@tanstack/react-query"; +import { oidcService } from "../../service/public-api"; +import { useCurrentOrg } from "../organizations/orgs-query"; +import { getOIDCClientsQueryKey, OIDCClientsQueryResults } from "./oidc-clients-query"; + +type DeleteOIDCClientArgs = { + clientId: string; +}; +export const useDeleteOIDCClientMutation = () => { + const queryClient = useQueryClient(); + const organization = useCurrentOrg().data; + + return useMutation({ + mutationFn: async ({ clientId }: DeleteOIDCClientArgs) => { + if (!organization) { + throw new Error("No current organization selected"); + } + + return await oidcService.deleteClientConfig({ + id: clientId, + organizationId: organization.id, + }); + }, + onSuccess: (_, { clientId }) => { + if (!organization) { + throw new Error("No current organization selected"); + } + + const queryKey = getOIDCClientsQueryKey(organization.id); + // filter out deleted client immediately + queryClient.setQueryData(queryKey, (clients) => { + return clients?.filter((c) => c.id !== clientId); + }); + + // then invalidate query + queryClient.invalidateQueries({ queryKey }); + }, + }); +}; diff --git a/components/dashboard/src/data/oidc-clients/oidc-clients-query.ts b/components/dashboard/src/data/oidc-clients/oidc-clients-query.ts new file mode 100644 index 00000000000000..02afd6c5b4cc16 --- /dev/null +++ b/components/dashboard/src/data/oidc-clients/oidc-clients-query.ts @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2023 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 { OIDCClientConfig } from "@gitpod/public-api/lib/gitpod/experimental/v1/oidc_pb"; +import { useQuery } from "@tanstack/react-query"; +import { oidcService } from "../../service/public-api"; +import { useCurrentOrg } from "../organizations/orgs-query"; + +export type OIDCClientsQueryResults = OIDCClientConfig[]; + +export const useOIDCClientsQuery = () => { + const { data: organization, isLoading } = useCurrentOrg(); + + return useQuery({ + queryKey: getOIDCClientsQueryKey(organization?.id ?? ""), + queryFn: async () => { + if (!organization) { + throw new Error("No current organization selected"); + } + + const { clientConfigs } = await oidcService.listClientConfigs({ organizationId: organization.id }); + + return clientConfigs; + }, + enabled: !isLoading, + }); +}; + +export const getOIDCClientsQueryKey = (organizationId: string) => ["oidc-clients", { organizationId }]; diff --git a/components/dashboard/src/data/oidc-clients/upsert-oidc-client-mutation.ts b/components/dashboard/src/data/oidc-clients/upsert-oidc-client-mutation.ts new file mode 100644 index 00000000000000..f8b5b0d575afb1 --- /dev/null +++ b/components/dashboard/src/data/oidc-clients/upsert-oidc-client-mutation.ts @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2023 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 { useMutation, useQueryClient } from "@tanstack/react-query"; +import { oidcService } from "../../service/public-api"; +import { getOIDCClientsQueryKey } from "./oidc-clients-query"; + +// TODO: find a better way to type this against the API +type UpsertOIDCClientMutationArgs = + | Parameters[0] + | Parameters[0]; + +export const useUpsertOIDCClientMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ config = {} }: UpsertOIDCClientMutationArgs) => { + if ("id" in config) { + return await oidcService.updateClientConfig({ + config, + }); + } else { + return await oidcService.createClientConfig({ + config, + }); + } + }, + onSuccess(resp, { config = {} }) { + if (!config || !config.organizationId) { + return; + } + + queryClient.invalidateQueries({ queryKey: getOIDCClientsQueryKey(config.organizationId || "") }); + }, + }); +}; diff --git a/components/dashboard/src/teams/GitIntegrationsPage.tsx b/components/dashboard/src/teams/GitIntegrationsPage.tsx index 9d24ec9e35bdf0..306d3e75966d1d 100644 --- a/components/dashboard/src/teams/GitIntegrationsPage.tsx +++ b/components/dashboard/src/teams/GitIntegrationsPage.tsx @@ -4,48 +4,13 @@ * See License.AGPL.txt in the project root for license information. */ -import { FunctionComponent } from "react"; -import { Redirect } from "react-router"; -import Header from "../components/Header"; -import { SpinnerLoader } from "../components/Loader"; -import { useCurrentOrg } from "../data/organizations/orgs-query"; import { GitIntegrations } from "./git-integrations/GitIntegrations"; import { OrgSettingsPage } from "./OrgSettingsPage"; -export default function GitAuth() { +export default function GitAuthPage() { return ( - + - - ); -} - -// TODO: Refactor this into OrgSettingsPage so each page doesn't have to do this -export const OrgSettingsPageWrapper: FunctionComponent = ({ children }) => { - const currentOrg = useCurrentOrg(); - - const title = "Git Auth"; - const subtitle = "Configure Git Auth for GitLab, or Github."; - - // Render as much of the page as we can in a loading state to avoid content shift - if (currentOrg.isLoading) { - return ( -
-
-
- -
-
- ); - } - - if (!currentOrg.data?.isOwner) { - return ; - } - - return ( - - {children} ); -}; +} diff --git a/components/dashboard/src/teams/SSO.tsx b/components/dashboard/src/teams/SSO.tsx index 5e4e0602248ccc..03a3f3a9b0ba41 100644 --- a/components/dashboard/src/teams/SSO.tsx +++ b/components/dashboard/src/teams/SSO.tsx @@ -4,302 +4,13 @@ * See License.AGPL.txt in the project root for license information. */ -import { OIDCClientConfig } from "@gitpod/public-api/lib/gitpod/experimental/v1/oidc_pb"; -import { useCallback, useEffect, useState } from "react"; -import { ContextMenuEntry } from "../components/ContextMenu"; -import { Item, ItemField, ItemFieldContextMenu, ItemFieldIcon, ItemsList } from "../components/ItemsList"; -import Modal from "../components/Modal"; -import { oidcService } from "../service/public-api"; -import { gitpodHostUrl } from "../service/service"; - -import { useCurrentOrg } from "../data/organizations/orgs-query"; -import copy from "../images/copy.svg"; -import exclamation from "../images/exclamation.svg"; import { OrgSettingsPage } from "./OrgSettingsPage"; -import { Heading2, Subheading } from "../components/typography/headings"; -import { EmptyMessage } from "../components/EmptyMessage"; +import { OIDCClients } from "./sso/OIDCClients"; export default function SSO() { - const currentOrg = useCurrentOrg(); - return ( - {currentOrg.data && } + ); } - -function OIDCClients(props: { organizationId: string }) { - const [clientConfigs, setClientConfigs] = useState([]); - - const [modal, setModal] = useState< - | { mode: "new" } - | { mode: "edit"; clientConfig: OIDCClientConfig } - | { mode: "delete"; clientConfig: OIDCClientConfig } - | undefined - >(undefined); - - const reloadClientConfigs = useCallback(async () => { - const clientConfigs = await oidcService - .listClientConfigs({ organizationId: props.organizationId }) - .then((resp) => { - return resp.clientConfigs; - }); - setClientConfigs(clientConfigs); - }, [props.organizationId]); - - useEffect(() => { - reloadClientConfigs().catch(console.error); - }, [reloadClientConfigs]); - - const loginWith = (id: string) => { - window.location.href = gitpodHostUrl.with({ pathname: `/iam/oidc/start`, search: `id=${id}` }).toString(); - }; - - const configMenu = (clientConfig: OIDCClientConfig) => { - const result: ContextMenuEntry[] = []; - result.push({ - title: "Login", - onClick: () => loginWith(clientConfig.id), - separator: true, - }); - result.push({ - title: "Edit", - onClick: () => setModal({ mode: "edit", clientConfig }), - separator: true, - }); - result.push({ - title: "Remove", - customFontStyle: "text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300", - onClick: () => setModal({ mode: "delete", clientConfig }), - }); - return result; - }; - - return ( - <> - {modal?.mode === "new" && ( - setModal(undefined)} - onUpdate={reloadClientConfigs} - /> - )} - - {modal?.mode === "edit" && <>} - {modal?.mode === "delete" && <>} - - OpenID Connect clients - Configure single sign-on for your organization. - -
- {clientConfigs.length !== 0 ? ( -
- -
- ) : null} -
- - {clientConfigs.length === 0 && ( - setModal({ mode: "new" })} - /> - )} - - - {clientConfigs.map((cc) => ( - - -
 
-
- - {cc.id} - - - - {cc.oidcConfig?.issuer} - - - -
- ))} -
- - ); -} - -function OIDCClientConfigModal( - props: ( - | { - mode: "new"; - } - | { - mode: "edit"; - clientConfig: OIDCClientConfig; - } - ) & { - organizationId: string; - onClose?: () => void; - closeable?: boolean; - onUpdate?: () => void; - }, -) { - const [mode] = useState<"new" | "edit">("new"); - const [busy] = useState(false); - const [errorMessage] = useState(undefined); - const [validationError] = useState(undefined); - - const [issuer, setIssuer] = useState(""); - const [clientId, setClientId] = useState(""); - const [clientSecret, setClientSecret] = useState(""); - const [callbackUrl, setCallbackUrl] = useState(""); - - useEffect(() => { - const pathname = `/iam/oidc/callback`; - setCallbackUrl(gitpodHostUrl.with({ pathname }).toString()); - }, []); - - const updateIssuer = (issuer: string) => { - setIssuer(issuer); - }; - - const updateClientId = (value: string) => { - setClientId(value.trim()); - }; - - const updateClientSecret = (value: string) => { - setClientSecret(value.trim()); - }; - - const copyRedirectUrl = () => { - const el = document.createElement("textarea"); - el.value = callbackUrl; - document.body.appendChild(el); - el.select(); - try { - document.execCommand("copy"); - } finally { - document.body.removeChild(el); - } - }; - - const save = async () => { - try { - const response = await oidcService.createClientConfig({ - config: { - organizationId: props.organizationId, - oauth2Config: { - clientId: clientId, - clientSecret: clientSecret, - }, - oidcConfig: { - issuer: issuer, - }, - }, - }); - console.log(response.config?.id); - onUpdate(); - onClose(); - } catch (error) {} - }; - - const onClose = () => props.onClose && props.onClose(); - const onUpdate = () => props.onUpdate && props.onUpdate(); - - return ( - -

{mode === "new" ? "New OIDC Client" : "OIDC Client"}

-
-
- Enter this information from your OIDC service. -
- -
-
- - updateIssuer(e.target.value)} - /> -
-
- -
- -
copyRedirectUrl()}> - copy icon -
-
- -
-
- - updateClientId(e.target.value)} - /> -
-
- - updateClientSecret(e.target.value)} - /> -
-
- - {(errorMessage || validationError) && ( -
- exclamation mark icon - {errorMessage || validationError} -
- )} -
-
- -
-
- ); -} diff --git a/components/dashboard/src/teams/git-integrations/GitIntegrationListItem.tsx b/components/dashboard/src/teams/git-integrations/GitIntegrationListItem.tsx index cda38a56f6136a..6adcda981384f6 100644 --- a/components/dashboard/src/teams/git-integrations/GitIntegrationListItem.tsx +++ b/components/dashboard/src/teams/git-integrations/GitIntegrationListItem.tsx @@ -63,7 +63,7 @@ export const GitIntegrationListItem: FunctionComponent = ({ provider }) = {provider.host} - + {showDeleteConfirmation && ( = (props) => { error={errorMessage} warning={!isNew && savedProvider?.status !== "verified" ? "You need to activate this integration." : ""} > + diff --git a/components/dashboard/src/teams/git-integrations/GitIntegrations.tsx b/components/dashboard/src/teams/git-integrations/GitIntegrations.tsx index 7c777d8a5f69c1..a7b988462c74ee 100644 --- a/components/dashboard/src/teams/git-integrations/GitIntegrations.tsx +++ b/components/dashboard/src/teams/git-integrations/GitIntegrations.tsx @@ -6,11 +6,22 @@ import { FunctionComponent } from "react"; import { SpinnerLoader } from "../../components/Loader"; +import { Heading2, Subheading } from "../../components/typography/headings"; import { useOrgAuthProvidersQuery } from "../../data/auth-providers/org-auth-providers-query"; import { GitIntegrationsList } from "./GitIntegrationsList"; export const GitIntegrations: FunctionComponent = () => { const { data, isLoading } = useOrgAuthProvidersQuery(); - return
{isLoading ? : }
; + if (isLoading) { + return ; + } + + return ( +
+ Git Auth configurations + Configure Git Auth for your organization. + +
+ ); }; diff --git a/components/dashboard/src/teams/sso/OIDCClientConfigModal.tsx b/components/dashboard/src/teams/sso/OIDCClientConfigModal.tsx new file mode 100644 index 00000000000000..b9648b7e9b9651 --- /dev/null +++ b/components/dashboard/src/teams/sso/OIDCClientConfigModal.tsx @@ -0,0 +1,149 @@ +/** + * Copyright (c) 2023 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 { OIDCClientConfig } from "@gitpod/public-api/lib/gitpod/experimental/v1/oidc_pb"; +import { FC, useCallback, useMemo, useState } from "react"; +import isURL from "validator/lib/isURL"; +import { Button } from "../../components/Button"; +import { InputField } from "../../components/forms/InputField"; +import { TextInputField } from "../../components/forms/TextInputField"; +import { InputWithCopy } from "../../components/InputWithCopy"; +import Modal, { ModalBody, ModalFooter, ModalHeader } from "../../components/Modal"; +import { useUpsertOIDCClientMutation } from "../../data/oidc-clients/upsert-oidc-client-mutation"; +import { useCurrentOrg } from "../../data/organizations/orgs-query"; +import { useOnBlurError } from "../../hooks/use-onblur-error"; +import { gitpodHostUrl } from "../../service/service"; + +type Props = { + clientConfig?: OIDCClientConfig; + onClose: () => void; +}; + +export const OIDCClientConfigModal: FC = ({ clientConfig, onClose }) => { + const { data: org } = useCurrentOrg(); + const upsertClientConfig = useUpsertOIDCClientMutation(); + + const isNew = !clientConfig; + + const [issuer, setIssuer] = useState(clientConfig?.oidcConfig?.issuer ?? ""); + const [clientId, setClientId] = useState(clientConfig?.oauth2Config?.clientId ?? ""); + const [clientSecret, setClientSecret] = useState(clientConfig?.oauth2Config?.clientSecret ?? ""); + + const redirectUrl = gitpodHostUrl.with({ pathname: `/iam/oidc/callback` }).toString(); + + const issuerError = useOnBlurError(`Please enter a valid URL.`, issuer.trim().length > 0 && isURL(issuer)); + const clientIdError = useOnBlurError("Client ID is missing.", clientId.trim().length > 0); + const clientSecretError = useOnBlurError("Client Secret is missing.", clientSecret.trim().length > 0); + + const isValid = useMemo( + () => [issuerError, clientIdError, clientSecretError].every((e) => e.isValid), + [clientIdError, clientSecretError, issuerError], + ); + + const saveConfig = useCallback(async () => { + if (!org) { + console.error("no current org selected"); + return; + } + if (!isValid) { + return; + } + + const trimmedIssuer = issuer.trim(); + const trimmedClientId = clientId.trim(); + const trimmedClientSecret = clientSecret.trim(); + + try { + await upsertClientConfig.mutateAsync({ + config: isNew + ? { + organizationId: org.id, + oauth2Config: { + clientId: trimmedClientId, + clientSecret: trimmedClientSecret, + }, + oidcConfig: { + issuer: trimmedIssuer, + }, + } + : { + id: clientConfig?.id, + organizationId: org.id, + oauth2Config: { + clientId: trimmedClientSecret, + // TODO: determine how we should handle when user doesn't change their secret + clientSecret: clientSecret === "redacted" ? "" : trimmedClientSecret, + }, + oidcConfig: { + issuer: trimmedIssuer, + }, + }, + }); + + onClose(); + } catch (error) { + console.error(error); + } + }, [clientConfig?.id, clientId, clientSecret, isNew, isValid, issuer, onClose, org, upsertClientConfig]); + + const errorMessage = upsertClientConfig.isError ? "There was a problem saving your configuration." : ""; + + return ( + { + saveConfig(); + return false; + }} + > + {isNew ? "New OIDC Client" : "OIDC Client"} + +
+ Enter this information from your OIDC service. +
+ + + + + + + + + + +
+ + + + +
+ ); +}; diff --git a/components/dashboard/src/teams/sso/OIDCClientListItem.tsx b/components/dashboard/src/teams/sso/OIDCClientListItem.tsx new file mode 100644 index 00000000000000..478de3acfdd8db --- /dev/null +++ b/components/dashboard/src/teams/sso/OIDCClientListItem.tsx @@ -0,0 +1,92 @@ +/** + * Copyright (c) 2023 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 { OIDCClientConfig } from "@gitpod/public-api/lib/gitpod/experimental/v1/oidc_pb"; +import { FC, useCallback, useMemo, useState } from "react"; +import ConfirmationModal from "../../components/ConfirmationModal"; +import { ContextMenuEntry } from "../../components/ContextMenu"; +import { Item, ItemField, ItemFieldContextMenu, ItemFieldIcon } from "../../components/ItemsList"; +import { useDeleteOIDCClientMutation } from "../../data/oidc-clients/delete-oidc-client-mutation"; +import { gitpodHostUrl } from "../../service/service"; +import { OIDCClientConfigModal } from "./OIDCClientConfigModal"; + +type Props = { + clientConfig: OIDCClientConfig; +}; +export const OIDCClientListItem: FC = ({ clientConfig }) => { + const [showEditModal, setShowEditModal] = useState(false); + const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false); + const deleteOIDCClient = useDeleteOIDCClientMutation(); + + const menuEntries = useMemo(() => { + const result: ContextMenuEntry[] = [ + { + title: "Login", + onClick: () => { + window.location.href = gitpodHostUrl + .with({ pathname: `/iam/oidc/start`, search: `id=${clientConfig.id}` }) + .toString(); + }, + separator: true, + }, + { + title: "Edit", + onClick: () => setShowEditModal(true), + separator: true, + }, + { + title: "Remove", + customFontStyle: "text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300", + onClick: () => setShowDeleteConfirmation(true), + }, + ]; + return result; + }, [clientConfig]); + + const deleteClient = useCallback(async () => { + try { + await deleteOIDCClient.mutateAsync({ clientId: clientConfig.id }); + setShowDeleteConfirmation(false); + } catch (error) { + console.log(error); + } + }, [clientConfig.id, deleteOIDCClient]); + + return ( + <> + + +
 
+
+ + {clientConfig.id} + + + {clientConfig.oidcConfig?.issuer} + + +
+ {showDeleteConfirmation && ( + setShowDeleteConfirmation(false)} + onConfirm={deleteClient} + /> + )} + {showEditModal && ( + setShowEditModal(false)} /> + )} + + ); +}; diff --git a/components/dashboard/src/teams/sso/OIDCClients.tsx b/components/dashboard/src/teams/sso/OIDCClients.tsx new file mode 100644 index 00000000000000..4f659efb628bf8 --- /dev/null +++ b/components/dashboard/src/teams/sso/OIDCClients.tsx @@ -0,0 +1,77 @@ +/** + * Copyright (c) 2023 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 { OIDCClientConfig } from "@gitpod/public-api/lib/gitpod/experimental/v1/oidc_pb"; +import { FC, useCallback, useState } from "react"; +import { Button } from "../../components/Button"; +import { EmptyMessage } from "../../components/EmptyMessage"; +import { Item, ItemField, ItemsList } from "../../components/ItemsList"; +import { SpinnerLoader } from "../../components/Loader"; +import { Heading2, Subheading } from "../../components/typography/headings"; +import { useOIDCClientsQuery } from "../../data/oidc-clients/oidc-clients-query"; +import { OIDCClientConfigModal } from "./OIDCClientConfigModal"; +import { OIDCClientListItem } from "./OIDCClientListItem"; + +export const OIDCClients: FC = () => { + const { data, isLoading } = useOIDCClientsQuery(); + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ; +}; + +type OIDCClientsListProps = { + clientConfigs: OIDCClientConfig[]; +}; +const OIDCClientsList: FC = ({ clientConfigs }) => { + const [showCreateModal, setShowCreateModal] = useState(false); + + const onCreate = useCallback(() => setShowCreateModal(true), []); + const hideModal = useCallback(() => setShowCreateModal(false), []); + + return ( + <> + {showCreateModal && } + + OpenID Connect clients + Configure single sign-on for your organization. + +
+ {clientConfigs.length !== 0 ? ( +
+ +
+ ) : null} +
+ + {clientConfigs.length === 0 ? ( + + ) : ( + + + + ID + Issuer URL + + {clientConfigs.map((cc) => ( + + ))} + + )} + + ); +}; diff --git a/components/public-api-server/pkg/apiv1/oidc.go b/components/public-api-server/pkg/apiv1/oidc.go index 95302b5f62396b..71e1180fdeedec 100644 --- a/components/public-api-server/pkg/apiv1/oidc.go +++ b/components/public-api-server/pkg/apiv1/oidc.go @@ -301,6 +301,9 @@ func dbOIDCClientConfigToAPI(config db.OIDCClientConfig, decryptor db.Decryptor) AuthorizationEndpoint: decrypted.RedirectURL, Scopes: decrypted.Scopes, }, + OidcConfig: &v1.OIDCConfig{ + Issuer: config.Issuer, + }, }, nil } diff --git a/components/public-api-server/pkg/apiv1/oidc_test.go b/components/public-api-server/pkg/apiv1/oidc_test.go index 65c4c39c5b6983..8c823e683b0b9e 100644 --- a/components/public-api-server/pkg/apiv1/oidc_test.go +++ b/components/public-api-server/pkg/apiv1/oidc_test.go @@ -126,6 +126,9 @@ func TestOIDCService_CreateClientConfig_FeatureFlagEnabled(t *testing.T) { ClientSecret: "REDACTED", Scopes: []string{"openid", "profile", "email", "my-scope"}, }, + OidcConfig: &v1.OIDCConfig{ + Issuer: config.OidcConfig.Issuer, + }, }, }, response.Msg)