diff --git a/workspaces/frontend/scripts/swagger.version b/workspaces/frontend/scripts/swagger.version index 21ab894be..1b0720f87 100644 --- a/workspaces/frontend/scripts/swagger.version +++ b/workspaces/frontend/scripts/swagger.version @@ -1 +1 @@ -4f0a29dec0d3c9f0d0f02caab4dc84101bfef8b0 +be67e887ad7396cf0078edca36201564a208d1b7 diff --git a/workspaces/frontend/src/app/pages/Workspaces/Form/helpers.ts b/workspaces/frontend/src/app/pages/Workspaces/Form/helpers.ts new file mode 100644 index 000000000..9263694a8 --- /dev/null +++ b/workspaces/frontend/src/app/pages/Workspaces/Form/helpers.ts @@ -0,0 +1,7 @@ +export const isValidDefaultMode = (mode: string): boolean => { + if (mode.length !== 3) { + return false; + } + const permissions = ['0', '4', '5', '6', '7']; + return Array.from(mode).every((char) => permissions.includes(char)); +}; diff --git a/workspaces/frontend/src/app/pages/Workspaces/Form/properties/WorkspaceFormPropertiesSecrets.tsx b/workspaces/frontend/src/app/pages/Workspaces/Form/properties/WorkspaceFormPropertiesSecrets.tsx index 66e07ab46..f61ae359c 100644 --- a/workspaces/frontend/src/app/pages/Workspaces/Form/properties/WorkspaceFormPropertiesSecrets.tsx +++ b/workspaces/frontend/src/app/pages/Workspaces/Form/properties/WorkspaceFormPropertiesSecrets.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { EllipsisVIcon } from '@patternfly/react-icons/dist/esm/icons/ellipsis-v-icon'; import { Table, @@ -12,19 +12,18 @@ import { import { Button } from '@patternfly/react-core/dist/esm/components/Button'; import { Modal, - ModalBody, ModalFooter, ModalHeader, ModalVariant, } from '@patternfly/react-core/dist/esm/components/Modal'; -import { ValidatedOptions } from '@patternfly/react-core/helpers'; -import { TextInput } from '@patternfly/react-core/dist/esm/components/TextInput'; import { Dropdown, DropdownItem } from '@patternfly/react-core/dist/esm/components/Dropdown'; import { MenuToggle } from '@patternfly/react-core/dist/esm/components/MenuToggle'; -import { Form, FormGroup } from '@patternfly/react-core/dist/esm/components/Form'; -import { HelperText, HelperTextItem } from '@patternfly/react-core/dist/esm/components/HelperText'; -import { PlusCircleIcon } from '@patternfly/react-icons/dist/esm/icons/plus-circle-icon'; -import { WorkspacesPodSecretMount } from '~/generated/data-contracts'; +import { SecretsSecretListItem, WorkspacesPodSecretMount } from '~/generated/data-contracts'; +import { useNotebookAPI } from '~/app/hooks/useNotebookAPI'; +import { useNamespaceContext } from '~/app/context/NamespaceContextProvider'; +import { SecretsAttachModal } from './secrets/SecretsAttachModal'; +import { SecretsCreateModal } from './secrets/SecretsCreateModal'; +import { SecretsViewPopover } from './secrets/SecretsViewPopover'; interface WorkspaceFormPropertiesSecretsProps { secrets: WorkspacesPodSecretMount[]; @@ -37,18 +36,29 @@ export const WorkspaceFormPropertiesSecrets: React.FC { - const [isModalOpen, setIsModalOpen] = useState(false); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [isAttachModalOpen, setIsAttachModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const [formData, setFormData] = useState({ - secretName: '', - mountPath: '', - defaultMode: parseInt(DEFAULT_MODE_OCTAL, 8), - }); + const [editingSecret, setEditingSecret] = useState( + undefined, + ); const [editIndex, setEditIndex] = useState(null); - const [defaultMode, setDefaultMode] = useState(DEFAULT_MODE_OCTAL); const [deleteIndex, setDeleteIndex] = useState(null); - const [isDefaultModeValid, setIsDefaultModeValid] = useState(true); const [dropdownOpen, setDropdownOpen] = useState(null); + const [availableSecrets, setAvailableSecrets] = useState([]); + const [attachedSecrets, setAttachedSecrets] = useState([]); + const [attachedMountPath, setAttachedMountPath] = useState(''); + const [attachedDefaultMode, setAttachedDefaultMode] = useState(DEFAULT_MODE_OCTAL); + const { api } = useNotebookAPI(); + const { selectedNamespace } = useNamespaceContext(); + + useEffect(() => { + const fetchSecrets = async () => { + const secretsResponse = await api.secrets.listSecrets(selectedNamespace); + setAvailableSecrets(secretsResponse.data); + }; + fetchSecrets(); + }, [api.secrets, selectedNamespace]); const openDeleteModal = useCallback((i: number) => { setIsDeleteModalOpen(true); @@ -57,62 +67,86 @@ export const WorkspaceFormPropertiesSecrets: React.FC { - setFormData(secrets[index]); - setDefaultMode(secrets[index].defaultMode?.toString(8) ?? DEFAULT_MODE_OCTAL); + setEditingSecret(secrets[index]); setEditIndex(index); - setIsModalOpen(true); + setIsCreateModalOpen(true); }, [secrets], ); - const handleDefaultModeInput = useCallback( - (val: string) => { - if (val.length <= 3) { - // 0 no permissions, 4 read only, 5 read + execute, 6 read + write, 7 all permissions - setDefaultMode(val); - const permissions = ['0', '4', '5', '6', '7']; - const isValid = Array.from(val).every((char) => permissions.includes(char)); - if (val.length < 3 || !isValid) { - setIsDefaultModeValid(false); - } else { - setIsDefaultModeValid(true); - } - const decimalVal = parseInt(val, 8); - setFormData({ ...formData, defaultMode: decimalVal }); + const handleAttachSecrets = useCallback( + (newSecrets: SecretsSecretListItem[], mountPath: string, mode: number) => { + const newAttachedSecrets = newSecrets.map((secret) => ({ + secretName: secret.name, + mountPath, + defaultMode: mode, + })); + const oldAttachedNames = new Set(attachedSecrets.map((s) => s.secretName)); + const secretsWithoutOldAttached = secrets.filter((s) => !oldAttachedNames.has(s.secretName)); + const manualSecretNames = new Set(secretsWithoutOldAttached.map((s) => s.secretName)); + const filteredNewAttached = newAttachedSecrets.filter( + (s) => !manualSecretNames.has(s.secretName), + ); + + // Update both states + setAttachedSecrets(filteredNewAttached); + setSecrets([...secretsWithoutOldAttached, ...filteredNewAttached]); + setAttachedMountPath(mountPath); + setAttachedDefaultMode(mode.toString(8)); + setIsAttachModalOpen(false); + }, + [attachedSecrets, secrets, setSecrets], + ); + + const handleCreateOrEditSubmit = useCallback( + (secret: WorkspacesPodSecretMount) => { + if (editIndex !== null) { + const updated = [...secrets]; + updated[editIndex] = secret; + setSecrets(updated); + } else { + setSecrets([...secrets, secret]); } + setEditingSecret(undefined); + setEditIndex(null); + setIsCreateModalOpen(false); }, - [setFormData, setIsDefaultModeValid, setDefaultMode, formData], + [editIndex, secrets, setSecrets], ); - const clearForm = useCallback(() => { - setFormData({ secretName: '', mountPath: '', defaultMode: 420 }); + const handleCreateModalClose = useCallback(() => { + setEditingSecret(undefined); setEditIndex(null); - setIsModalOpen(false); - setIsDefaultModeValid(true); + setIsCreateModalOpen(false); }, []); - const handleAddOrEditSubmit = useCallback(() => { - if (!formData.secretName || !formData.mountPath) { - return; - } - if (editIndex !== null) { - const updated = [...secrets]; - updated[editIndex] = formData; - setSecrets(updated); - } else { - setSecrets([...secrets, formData]); - } - clearForm(); - }, [clearForm, editIndex, formData, secrets, setSecrets]); + const isAttachedSecret = useCallback( + (secretName: string) => attachedSecrets.some((s) => s.secretName === secretName), + [attachedSecrets], + ); const handleDelete = useCallback(() => { if (deleteIndex === null) { return; } + const secretToDelete = secrets[deleteIndex]; setSecrets(secrets.filter((_, i) => i !== deleteIndex)); + + // If it's an attached secret, also remove from attachedSecrets + if (isAttachedSecret(secretToDelete.secretName)) { + const updatedAttachedSecrets = attachedSecrets.filter( + (s) => s.secretName !== secretToDelete.secretName, + ); + setAttachedSecrets(updatedAttachedSecrets); + if (updatedAttachedSecrets.length === 0) { + setAttachedMountPath(''); + setAttachedDefaultMode(DEFAULT_MODE_OCTAL); + } + } + setDeleteIndex(null); setIsDeleteModalOpen(false); - }, [deleteIndex, secrets, setSecrets]); + }, [deleteIndex, secrets, setSecrets, attachedSecrets, isAttachedSecret]); return ( <> @@ -123,6 +157,7 @@ export const WorkspaceFormPropertiesSecrets: React.FCSecret Name Mount Path Default Mode + @@ -132,6 +167,9 @@ export const WorkspaceFormPropertiesSecrets: React.FC{secret.secretName} {secret.mountPath} {secret.defaultMode?.toString(8) ?? DEFAULT_MODE_OCTAL} + + + ( @@ -149,7 +187,9 @@ export const WorkspaceFormPropertiesSecrets: React.FC setDropdownOpen(null)} popperProps={{ position: 'right' }} > - handleEdit(index)}>Edit + {!isAttachedSecret(secret.secretName) && ( + handleEdit(index)}>Edit + )} openDeleteModal(index)}>Remove @@ -159,79 +199,35 @@ export const WorkspaceFormPropertiesSecrets: React.FC )} + - - - -
- - setFormData({ ...formData, secretName: val })} - id="secret-name" - /> - - - setFormData({ ...formData, mountPath: val })} - id="mount-path" - /> - - - handleDefaultModeInput(val)} - id="default-mode" - /> - {!isDefaultModeValid && ( - - - Must be a valid UNIX file system permission value (i.e. 644) - - - )} - -
-
- - - - -
+ secret.secretName)} + onClose={handleAttachSecrets} + initialMountPath={attachedMountPath} + initialDefaultMode={attachedDefaultMode} + /> + + setIsDeleteModalOpen(false)} diff --git a/workspaces/frontend/src/app/pages/Workspaces/Form/properties/secrets/SecretsAttachModal.tsx b/workspaces/frontend/src/app/pages/Workspaces/Form/properties/secrets/SecretsAttachModal.tsx new file mode 100644 index 000000000..d0ebc13a7 --- /dev/null +++ b/workspaces/frontend/src/app/pages/Workspaces/Form/properties/secrets/SecretsAttachModal.tsx @@ -0,0 +1,144 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Button } from '@patternfly/react-core/dist/esm/components/Button'; +import { + Modal, + ModalBody, + ModalFooter, + ModalHeader, + ModalVariant, +} from '@patternfly/react-core/dist/esm/components/Modal'; +import { MultiTypeaheadSelect, MultiTypeaheadSelectOption } from '@patternfly/react-templates'; +import { Form, FormGroup } from '@patternfly/react-core/dist/esm/components/Form'; +import { HelperText, HelperTextItem } from '@patternfly/react-core/dist/esm/components/HelperText'; +import { TextInput } from '@patternfly/react-core/dist/esm/components/TextInput'; +import { ValidatedOptions } from '@patternfly/react-core/helpers'; +import { SecretsSecretListItem } from '~/generated/data-contracts'; +import { isValidDefaultMode } from '~/app/pages/Workspaces/Form/helpers'; + +export interface SecretsAttachModalProps { + isOpen: boolean; + setIsOpen: (isOpen: boolean) => void; + onClose: (secrets: SecretsSecretListItem[], mountPath: string, mode: number) => void; + selectedSecrets: string[]; + availableSecrets: SecretsSecretListItem[]; + initialMountPath?: string; + initialDefaultMode?: string; +} + +export const SecretsAttachModal: React.FC = ({ + isOpen, + setIsOpen, + onClose, + selectedSecrets, + availableSecrets, + initialMountPath = '', + initialDefaultMode = '', +}) => { + const [selected, setSelected] = useState(selectedSecrets); + const [mountPath, setMountPath] = useState(initialMountPath); + const [defaultMode, setDefaultMode] = useState(initialDefaultMode); + const [isDefaultModeValid, setIsDefaultModeValid] = useState(true); + + // Sync state with props when modal opens or props change + useEffect(() => { + if (isOpen) { + setSelected(selectedSecrets); + setMountPath(initialMountPath); + setDefaultMode(initialDefaultMode); + setIsDefaultModeValid(true); + } + }, [isOpen, selectedSecrets, initialMountPath, initialDefaultMode]); + + const handleDefaultModeChange = (val: string) => { + if (val.length <= 3) { + setDefaultMode(val); + const isValid = isValidDefaultMode(val); + setIsDefaultModeValid(val.length === 3 && isValid); + } + }; + + const initialOptions = useMemo( + () => + availableSecrets.map((secret) => ({ + content: secret.name, + value: secret.name, + selected: selectedSecrets.includes(secret.name), + isDisabled: !secret.canMount, + description: `Type: ${secret.type}`, + })), + [availableSecrets, selectedSecrets], + ); + + return ( + setIsOpen(false)} + ouiaId="BasicModal" + aria-labelledby="basic-modal-title" + aria-describedby="modal-box-body-basic" + variant={ModalVariant.medium} + > + + +
+ + `No secret was found for "${filter}"`} + onSelectionChange={(_ev, selections) => setSelected(selections as string[])} + /> + + + setMountPath(val)} + id="mount-path" + /> + + + handleDefaultModeChange(val)} + id="default-mode" + /> + {!isDefaultModeValid && ( + + + Must be a valid UNIX file system permission value (i.e. 644) + + + )} + +
+
+ + + + +
+ ); +}; diff --git a/workspaces/frontend/src/app/pages/Workspaces/Form/properties/secrets/SecretsCreateModal.tsx b/workspaces/frontend/src/app/pages/Workspaces/Form/properties/secrets/SecretsCreateModal.tsx new file mode 100644 index 000000000..e941eb61f --- /dev/null +++ b/workspaces/frontend/src/app/pages/Workspaces/Form/properties/secrets/SecretsCreateModal.tsx @@ -0,0 +1,148 @@ +import React, { useEffect, useState } from 'react'; +import { Button } from '@patternfly/react-core/dist/esm/components/Button'; +import { + Modal, + ModalBody, + ModalFooter, + ModalHeader, + ModalVariant, +} from '@patternfly/react-core/dist/esm/components/Modal'; +import { Form, FormGroup } from '@patternfly/react-core/dist/esm/components/Form'; +import { HelperText, HelperTextItem } from '@patternfly/react-core/dist/esm/components/HelperText'; +import { TextInput } from '@patternfly/react-core/dist/esm/components/TextInput'; +import { ValidatedOptions } from '@patternfly/react-core/helpers'; +import { WorkspacesPodSecretMount } from '~/generated/data-contracts'; +import { isValidDefaultMode } from '~/app/pages/Workspaces/Form/helpers'; + +const DEFAULT_MODE_OCTAL = (420).toString(8); + +export interface SecretsCreateModalProps { + isOpen: boolean; + setIsOpen: (isOpen: boolean) => void; + onSubmit: (secret: WorkspacesPodSecretMount) => void; + editSecret?: WorkspacesPodSecretMount; +} + +export const SecretsCreateModal: React.FC = ({ + isOpen, + setIsOpen, + onSubmit, + editSecret, +}) => { + const [formData, setFormData] = useState({ + secretName: '', + mountPath: '', + defaultMode: parseInt(DEFAULT_MODE_OCTAL, 8), + }); + const [defaultMode, setDefaultMode] = useState(DEFAULT_MODE_OCTAL); + const [isDefaultModeValid, setIsDefaultModeValid] = useState(true); + + // Sync state when modal opens or editSecret changes + useEffect(() => { + if (isOpen) { + if (editSecret) { + setFormData(editSecret); + setDefaultMode(editSecret.defaultMode?.toString(8) ?? DEFAULT_MODE_OCTAL); + } else { + setFormData({ + secretName: '', + mountPath: '', + defaultMode: parseInt(DEFAULT_MODE_OCTAL, 8), + }); + setDefaultMode(DEFAULT_MODE_OCTAL); + } + setIsDefaultModeValid(true); + } + }, [isOpen, editSecret]); + + const handleDefaultModeInput = (val: string) => { + if (val.length <= 3) { + // 0 no permissions, 4 read only, 5 read + execute, 6 read + write, 7 all permissions + setDefaultMode(val); + const isValid = isValidDefaultMode(val); + setIsDefaultModeValid(val.length === 3 && isValid); + const decimalVal = parseInt(val, 8); + setFormData({ ...formData, defaultMode: decimalVal }); + } + }; + + const handleSubmit = () => { + if (!formData.secretName || !formData.mountPath || !isDefaultModeValid) { + return; + } + onSubmit(formData); + }; + + const handleClose = () => { + setIsOpen(false); + }; + + return ( + + + +
+ + setFormData({ ...formData, secretName: val })} + id="secret-name" + /> + + + setFormData({ ...formData, mountPath: val })} + id="mount-path" + /> + + + handleDefaultModeInput(val)} + id="default-mode" + /> + {!isDefaultModeValid && ( + + + Must be a valid UNIX file system permission value (i.e. 644) + + + )} + +
+
+ + + + +
+ ); +}; diff --git a/workspaces/frontend/src/app/pages/Workspaces/Form/properties/secrets/SecretsViewPopover.tsx b/workspaces/frontend/src/app/pages/Workspaces/Form/properties/secrets/SecretsViewPopover.tsx new file mode 100644 index 000000000..416613dcf --- /dev/null +++ b/workspaces/frontend/src/app/pages/Workspaces/Form/properties/secrets/SecretsViewPopover.tsx @@ -0,0 +1,39 @@ +import React, { useEffect, useState } from 'react'; +import { Popover } from '@patternfly/react-core/dist/esm/components/Popover'; +import { Button } from '@patternfly/react-core/dist/esm/components/Button'; +import { EyeIcon } from '@patternfly/react-icons/dist/esm/icons/eye-icon'; +import { useNamespaceContext } from '~/app/context/NamespaceContextProvider'; +import { useNotebookAPI } from '~/app/hooks/useNotebookAPI'; +import { SecretsSecretUpdate } from '~/generated/data-contracts'; + +export interface SecretsViewPopoverProps { + secretName: string; +} +export const SecretsViewPopover: React.FC = ({ secretName }) => { + const { api } = useNotebookAPI(); + const { selectedNamespace } = useNamespaceContext(); + const [secret, setSecret] = useState(null); + useEffect(() => { + const fetchSecret = async () => { + const response = await api.secrets.getSecret(selectedNamespace, secretName); + setSecret(response.data); + }; + fetchSecret(); + }, [secretName, api.secrets, selectedNamespace]); + const secretContentsKeys = secret ? Object.keys(secret.contents) : []; + return ( + {secretName}} + bodyContent={ +
+ {secretContentsKeys.map((key) => ( +
{key}: *********
+ ))} +
+ } + > +