diff --git a/client/.eslintrc.json b/client/.eslintrc.json index 09552ec567..edcb56f461 100644 --- a/client/.eslintrc.json +++ b/client/.eslintrc.json @@ -203,6 +203,7 @@ "papermill", "pathname", "pdfjs", + "pkce", "plaintext", "poller", "popups", diff --git a/client/src/features/admin/AddConnectedServiceButton.tsx b/client/src/features/admin/AddConnectedServiceButton.tsx new file mode 100644 index 0000000000..c67f851bab --- /dev/null +++ b/client/src/features/admin/AddConnectedServiceButton.tsx @@ -0,0 +1,173 @@ +/*! + * Copyright 2024 - Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import cx from "classnames"; +import { useCallback, useEffect, useState } from "react"; +import { PlusLg, XLg } from "react-bootstrap-icons"; +import { Controller, useForm } from "react-hook-form"; +import { + Button, + Form, + Input, + Label, + Modal, + ModalBody, + ModalFooter, + ModalHeader, +} from "reactstrap"; +import { + ConnectedServiceForm, + CreateProviderParams, +} from "../connectedServices/connectedServices.types"; +import { useCreateProviderMutation } from "../connectedServices/connectedServices.api"; +import { RtkOrNotebooksError } from "../../components/errors/RtkErrorAlert"; +import { Loader } from "../../components/Loader"; +import ConnectedServiceFormContent from "./ConnectedServiceFormContent"; + +export default function AddConnectedServiceButton() { + const [isOpen, setIsOpen] = useState(false); + const toggle = useCallback(() => { + setIsOpen((open) => !open); + }, []); + + return ( + <> + + + + ); +} + +interface AddConnectedServiceModalProps { + isOpen: boolean; + toggle: () => void; +} +function AddConnectedServiceModal({ + isOpen, + toggle, +}: AddConnectedServiceModalProps) { + const [createProvider, result] = useCreateProviderMutation(); + + const { + control, + formState: { errors }, + handleSubmit, + reset, + } = useForm({ + defaultValues: { + id: "", + kind: "gitlab", + client_id: "", + client_secret: "", + display_name: "", + scope: "", + url: "", + use_pkce: false, + }, + }); + const onSubmit = useCallback( + (data: CreateProviderParams) => { + createProvider({ + id: data.id, + kind: data.kind, + client_id: data.client_id, + client_secret: data.client_secret, + display_name: data.display_name, + scope: data.scope, + url: data.url, + use_pkce: data.use_pkce, + }); + }, + [createProvider] + ); + + useEffect(() => { + if (!result.isSuccess) { + return; + } + toggle(); + }, [result.isSuccess, toggle]); + + useEffect(() => { + if (!isOpen) { + reset(); + result.reset(); + } + }, [isOpen, reset, result]); + + return ( + +
+ Add provider + + {result.error && } + +
+ + ( + + )} + rules={{ required: true }} + /> +
+ + +
+ + + + +
+
+ ); +} diff --git a/client/src/features/admin/AdminPage.tsx b/client/src/features/admin/AdminPage.tsx index ef013906d9..b7a4d4b0de 100644 --- a/client/src/features/admin/AdminPage.tsx +++ b/client/src/features/admin/AdminPage.tsx @@ -62,6 +62,7 @@ import { ResourcePoolUser } from "./adminComputeResources.types"; import { useGetKeycloakUserQuery } from "./adminKeycloak.api"; import { KeycloakUser } from "./adminKeycloak.types"; import useKeycloakRealm from "./useKeycloakRealm.hook"; +import ConnectedServicesSection from "./ConnectedServicesSection"; export default function AdminPage() { return ( @@ -69,6 +70,7 @@ export default function AdminPage() {

Admin Panel

+ ); @@ -76,8 +78,8 @@ export default function AdminPage() { function ComputeResourcesSection() { return ( -
-

Compute Resources

+
+

Compute Resources

); diff --git a/client/src/features/admin/ConnectedServiceFormContent.tsx b/client/src/features/admin/ConnectedServiceFormContent.tsx new file mode 100644 index 0000000000..5648ba00bc --- /dev/null +++ b/client/src/features/admin/ConnectedServiceFormContent.tsx @@ -0,0 +1,201 @@ +/*! + * Copyright 2024 - Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import cx from "classnames"; +import { Control, Controller, FieldErrors } from "react-hook-form"; +import { Input, Label } from "reactstrap"; + +import { ConnectedServiceForm } from "../connectedServices/connectedServices.types"; + +export interface ConnectedServiceFormContentProps { + control: Control; + errors: FieldErrors; +} +export default function ConnectedServiceFormContent({ + control, + errors, +}: ConnectedServiceFormContentProps) { + return ( + <> +
+ + ( + <> + + + + + + )} + rules={{ required: true }} + /> +
Please provide a kind
+
+ +
+ + ( + + )} + rules={{ required: true }} + /> +
Please provide a display name
+
+ +
+ + ( + + )} + rules={{ required: true }} + /> +
Please provide a URL
+
+ +
+ ( + + )} + /> + +
+ +
+ + ( + + )} + rules={{ required: true }} + /> +
Please provide a client id
+
+ +
+ + ( + + )} + /> +
+ Please provide a valid client secret or leave it empty +
+
+ +
+ + ( + + )} + /> +
+ Please provide a valid scope or leave it empty +
+
+ + ); +} diff --git a/client/src/features/admin/ConnectedServicesSection.tsx b/client/src/features/admin/ConnectedServicesSection.tsx new file mode 100644 index 0000000000..a210c4bbfc --- /dev/null +++ b/client/src/features/admin/ConnectedServicesSection.tsx @@ -0,0 +1,161 @@ +/*! + * Copyright 2024 - Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import cx from "classnames"; +import { + Card, + CardBody, + CardHeader, + CardText, + Col, + Collapse, + Container, + Row, +} from "reactstrap"; +import { RtkOrNotebooksError } from "../../components/errors/RtkErrorAlert"; +import { Loader } from "../../components/Loader"; +import { useGetProvidersQuery } from "../connectedServices/connectedServices.api"; +import { + Provider, + ProviderList, +} from "../connectedServices/connectedServices.types"; +import AddConnectedServiceButton from "./AddConnectedServiceButton"; +import ChevronFlippedIcon from "../../components/icons/ChevronFlippedIcon"; +import { useCallback, useState } from "react"; +import DeleteConnectedServiceButton from "./DeleteConnectedServiceButton"; +import UpdateConnectedServiceButton from "./UpdateConnectedServiceButton"; + +export default function ConnectedServicesSection() { + return ( +
+

Connected Services - Renku 2.0

+ +
+ ); +} + +function ConnectedServices() { + const { data: providers, isLoading, error } = useGetProvidersQuery(); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + return ( +
+
+ +
+
+ +
+
+ ); +} + +interface ConnectedServicesListProps { + providers?: ProviderList; +} +function ConnectedServicesList({ providers }: ConnectedServicesListProps) { + if (!providers || providers.length === 0) { + return

No connected services

; + } + return ( + + + {providers.map((provider) => ( + + ))} + + + ); +} + +interface ConnectedServiceProps { + provider: Provider; +} +function ConnectedService({ provider }: ConnectedServiceProps) { + const [isOpen, setIsOpen] = useState(false); + const toggle = useCallback(() => { + setIsOpen((isOpen) => !isOpen); + }, []); + + return ( + + + + + + + + + ID: {provider.id} + Kind: {provider.kind} + URL: {provider.url} + + Client ID: {provider.client_id} + + + Client secret: {provider.client_secret} + + Scope: {provider.scope} + Use PKCE: {provider.use_pkce.toString()} + + + + + + + + + + ); +} diff --git a/client/src/features/admin/DeleteConnectedServiceButton.tsx b/client/src/features/admin/DeleteConnectedServiceButton.tsx new file mode 100644 index 0000000000..9094337fb8 --- /dev/null +++ b/client/src/features/admin/DeleteConnectedServiceButton.tsx @@ -0,0 +1,122 @@ +/*! + * Copyright 2024 - Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import cx from "classnames"; + +import { useCallback, useEffect, useState } from "react"; +import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from "reactstrap"; +import { TrashFill, XLg } from "react-bootstrap-icons"; + +import { Provider } from "../connectedServices/connectedServices.types"; +import { useDeleteProviderMutation } from "../connectedServices/connectedServices.api"; +import { RtkOrNotebooksError } from "../../components/errors/RtkErrorAlert"; + +interface DeleteConnectedServiceButtonProps { + provider: Provider; +} + +export default function DeleteConnectedServiceButton({ + provider, +}: DeleteConnectedServiceButtonProps) { + const [isOpen, setIsOpen] = useState(false); + const toggle = useCallback(() => { + setIsOpen((open) => !open); + }, []); + + return ( + <> + + + + ); +} + +interface DeleteConnectedServiceModalProps { + provider: Provider; + isOpen: boolean; + toggle: () => void; +} + +function DeleteConnectedServiceModal({ + provider, + isOpen, + toggle, +}: DeleteConnectedServiceModalProps) { + const [deleteSessionEnvironment, result] = useDeleteProviderMutation(); + + const onDelete = useCallback(() => { + deleteSessionEnvironment(provider.id); + }, [deleteSessionEnvironment, provider.id]); + + useEffect(() => { + if (!result.isSuccess) { + return; + } + toggle(); + }, [result.isSuccess, toggle]); + + useEffect(() => { + if (!isOpen) { + result.reset(); + } + }, [isOpen, result]); + + return ( + + Are you sure? + + {result.error && } + +

+ Please confirm that you want to delete the {provider.display_name}{" "} + service. +

+
+ + + + +
+ ); +} diff --git a/client/src/features/admin/IncidentsAndMaintenanceSection.tsx b/client/src/features/admin/IncidentsAndMaintenanceSection.tsx index b4d733b09b..b814fb8465 100644 --- a/client/src/features/admin/IncidentsAndMaintenanceSection.tsx +++ b/client/src/features/admin/IncidentsAndMaintenanceSection.tsx @@ -62,7 +62,7 @@ export default function IncidentsAndMaintenanceSection() { return (
-

Incidents And Maintenance

+

Incidents And Maintenance

-

Session Environments - Renku 1.0

+
+

Session Environments - Renku 2.0

); diff --git a/client/src/features/admin/UpdateConnectedServiceButton.tsx b/client/src/features/admin/UpdateConnectedServiceButton.tsx new file mode 100644 index 0000000000..1ccb20e0fc --- /dev/null +++ b/client/src/features/admin/UpdateConnectedServiceButton.tsx @@ -0,0 +1,195 @@ +/*! + * Copyright 2024 - Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import cx from "classnames"; +import { useCallback, useEffect, useState } from "react"; +import { CheckLg, PencilSquare, XLg } from "react-bootstrap-icons"; +import { useForm } from "react-hook-form"; +import { + Button, + Form, + Input, + Label, + Modal, + ModalBody, + ModalFooter, + ModalHeader, +} from "reactstrap"; + +import { Loader } from "../../components/Loader"; +import { RtkOrNotebooksError } from "../../components/errors/RtkErrorAlert"; +import { useUpdateProviderMutation } from "../connectedServices/connectedServices.api"; +import { + ConnectedServiceForm, + Provider, + UpdateProviderParams, +} from "../connectedServices/connectedServices.types"; +import ConnectedServiceFormContent from "./ConnectedServiceFormContent"; + +interface UpdateConnectedServiceButtonProps { + provider: Provider; +} +export default function UpdateConnectedServiceButton({ + provider, +}: UpdateConnectedServiceButtonProps) { + const [isOpen, setIsOpen] = useState(false); + const toggle = useCallback(() => { + setIsOpen((open) => !open); + }, []); + + return ( + <> + + + + ); +} + +interface UpdateConnectedServiceModalProps { + provider: Provider; + isOpen: boolean; + toggle: () => void; +} + +function UpdateConnectedServiceModal({ + provider, + isOpen, + toggle, +}: UpdateConnectedServiceModalProps) { + const [updateProvider, result] = useUpdateProviderMutation(); + + const { + control, + formState: { errors, isDirty }, + handleSubmit, + reset, + } = useForm({ + defaultValues: { + kind: "", + client_id: "", + client_secret: "", + display_name: "", + scope: "", + url: "", + use_pkce: false, + }, + }); + const onSubmit = useCallback( + (data: UpdateProviderParams) => { + updateProvider({ + id: provider.id, + kind: data.kind, + client_id: data.client_id, + client_secret: data.client_secret, + display_name: data.display_name, + scope: data.scope, + url: data.url, + use_pkce: data.use_pkce, + }); + }, + [provider.id, updateProvider] + ); + + useEffect(() => { + if (!result.isSuccess) { + return; + } + toggle(); + reset(); + }, [result.isSuccess, reset, toggle]); + + useEffect(() => { + if (!isOpen) { + result.reset(); + } + }, [isOpen, result]); + + useEffect(() => { + reset({ + kind: provider.kind, + client_id: provider.client_id, + display_name: provider.display_name, + scope: provider.scope, + url: provider.url, + use_pkce: provider.use_pkce, + ...(provider.client_secret && + provider.client_secret !== "redacted" && { + client_secret: provider.client_secret, + }), + }); + }, [provider, reset]); + + return ( + +
+ Update provider + + {result.error && } + +
+ + +
+ + +
+ + + + +
+
+ ); +} diff --git a/client/src/features/connectedServices/ConnectedServicesPage.tsx b/client/src/features/connectedServices/ConnectedServicesPage.tsx index 2842768142..b6842682fc 100644 --- a/client/src/features/connectedServices/ConnectedServicesPage.tsx +++ b/client/src/features/connectedServices/ConnectedServicesPage.tsx @@ -109,7 +109,7 @@ function ConnectedServiceCard({ provider }: ConnectedServiceCardProps) {
{display_name} - +
@@ -129,20 +129,22 @@ function ConnectedServiceCard({ provider }: ConnectedServiceCardProps) { } interface ConnectButtonParams { - id: string; connectionStatus?: ConnectionStatus; + id: string; } -function ConnectButton({ id, connectionStatus }: ConnectButtonParams) { +function ConnectButton({ connectionStatus, id }: ConnectButtonParams) { const hereUrl = window.location.href; const authorizeUrl = `/ui-server/api/data/oauth2/providers/${id}/authorize`; const url = `${authorizeUrl}?next_url=${encodeURIComponent(hereUrl)}`; const text = connectionStatus === "connected" ? "Reconnect" : "Connect"; + const color = + connectionStatus === "connected" ? "btn-outline-primary" : "btn-primary"; return ( - + {text} ); diff --git a/client/src/features/connectedServices/connectedServices.api.ts b/client/src/features/connectedServices/connectedServices.api.ts index 7b112b29e2..04abb19dd2 100644 --- a/client/src/features/connectedServices/connectedServices.api.ts +++ b/client/src/features/connectedServices/connectedServices.api.ts @@ -19,10 +19,13 @@ import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; import { - ProviderList, - ConnectionList, ConnectedAccount, + ConnectionList, + CreateProviderParams, GetConnectedAccountParams, + Provider, + ProviderList, + UpdateProviderParams, } from "./connectedServices.types"; const connectedServicesApi = createApi({ @@ -32,6 +35,43 @@ const connectedServicesApi = createApi({ }), tagTypes: ["Provider", "Connection", "ConnectedAccount"], endpoints: (builder) => ({ + createProvider: builder.mutation({ + query: ({ + id, + kind, + client_id, + client_secret, + display_name, + scope, + url, + use_pkce, + }) => { + return { + url: "providers", + method: "POST", + body: { + id, + kind, + client_id, + ...(client_secret && { client_secret }), + display_name, + scope: scope || "", + url, + use_pkce, + }, + }; + }, + invalidatesTags: ["Provider"], + }), + deleteProvider: builder.mutation({ + query: (providerId) => { + return { + url: `providers/${providerId}`, + method: "DELETE", + }; + }, + invalidatesTags: ["Provider"], + }), getProviders: builder.query({ query: () => { return { @@ -79,12 +119,25 @@ const connectedServicesApi = createApi({ ] : [], }), + updateProvider: builder.mutation({ + query: ({ id, ...params }) => { + return { + url: `providers/${id}`, + method: "PATCH", + body: params, + }; + }, + invalidatesTags: (_result, _error, { id }) => [{ id, type: "Provider" }], + }), }), }); export default connectedServicesApi; export const { + useCreateProviderMutation, + useDeleteProviderMutation, useGetConnectedAccountQuery, useGetConnectionsQuery, useGetProvidersQuery, + useUpdateProviderMutation, } = connectedServicesApi; diff --git a/client/src/features/connectedServices/connectedServices.types.ts b/client/src/features/connectedServices/connectedServices.types.ts index b932de1f2b..3e97d537d5 100644 --- a/client/src/features/connectedServices/connectedServices.types.ts +++ b/client/src/features/connectedServices/connectedServices.types.ts @@ -18,8 +18,13 @@ export interface Provider { id: string; + kind: string; + client_id: string; + client_secret: string; display_name: string; + scope: string; url: string; + use_pkce: boolean; } export type ProviderList = Provider[]; @@ -42,3 +47,27 @@ export interface ConnectedAccount { export interface GetConnectedAccountParams { connectionId: string; } + +export interface CreateProviderParams { + id: string; + kind: string; + client_id: string; + client_secret?: string; + display_name: string; + scope?: string; + url: string; + use_pkce: boolean; +} + +export interface UpdateProviderParams { + id: string; + kind?: string; + client_id?: string; + client_secret?: string; + display_name?: string; + scope?: string; + url?: string; + use_pkce?: boolean; +} + +export type ConnectedServiceForm = Provider;