Skip to content

Commit

Permalink
feat!: add support for session secrets in Renku 2.0 projects (#3413)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
leafty authored Dec 17, 2024
1 parent ba2c42d commit b3c8db6
Show file tree
Hide file tree
Showing 99 changed files with 6,768 additions and 785 deletions.
1 change: 1 addition & 0 deletions client/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@
"rproj",
"rstudio",
"scala",
"scrollable",
"selectautosuggest",
"semibold",
"serializable",
Expand Down
3 changes: 3 additions & 0 deletions client/src/components/modal/ScrollableModal.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.modal :global(.modal-content) {
height: unset !important;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<ModalProps, "scrollable">;

export interface EditSecretParams {
id: string;
value: string;
export default function ScrollableModal({
className,
...props
}: ScrollableModalProps) {
return (
<Modal className={cx(className, styles.modal)} scrollable {...props} />
);
}

export interface GetSecretsParams {
kind: SecretKind;
}

export type SecretKind = "general" | "storage";
19 changes: 15 additions & 4 deletions client/src/components/navbar/NavBarItems.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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<User>((state) => state.stateModel.user);

const { renku10Enabled } = useAppSelector(({ featureFlags }) => featureFlags);
Expand All @@ -304,6 +309,8 @@ export function RenkuToolbarItemUser({ params }: RenkuToolbarItemUserProps) {
);
}

const userSecretsUrl = isV2 ? ABSOLUTE_ROUTES.v2.secrets : "/secrets";

return (
<UncontrolledDropdown className="nav-item dropdown">
<DropdownToggle
Expand Down Expand Up @@ -332,18 +339,22 @@ export function RenkuToolbarItemUser({ params }: RenkuToolbarItemUserProps) {
/>
</DropdownItem>

<Link to="/secrets" className="dropdown-item">
<Link to={userSecretsUrl} className="dropdown-item">
User Secrets
</Link>

<AdminDropdownItem />

{renku10Enabled && (
<>
<Link to="/v2/" className="dropdown-item">
<DropdownItem divider />
<Link to={ABSOLUTE_ROUTES.v2.root} className="dropdown-item">
Renku 2.0
</Link>
<Link to="/v2/connected-services" className="dropdown-item">
<Link
to={ABSOLUTE_ROUTES.v2.connectedServices}
className="dropdown-item"
>
Renku 2.0 Settings
</Link>
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 <Loader className="align-self-center" />;

Expand Down
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>(null);

const [isOpen, setIsOpen] = useState(false);
const toggle = useCallback(() => setIsOpen((isOpen) => !isOpen), []);

return (
<>
<Button color="outline-primary" innerRef={ref} onClick={toggle} size="sm">
<PlusLg className="bi" />
<span className="visually-hidden">Add session secret slot</span>
</Button>
<AddSessionSecretModal isOpen={isOpen} toggle={toggle} />
<UncontrolledTooltip target={ref}>
Add session secret slot
</UncontrolledTooltip>
</>
);
}

interface AddSessionSecretModalProps {
isOpen: boolean;
toggle: () => void;
}

function AddSessionSecretModal({ isOpen, toggle }: AddSessionSecretModalProps) {
const [state, setState] = useState<AddSessionSecretModalState>({
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" && (
<SuccessAlert timeout={0} dismissible={false}>
The session secret slot{" "}
<span className="fw-bold">{state.secretSlot.name}</span> has been
successfully added. You can now provide a value for it.
</SuccessAlert>
);

return (
<Modal backdrop="static" centered isOpen={isOpen} size="lg" toggle={toggle}>
{step === "add-secret-slot" && (
<AddSessionSecretModalContentStep1
isOpen={isOpen}
onSuccess={onFirstStepSuccess}
toggle={toggle}
/>
)}
{step === "provide-secret" && (
<ProvideSessionSecretModalContent
isOpen={isOpen}
previousStepAlert={slotSavedAlert}
secretSlot={state.secretSlot}
toggle={toggle}
/>
)}
</Modal>
);
}

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<AddSessionSecretForm>({
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 (
<Form noValidate onSubmit={onSubmit}>
<ModalHeader toggle={toggle}>Add session secret slot</ModalHeader>
<ModalBody>
<p>Add a new slot for a secret to be mounted in sessions.</p>

{result.error && (
<RtkOrNotebooksError error={result.error} dismissible={false} />
)}

<NameField control={control} errors={errors} name="name" />
<DescriptionField
control={control}
errors={errors}
name="description"
/>
<FilenameField
control={control}
errors={errors}
name="filename"
secretsMountDirectory={secretsMountDirectory}
/>
</ModalBody>
<ModalFooter>
<Button color="outline-primary" onClick={toggle}>
<XLg className={cx("bi", "me-1")} />
Close
</Button>
<Button color="primary" disabled={result.isLoading} type="submit">
{result.isLoading ? (
<Loader className="me-1" inline size={16} />
) : (
<PlusLg className={cx("bi", "me-1")} />
)}
Add session secret slot
</Button>
</ModalFooter>
</Form>
);
}

interface AddSessionSecretForm {
name: string | undefined;
description: string | undefined;
filename: string;
}
Loading

0 comments on commit b3c8db6

Please sign in to comment.