From b3c8db6f469700f2ef10072778e0afcb8a5cd5ab Mon Sep 17 00:00:00 2001 From: Flora Thiebaut Date: Tue, 17 Dec 2024 13:19:03 +0100 Subject: [PATCH] feat!: add support for session secrets in Renku 2.0 projects (#3413) Closes #3412. Details: * Add support for session secrets slots and session secrets in Renku 2.0 projects. * Ask logged-in users to provide missing session secrets at launch. * Re-work user secrets: - Split the "user secrets" page into v1 and v2 versions. * `/secrets` lists secrets usable in Renku 1.0. * `/v2/secrets` lists secrets usable in Renku 2.0 (all secrets). - The `name` field does not have constraints, e.g. a secret can have "My Secret" as a name. - A new field `default_filename` has been added for Renku 1.0 sessions. - Secrets list the connected session secret slot and data connectors. * Update the "Session Secrets" section to allow for the mount location to be configured. * Polish the session secrets feature: - Launch interrupt for anon users - Re-designed user secrets page v2 - Updated UI for session secrets and session secret slots - Ask for secret value after creating a new secret slot --- client/.eslintrc.json | 1 + .../modal/ScrollableModal.module.scss | 3 + .../modal/ScrollableModal.tsx} | 36 +- client/src/components/navbar/NavBarItems.tsx | 19 +- .../ProjectPageContainer.tsx | 11 +- .../SessionSecrets/AddSessionSecretButton.tsx | 226 +++++++ .../SessionSecrets/ProjectSessionSecrets.tsx | 186 +++++ .../ProvideSessionSecretModalContent.tsx | 318 +++++++++ .../SecretsMountDirectoryComponent.tsx | 209 ++++++ .../SessionSecrets/SessionSecretActions.tsx | 567 ++++++++++++++++ .../SessionSecrets/SessionSecretSlotItem.tsx | 117 ++++ .../SessionViewSessionSecrets.tsx | 151 +++++ .../fields/DescriptionField.tsx | 59 ++ .../SessionSecrets/fields/FilenameField.tsx | 89 +++ .../SessionSecrets/fields/NameField.tsx | 55 ++ .../fields/SelectUserSecretField.module.scss | 10 + .../fields/SelectUserSecretField.tsx | 200 ++++++ .../SessionSecrets/fields/fields.types.ts | 28 + .../sessionSecrets.constants.ts | 19 + .../SessionSecrets/sessionSecrets.types.ts | 24 + .../SessionSecrets/sessionSecrets.utils.ts | 43 ++ .../Settings/ProjectSettings.tsx | 2 + .../utils/useProjectPermissions.hook.ts | 22 +- .../components/DataConnectorActions.tsx | 4 +- .../components/DataConnectorModal/index.tsx | 4 +- .../components/DataConnectorView.tsx | 11 +- .../DataConnectorsBoxListDisplay.tsx | 17 +- .../utils/useDataConnectorPermissions.hook.ts | 6 +- .../features/projectsV2/api/projectV2.api.ts | 238 ++++++- .../projectsV2/api/projectV2.enhanced-api.ts | 142 +++- .../projectsV2/api/projectV2.openapi.json | 637 +++++++++++++++++- .../fields/SecretsMountDirectoryField.tsx | 79 +++ client/src/features/rootV2/NavbarV2.tsx | 2 +- client/src/features/rootV2/RootV2.tsx | 15 +- .../src/features/secrets/GeneralSecretNew.tsx | 115 ++-- client/src/features/secrets/SecretDelete.tsx | 98 --- client/src/features/secrets/SecretEdit.tsx | 176 ----- client/src/features/secrets/Secrets.tsx | 31 +- client/src/features/secrets/SecretsList.tsx | 24 +- .../src/features/secrets/SecretsListItem.tsx | 31 +- .../features/secrets/StorageSecretsList.tsx | 80 --- .../src/features/secrets/secrets.api.old.ts | 99 --- ...{secrets.utils.ts => secrets.constants.ts} | 11 - .../secretsV2/DataConnectorSecretItem.tsx | 260 +++++++ .../features/secretsV2/GeneralSecretItem.tsx | 227 +++++++ .../src/features/secretsV2/LazySecretsV2.tsx | 31 + .../secretsV2/ReplaceSecretValueModal.tsx | 185 +++++ .../features/secretsV2/SecretItemActions.tsx | 316 +++++++++ client/src/features/secretsV2/SecretsV2.tsx | 267 ++++++++ .../secretsV2/fields/FilenameField.tsx | 70 ++ .../features/secretsV2/fields/NameField.tsx | 62 ++ .../secretsV2/fields/SecretValueField.tsx | 76 +++ .../features/secretsV2/fields/fields.types.ts | 29 + .../src/features/secretsV2/secrets.utils.ts | 25 + .../secretsV2/useGetRelatedProjects.hook.ts | 74 ++ .../components/options/SessionUserSecrets.tsx | 2 +- .../session/startSessionOptions.types.ts | 3 +- .../sessionsV2/DataConnectorSecretsModal.tsx | 4 +- .../sessionsV2/SessionSecretsModal.tsx | 471 +++++++++++++ .../SessionShowPage/ShowSessionPage.tsx | 6 +- .../features/sessionsV2/SessionStartPage.tsx | 109 ++- .../sessionsV2/SessionView/SessionView.tsx | 5 +- .../SessionModals/NewSessionLauncherModal.tsx | 4 +- .../sessionsV2/startSessionOptionsV2.slice.ts | 4 + .../sessionsV2/startSessionOptionsV2.types.ts | 1 + .../sessionsV2/useSessionLaunchState.hook.ts | 8 +- .../sessionsV2/useSessionSecrets.hook.ts | 81 +++ client/src/features/usersV2/api/users.api.ts | 19 +- .../usersV2/api/users.generated-api.ts | 10 +- .../features/usersV2/api/users.openapi.json | 55 +- client/src/routing/routes.constants.ts | 5 +- .../utils/customHooks/useLocationHash.hook.ts | 6 +- tests/cypress/e2e/projectV2Session.spec.ts | 207 +++++- .../e2e/projectV2SessionSecrets.spec.ts | 393 +++++++++++ tests/cypress/e2e/secrets.spec.ts | 54 +- .../fixtures/dataConnector/empty-list.json | 1 + .../fixtures/projectV2/create-projectV2.json | 3 +- .../fixtures/projectV2/list-projectV2.json | 6 +- .../projectV2/read-projectV2-empty.json | 3 +- .../fixtures/projectV2/read-projectV2.json | 3 +- .../projectV2/update-projectV2-metadata.json | 3 +- .../update-projectV2-one-repository.json | 3 +- .../update-projectV2-repositories.json | 3 +- .../projectV2SessionSecrets/empty_list.json | 1 + .../patch_secret_slot.json | 7 + .../patch_secrets_clear_value.json | 1 + .../patch_secrets_with_new_value.json | 12 + .../patched_secret_slots.json | 16 + .../patched_secrets_with_cleared_value.json | 1 + .../patched_secrets_with_new_value.json | 22 + .../post_secret_slot.json | 8 + .../projectV2SessionSecrets/secret_slots.json | 16 + .../secret_slots_with_new_slot.json | 24 + .../projectV2SessionSecrets/secrets.json | 12 + .../shortened_secret_slots.json | 9 + .../projectV2SessionSecrets/user_secrets.json | 8 + .../support/renkulab-fixtures/index.ts | 20 +- .../support/renkulab-fixtures/projectV2.ts | 78 +++ .../support/renkulab-fixtures/secrets.ts | 9 +- 99 files changed, 6768 insertions(+), 785 deletions(-) create mode 100644 client/src/components/modal/ScrollableModal.module.scss rename client/src/{features/secrets/secrets.types.ts => components/modal/ScrollableModal.tsx} (61%) create mode 100644 client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/AddSessionSecretButton.tsx create mode 100644 client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/ProjectSessionSecrets.tsx create mode 100644 client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/ProvideSessionSecretModalContent.tsx create mode 100644 client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/SecretsMountDirectoryComponent.tsx create mode 100644 client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/SessionSecretActions.tsx create mode 100644 client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/SessionSecretSlotItem.tsx create mode 100644 client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/SessionViewSessionSecrets.tsx create mode 100644 client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/fields/DescriptionField.tsx create mode 100644 client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/fields/FilenameField.tsx create mode 100644 client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/fields/NameField.tsx create mode 100644 client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/fields/SelectUserSecretField.module.scss create mode 100644 client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/fields/SelectUserSecretField.tsx create mode 100644 client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/fields/fields.types.ts create mode 100644 client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/sessionSecrets.constants.ts create mode 100644 client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/sessionSecrets.types.ts create mode 100644 client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/sessionSecrets.utils.ts create mode 100644 client/src/features/projectsV2/fields/SecretsMountDirectoryField.tsx delete mode 100644 client/src/features/secrets/SecretDelete.tsx delete mode 100644 client/src/features/secrets/SecretEdit.tsx delete mode 100644 client/src/features/secrets/StorageSecretsList.tsx delete mode 100644 client/src/features/secrets/secrets.api.old.ts rename client/src/features/secrets/{secrets.utils.ts => secrets.constants.ts} (73%) create mode 100644 client/src/features/secretsV2/DataConnectorSecretItem.tsx create mode 100644 client/src/features/secretsV2/GeneralSecretItem.tsx create mode 100644 client/src/features/secretsV2/LazySecretsV2.tsx create mode 100644 client/src/features/secretsV2/ReplaceSecretValueModal.tsx create mode 100644 client/src/features/secretsV2/SecretItemActions.tsx create mode 100644 client/src/features/secretsV2/SecretsV2.tsx create mode 100644 client/src/features/secretsV2/fields/FilenameField.tsx create mode 100644 client/src/features/secretsV2/fields/NameField.tsx create mode 100644 client/src/features/secretsV2/fields/SecretValueField.tsx create mode 100644 client/src/features/secretsV2/fields/fields.types.ts create mode 100644 client/src/features/secretsV2/secrets.utils.ts create mode 100644 client/src/features/secretsV2/useGetRelatedProjects.hook.ts create mode 100644 client/src/features/sessionsV2/SessionSecretsModal.tsx create mode 100644 client/src/features/sessionsV2/useSessionSecrets.hook.ts create mode 100644 tests/cypress/e2e/projectV2SessionSecrets.spec.ts create mode 100644 tests/cypress/fixtures/dataConnector/empty-list.json create mode 100644 tests/cypress/fixtures/projectV2SessionSecrets/empty_list.json create mode 100644 tests/cypress/fixtures/projectV2SessionSecrets/patch_secret_slot.json create mode 100644 tests/cypress/fixtures/projectV2SessionSecrets/patch_secrets_clear_value.json create mode 100644 tests/cypress/fixtures/projectV2SessionSecrets/patch_secrets_with_new_value.json create mode 100644 tests/cypress/fixtures/projectV2SessionSecrets/patched_secret_slots.json create mode 100644 tests/cypress/fixtures/projectV2SessionSecrets/patched_secrets_with_cleared_value.json create mode 100644 tests/cypress/fixtures/projectV2SessionSecrets/patched_secrets_with_new_value.json create mode 100644 tests/cypress/fixtures/projectV2SessionSecrets/post_secret_slot.json create mode 100644 tests/cypress/fixtures/projectV2SessionSecrets/secret_slots.json create mode 100644 tests/cypress/fixtures/projectV2SessionSecrets/secret_slots_with_new_slot.json create mode 100644 tests/cypress/fixtures/projectV2SessionSecrets/secrets.json create mode 100644 tests/cypress/fixtures/projectV2SessionSecrets/shortened_secret_slots.json create mode 100644 tests/cypress/fixtures/projectV2SessionSecrets/user_secrets.json diff --git a/client/.eslintrc.json b/client/.eslintrc.json index 5dedafd308..544d011637 100644 --- a/client/.eslintrc.json +++ b/client/.eslintrc.json @@ -243,6 +243,7 @@ "rproj", "rstudio", "scala", + "scrollable", "selectautosuggest", "semibold", "serializable", diff --git a/client/src/components/modal/ScrollableModal.module.scss b/client/src/components/modal/ScrollableModal.module.scss new file mode 100644 index 0000000000..ec983852a8 --- /dev/null +++ b/client/src/components/modal/ScrollableModal.module.scss @@ -0,0 +1,3 @@ +.modal :global(.modal-content) { + height: unset !important; +} diff --git a/client/src/features/secrets/secrets.types.ts b/client/src/components/modal/ScrollableModal.tsx similarity index 61% rename from client/src/features/secrets/secrets.types.ts rename to client/src/components/modal/ScrollableModal.tsx index 52cad92a12..6d62e150b3 100644 --- a/client/src/features/secrets/secrets.types.ts +++ b/client/src/components/modal/ScrollableModal.tsx @@ -16,32 +16,18 @@ * limitations under the License. */ -export interface SecretDetails { - id: string; - modification_date: string; - name: string; - kind: SecretKind; -} - -export interface AddSecretParams { - name: string; - value: string; - kind: SecretKind; -} +import cx from "classnames"; +import { Modal, type ModalProps } from "reactstrap"; -export type AddSecretForm = AddSecretParams; +import styles from "./ScrollableModal.module.scss"; -export interface EditSecretForm { - value: string; -} +type ScrollableModalProps = Omit; -export interface EditSecretParams { - id: string; - value: string; +export default function ScrollableModal({ + className, + ...props +}: ScrollableModalProps) { + return ( + + ); } - -export interface GetSecretsParams { - kind: SecretKind; -} - -export type SecretKind = "general" | "storage"; diff --git a/client/src/components/navbar/NavBarItems.tsx b/client/src/components/navbar/NavBarItems.tsx index 5f43803ed4..4e0bf67678 100644 --- a/client/src/components/navbar/NavBarItems.tsx +++ b/client/src/components/navbar/NavBarItems.tsx @@ -50,6 +50,7 @@ import { Loader } from "../Loader"; import BootstrapGitLabIcon from "../icons/BootstrapGitLabIcon"; import styles from "./NavBarItem.module.scss"; +import { ABSOLUTE_ROUTES } from "../../routing/routes.constants"; export function RenkuToolbarItemPlus() { const location = useLocation(); @@ -280,10 +281,14 @@ export function RenkuToolbarNotifications({ } interface RenkuToolbarItemUserProps { + isV2?: boolean; params: AppParams; } -export function RenkuToolbarItemUser({ params }: RenkuToolbarItemUserProps) { +export function RenkuToolbarItemUser({ + isV2, + params, +}: RenkuToolbarItemUserProps) { const user = useLegacySelector((state) => state.stateModel.user); const { renku10Enabled } = useAppSelector(({ featureFlags }) => featureFlags); @@ -304,6 +309,8 @@ export function RenkuToolbarItemUser({ params }: RenkuToolbarItemUserProps) { ); } + const userSecretsUrl = isV2 ? ABSOLUTE_ROUTES.v2.secrets : "/secrets"; + return ( - + User Secrets @@ -340,10 +347,14 @@ export function RenkuToolbarItemUser({ params }: RenkuToolbarItemUserProps) { {renku10Enabled && ( <> - + + Renku 2.0 - + Renku 2.0 Settings diff --git a/client/src/features/ProjectPageV2/ProjectPageContainer/ProjectPageContainer.tsx b/client/src/features/ProjectPageV2/ProjectPageContainer/ProjectPageContainer.tsx index eb9d1f845a..f8d2c88f93 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContainer/ProjectPageContainer.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContainer/ProjectPageContainer.tsx @@ -26,7 +26,7 @@ import { Col, Row } from "reactstrap"; import { Loader } from "../../../components/Loader"; import ContainerWrap from "../../../components/container/ContainerWrap"; import type { Project } from "../../projectsV2/api/projectV2.api"; -import { useGetProjectsByNamespaceAndSlugQuery } from "../../projectsV2/api/projectV2.api"; +import { useGetNamespacesByNamespaceProjectsAndSlugQuery } from "../../projectsV2/api/projectV2.enhanced-api"; import ProjectNotFound from "../../projectsV2/notFound/ProjectNotFound"; import ProjectPageHeader from "../ProjectPageHeader/ProjectPageHeader"; import ProjectPageNav from "../ProjectPageNav/ProjectPageNav"; @@ -37,10 +37,11 @@ export default function ProjectPageContainer() { namespace: string | undefined; slug: string | undefined; }>(); - const { data, isLoading, error } = useGetProjectsByNamespaceAndSlugQuery({ - namespace: namespace ?? "", - slug: slug ?? "", - }); + const { data, isLoading, error } = + useGetNamespacesByNamespaceProjectsAndSlugQuery({ + namespace: namespace ?? "", + slug: slug ?? "", + }); if (isLoading) return ; diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/AddSessionSecretButton.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/AddSessionSecretButton.tsx new file mode 100644 index 0000000000..0bab38c00e --- /dev/null +++ b/client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/AddSessionSecretButton.tsx @@ -0,0 +1,226 @@ +/*! + * 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, useMemo, useRef, useState } from "react"; +import { PlusLg, XLg } from "react-bootstrap-icons"; +import { useForm } from "react-hook-form"; +import { + Button, + Form, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + UncontrolledTooltip, +} from "reactstrap"; + +import { Loader } from "../../../../components/Loader"; +import { RtkOrNotebooksError } from "../../../../components/errors/RtkErrorAlert"; +import { usePostSessionSecretSlotsMutation } from "../../../projectsV2/api/projectV2.enhanced-api"; +import { useProject } from "../../ProjectPageContainer/ProjectPageContainer"; +import DescriptionField from "./fields/DescriptionField"; +import type { SessionSecretSlot } from "../../../projectsV2/api/projectV2.api"; +import FilenameField from "./fields/FilenameField"; +import NameField from "./fields/NameField"; +import ProvideSessionSecretModalContent from "./ProvideSessionSecretModalContent"; +import { SuccessAlert } from "../../../../components/Alert"; + +export default function AddSessionSecretButton() { + const ref = useRef(null); + + const [isOpen, setIsOpen] = useState(false); + const toggle = useCallback(() => setIsOpen((isOpen) => !isOpen), []); + + return ( + <> + + + + Add session secret slot + + + ); +} + +interface AddSessionSecretModalProps { + isOpen: boolean; + toggle: () => void; +} + +function AddSessionSecretModal({ isOpen, toggle }: AddSessionSecretModalProps) { + const [state, setState] = useState({ + step: "add-secret-slot", + }); + const { step } = state; + const onFirstStepSuccess = useCallback( + (secretSlot: SessionSecretSlot) => + setState({ step: "provide-secret", secretSlot }), + [] + ); + + useEffect(() => { + if (!isOpen) { + setState({ step: "add-secret-slot" }); + } + }, [isOpen]); + + const slotSavedAlert = step === "provide-secret" && ( + + The session secret slot{" "} + {state.secretSlot.name} has been + successfully added. You can now provide a value for it. + + ); + + return ( + + {step === "add-secret-slot" && ( + + )} + {step === "provide-secret" && ( + + )} + + ); +} + +type AddSessionSecretModalState = + | { step: "add-secret-slot" } + | { step: "provide-secret"; secretSlot: SessionSecretSlot }; + +interface AddSessionSecretModalContentStep1Props + extends AddSessionSecretModalProps { + onSuccess: (secretSlot: SessionSecretSlot) => void; +} + +function AddSessionSecretModalContentStep1({ + isOpen, + onSuccess, + toggle, +}: AddSessionSecretModalContentStep1Props) { + const { project } = useProject(); + const { id: projectId, secrets_mount_directory: secretsMountDirectory } = + project; + + const [postSessionSecretSlot, result] = usePostSessionSecretSlotsMutation(); + + const { + control, + formState: { errors }, + handleSubmit, + reset, + } = useForm({ + defaultValues: { + description: "", + filename: "", + name: "", + }, + }); + + const submitHandler = useCallback( + (data: AddSessionSecretForm) => { + const description = data.description?.trim(); + const name = data.name?.trim(); + postSessionSecretSlot({ + sessionSecretSlotPost: { + filename: data.filename, + project_id: projectId, + description: description ? description : undefined, + name: name ? name : undefined, + }, + }); + }, + [postSessionSecretSlot, projectId] + ); + const onSubmit = useMemo( + () => handleSubmit(submitHandler), + [handleSubmit, submitHandler] + ); + + useEffect(() => { + if (!isOpen) { + reset(); + result.reset(); + } + }, [isOpen, reset, result]); + + useEffect(() => { + if (result.isSuccess) { + onSuccess(result.data); + } + }, [onSuccess, result.data, result.isSuccess]); + + return ( +
+ Add session secret slot + +

Add a new slot for a secret to be mounted in sessions.

+ + {result.error && ( + + )} + + + + +
+ + + + +
+ ); +} + +interface AddSessionSecretForm { + name: string | undefined; + description: string | undefined; + filename: string; +} diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/ProjectSessionSecrets.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/ProjectSessionSecrets.tsx new file mode 100644 index 0000000000..4dbb4bccd9 --- /dev/null +++ b/client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/ProjectSessionSecrets.tsx @@ -0,0 +1,186 @@ +/*! + * 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 { skipToken } from "@reduxjs/toolkit/query"; +import cx from "classnames"; +import { useEffect, useMemo, useRef } from "react"; +import { ShieldLock } from "react-bootstrap-icons"; +import { Badge, Card, CardBody, CardHeader, ListGroup } from "reactstrap"; + +import { InfoAlert } from "../../../../components/Alert"; +import { RtkOrNotebooksError } from "../../../../components/errors/RtkErrorAlert"; +import { Loader } from "../../../../components/Loader"; +import useLegacySelector from "../../../../utils/customHooks/useLegacySelector.hook"; +import useLocationHash from "../../../../utils/customHooks/useLocationHash.hook"; +import PermissionsGuard from "../../../permissionsV2/PermissionsGuard"; +import type { + SessionSecret, + SessionSecretSlot, +} from "../../../projectsV2/api/projectV2.api"; +import { + useGetProjectsByProjectIdSessionSecretSlotsQuery, + useGetProjectsByProjectIdSessionSecretsQuery, +} from "../../../projectsV2/api/projectV2.enhanced-api"; +import { useProject } from "../../ProjectPageContainer/ProjectPageContainer"; +import useProjectPermissions from "../../utils/useProjectPermissions.hook"; +import AddSessionSecretButton from "./AddSessionSecretButton"; +import SecretsMountDirectoryComponent from "./SecretsMountDirectoryComponent"; +import { SESSION_SECRETS_CARD_ID } from "./sessionSecrets.constants"; +import { getSessionSecretSlotsWithSecrets } from "./sessionSecrets.utils"; +import SessionSecretSlotItem from "./SessionSecretSlotItem"; + +export default function ProjectSessionSecrets() { + const userLogged = useLegacySelector( + (state) => state.stateModel.user.logged + ); + + const { project } = useProject(); + const { id: projectId, secrets_mount_directory: secretsMountDirectory } = + project; + const permissions = useProjectPermissions({ projectId }); + const { + data: sessionSecretSlots, + isLoading: isLoadingSessionSecretSlots, + error: sessionSecretSlotsError, + } = useGetProjectsByProjectIdSessionSecretSlotsQuery({ projectId }); + const { + data: sessionSecrets, + isLoading: isLoadingSessionSecrets, + error: sessionSecretsError, + } = useGetProjectsByProjectIdSessionSecretsQuery( + userLogged ? { projectId } : skipToken + ); + const isLoading = isLoadingSessionSecretSlots || isLoadingSessionSecrets; + const error = sessionSecretSlotsError ?? sessionSecretsError; + + const content = isLoading ? ( + + ) : error || !sessionSecretSlots || (userLogged && !sessionSecrets) ? ( + <> +

Error: could not load this project's session secrets.

+ {error && } + + ) : ( + + ); + + const ref = useRef(null); + const [hash] = useLocationHash(); + useEffect(() => { + if (hash === SESSION_SECRETS_CARD_ID && !isLoading) { + ref.current?.scrollIntoView({ behavior: "smooth" }); + } + }, [hash, isLoading]); + + return ( + + +
+
+

+ + Session secret slots +

+ {sessionSecretSlots && {sessionSecretSlots.length}} +
+ +
+ } + requestedPermission="write" + userPermissions={permissions} + /> +
+
+

+ Use session secrets to connect to resources from inside a session that + require a password or credential. +

+

+ Session secrets will be mounted at the following location: +

+ + + {!userLogged && ( + +

+ As an anonymous user, you cannot use session secrets. +

+
+ )} +
+ + {content} + +
+ ); +} + +interface ProjectSessionSecretsContentProps { + secretsMountDirectory: string; + sessionSecretSlots: SessionSecretSlot[]; + sessionSecrets: SessionSecret[]; +} + +function ProjectSessionSecretsContent({ + secretsMountDirectory, + sessionSecretSlots, + sessionSecrets, +}: ProjectSessionSecretsContentProps) { + const sessionSecretSlotsWithSecrets = useMemo( + () => + getSessionSecretSlotsWithSecrets({ sessionSecretSlots, sessionSecrets }), + [sessionSecretSlots, sessionSecrets] + ); + + if (!sessionSecretSlots.length) { + return null; + } + + return ( + + {sessionSecretSlotsWithSecrets.map((secretSlot) => ( + + ))} + + ); +} diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/ProvideSessionSecretModalContent.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/ProvideSessionSecretModalContent.tsx new file mode 100644 index 0000000000..19063bb17b --- /dev/null +++ b/client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/ProvideSessionSecretModalContent.tsx @@ -0,0 +1,318 @@ +/*! + * 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 { ReactNode, useCallback, useEffect, useMemo, useState } from "react"; +import { BoxArrowInLeft, PlusLg, XLg } from "react-bootstrap-icons"; +import { Controller, useForm } from "react-hook-form"; +import { + Button, + ButtonGroup, + Form, + Input, + Label, + ModalBody, + ModalFooter, + ModalHeader, +} from "reactstrap"; + +import { RtkOrNotebooksError } from "../../../../components/errors/RtkErrorAlert"; +import { Loader } from "../../../../components/Loader"; +import type { SessionSecretSlot } from "../../../projectsV2/api/projectV2.api"; +import { usePatchProjectsByProjectIdSessionSecretsMutation } from "../../../projectsV2/api/projectV2.enhanced-api"; +import SelectUserSecretField from "./fields/SelectUserSecretField"; + +interface ProvideSessionSecretModalContentProps { + isOpen: boolean; + previousStepAlert?: ReactNode; + secretSlot: SessionSecretSlot; + toggle: () => void; +} + +export default function ProvideSessionSecretModalContent({ + isOpen, + previousStepAlert, + secretSlot, + toggle, +}: ProvideSessionSecretModalContentProps) { + const [mode, setMode] = useState<"new-value" | "existing">("new-value"); + + useEffect(() => { + if (!isOpen) { + setMode("new-value"); + } + }, [isOpen]); + + return ( + <> + Provide session secret + + {previousStepAlert} + + + setMode("new-value")} + /> + + setMode("existing")} + /> + + + + {mode === "new-value" ? ( + + ) : ( + + )} + + ); +} + +interface ProvideSessionSecretModalNewValueContentProps { + isOpen: boolean; + secretSlot: SessionSecretSlot; + toggle: () => void; +} + +function ProvideSessionSecretModalNewValueContent({ + isOpen, + secretSlot, + toggle, +}: ProvideSessionSecretModalNewValueContentProps) { + const { id: slotId, project_id: projectId } = secretSlot; + + const [patchSessionSecrets, result] = + usePatchProjectsByProjectIdSessionSecretsMutation(); + + const { + control, + formState: { errors }, + handleSubmit, + reset, + } = useForm({ + defaultValues: { value: "" }, + }); + + const submitHandler = useCallback( + (data: ProvideNewSecretValueForm) => { + patchSessionSecrets({ + projectId, + sessionSecretPatchList: [{ secret_slot_id: slotId, value: data.value }], + }); + }, + [patchSessionSecrets, projectId, slotId] + ); + const onSubmit = useMemo( + () => handleSubmit(submitHandler), + [handleSubmit, submitHandler] + ); + + useEffect(() => { + reset({ value: "" }); + }, [reset, secretSlot]); + + useEffect(() => { + if (!isOpen) { + reset(); + result.reset(); + } + }, [isOpen, reset, result]); + + useEffect(() => { + if (result.isSuccess) { + toggle(); + } + }, [result.isSuccess, toggle]); + + return ( +
+ + {result.error && ( + + )} + +
+ + ( +