From c0cf3252fa9f246382b2c078fe0c190feb8b348c Mon Sep 17 00:00:00 2001 From: Charles Thao Date: Wed, 29 Oct 2025 15:06:45 -0400 Subject: [PATCH 1/4] Generate frontend client endpoints for Secrets API Signed-off-by: Charles Thao --- workspaces/frontend/scripts/swagger.version | 2 +- .../WorkspaceFormPropertiesSecrets.tsx | 18 ++- workspaces/frontend/src/generated/Secrets.ts | 141 ++++++++++++++++++ .../frontend/src/generated/data-contracts.ts | 54 +++++++ .../frontend/src/shared/api/notebookApi.ts | 3 + .../frontend/src/shared/mock/mockBuilder.ts | 18 +++ .../src/shared/mock/mockNotebookApis.ts | 20 +++ .../shared/mock/mockNotebookServiceData.ts | 22 +++ 8 files changed, 275 insertions(+), 3 deletions(-) create mode 100644 workspaces/frontend/src/generated/Secrets.ts diff --git a/workspaces/frontend/scripts/swagger.version b/workspaces/frontend/scripts/swagger.version index 21ab894b..1b0720f8 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/properties/WorkspaceFormPropertiesSecrets.tsx b/workspaces/frontend/src/app/pages/Workspaces/Form/properties/WorkspaceFormPropertiesSecrets.tsx index 66e07ab4..5cffe587 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, @@ -24,7 +24,9 @@ import { MenuToggle } from '@patternfly/react-core/dist/esm/components/MenuToggl 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'; interface WorkspaceFormPropertiesSecretsProps { secrets: WorkspacesPodSecretMount[]; @@ -49,6 +51,18 @@ export const WorkspaceFormPropertiesSecrets: React.FC(null); const [isDefaultModeValid, setIsDefaultModeValid] = useState(true); const [dropdownOpen, setDropdownOpen] = useState(null); + const [, setAvailableSecrets] = useState([]); + + 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); diff --git a/workspaces/frontend/src/generated/Secrets.ts b/workspaces/frontend/src/generated/Secrets.ts new file mode 100644 index 00000000..c40d11a4 --- /dev/null +++ b/workspaces/frontend/src/generated/Secrets.ts @@ -0,0 +1,141 @@ +/* eslint-disable */ +/* tslint:disable */ +// @ts-nocheck +/* + * --------------------------------------------------------------- + * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## + * ## ## + * ## AUTHOR: acacode ## + * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## + * --------------------------------------------------------------- + */ + +import { + ApiErrorEnvelope, + ApiSecretCreateEnvelope, + ApiSecretEnvelope, + ApiSecretListEnvelope, + SecretsSecretUpdate, +} from './data-contracts'; +import { ContentType, HttpClient, RequestParams } from './http-client'; + +export class Secrets extends HttpClient { + /** + * @description Provides a list of all secrets that the user has access to in the specified namespace + * + * @tags secrets + * @name ListSecrets + * @summary Returns a list of all secrets in a namespace + * @request GET:/secrets/{namespace} + * @response `200` `ApiSecretListEnvelope` Successful secrets response + * @response `401` `ApiErrorEnvelope` Unauthorized + * @response `403` `ApiErrorEnvelope` Forbidden + * @response `422` `ApiErrorEnvelope` Unprocessable Entity. Validation error. + * @response `500` `ApiErrorEnvelope` Internal server error + */ + listSecrets = (namespace: string, params: RequestParams = {}) => + this.request({ + path: `/secrets/${namespace}`, + method: 'GET', + format: 'json', + ...params, + }); + /** + * @description Creates a new secret in the specified namespace + * + * @tags secrets + * @name CreateSecret + * @summary Creates a new secret + * @request POST:/secrets/{namespace} + * @response `201` `ApiSecretCreateEnvelope` Secret created successfully + * @response `400` `ApiErrorEnvelope` Bad request + * @response `401` `ApiErrorEnvelope` Unauthorized + * @response `403` `ApiErrorEnvelope` Forbidden + * @response `409` `ApiErrorEnvelope` Secret already exists + * @response `413` `ApiErrorEnvelope` Request Entity Too Large. The request body is too large. + * @response `415` `ApiErrorEnvelope` Unsupported Media Type. Content-Type header is not correct. + * @response `422` `ApiErrorEnvelope` Unprocessable Entity. Validation error. + * @response `500` `ApiErrorEnvelope` Internal server error + */ + createSecret = (namespace: string, secret: ApiSecretCreateEnvelope, params: RequestParams = {}) => + this.request({ + path: `/secrets/${namespace}`, + method: 'POST', + body: secret, + type: ContentType.Json, + format: 'json', + ...params, + }); + /** + * @description Provides details of a specific secret by name and namespace + * + * @tags secrets + * @name GetSecret + * @summary Returns a specific secret + * @request GET:/secrets/{namespace}/{name} + * @response `200` `ApiSecretEnvelope` Successful secret response + * @response `401` `ApiErrorEnvelope` Unauthorized + * @response `403` `ApiErrorEnvelope` Forbidden + * @response `404` `ApiErrorEnvelope` Secret not found + * @response `422` `ApiErrorEnvelope` Unprocessable Entity. Validation error. + * @response `500` `ApiErrorEnvelope` Internal server error + */ + getSecret = (namespace: string, name: string, params: RequestParams = {}) => + this.request({ + path: `/secrets/${namespace}/${name}`, + method: 'GET', + format: 'json', + ...params, + }); + /** + * @description Updates an existing secret in the specified namespace + * + * @tags secrets + * @name UpdateSecret + * @summary Updates an existing secret + * @request PUT:/secrets/{namespace}/{name} + * @response `200` `ApiSecretEnvelope` Secret updated successfully + * @response `400` `ApiErrorEnvelope` Bad request + * @response `401` `ApiErrorEnvelope` Unauthorized + * @response `403` `ApiErrorEnvelope` Forbidden + * @response `404` `ApiErrorEnvelope` Secret not found + * @response `413` `ApiErrorEnvelope` Request Entity Too Large. The request body is too large. + * @response `415` `ApiErrorEnvelope` Unsupported Media Type. Content-Type header is not correct. + * @response `422` `ApiErrorEnvelope` Unprocessable Entity. Validation error. + * @response `500` `ApiErrorEnvelope` Internal server error + */ + updateSecret = ( + namespace: string, + name: string, + secret: SecretsSecretUpdate, + params: RequestParams = {}, + ) => + this.request({ + path: `/secrets/${namespace}/${name}`, + method: 'PUT', + body: secret, + type: ContentType.Json, + format: 'json', + ...params, + }); + /** + * @description Deletes a secret from the specified namespace + * + * @tags secrets + * @name DeleteSecret + * @summary Deletes a secret + * @request DELETE:/secrets/{namespace}/{name} + * @response `204` `void` No Content + * @response `401` `ApiErrorEnvelope` Unauthorized + * @response `403` `ApiErrorEnvelope` Forbidden + * @response `404` `ApiErrorEnvelope` Secret not found + * @response `500` `ApiErrorEnvelope` Internal server error + */ + deleteSecret = (namespace: string, name: string, params: RequestParams = {}) => + this.request({ + path: `/secrets/${namespace}/${name}`, + method: 'DELETE', + type: ContentType.Json, + ...params, + }); +} diff --git a/workspaces/frontend/src/generated/data-contracts.ts b/workspaces/frontend/src/generated/data-contracts.ts index 12a6a45e..6db10eb0 100644 --- a/workspaces/frontend/src/generated/data-contracts.ts +++ b/workspaces/frontend/src/generated/data-contracts.ts @@ -77,6 +77,18 @@ export interface ApiNamespaceListEnvelope { data: NamespacesNamespace[]; } +export interface ApiSecretCreateEnvelope { + data: SecretsSecretCreate; +} + +export interface ApiSecretEnvelope { + data: SecretsSecretUpdate; +} + +export interface ApiSecretListEnvelope { + data: SecretsSecretListItem[]; +} + export interface ApiValidationError { field: string; message: string; @@ -107,6 +119,13 @@ export interface ApiWorkspaceListEnvelope { data: WorkspacesWorkspace[]; } +export interface CommonAudit { + createdAt: string; + createdBy: string; + updatedAt: string; + updatedBy: string; +} + export interface HealthCheckHealthCheck { status: HealthCheckServiceStatus; systemInfo: HealthCheckSystemInfo; @@ -120,6 +139,41 @@ export interface NamespacesNamespace { name: string; } +export interface SecretsSecretCreate { + contents: SecretsSecretData; + immutable: boolean; + name: string; + type: string; +} + +export type SecretsSecretData = Record; + +export interface SecretsSecretListItem { + audit: CommonAudit; + canMount: boolean; + canUpdate: boolean; + immutable: boolean; + mounts?: SecretsSecretMount[]; + name: string; + type: string; +} + +export interface SecretsSecretMount { + group: string; + kind: string; + name: string; +} + +export interface SecretsSecretUpdate { + contents: SecretsSecretData; + immutable: boolean; + type: string; +} + +export interface SecretsSecretValue { + base64?: string; +} + export interface WorkspacekindsImageConfig { default: string; values: WorkspacekindsImageConfigValue[]; diff --git a/workspaces/frontend/src/shared/api/notebookApi.ts b/workspaces/frontend/src/shared/api/notebookApi.ts index ba4efb0b..bc3e57b4 100644 --- a/workspaces/frontend/src/shared/api/notebookApi.ts +++ b/workspaces/frontend/src/shared/api/notebookApi.ts @@ -1,5 +1,6 @@ import { Healthcheck } from '~/generated/Healthcheck'; import { Namespaces } from '~/generated/Namespaces'; +import { Secrets } from '~/generated/Secrets'; import { Workspacekinds } from '~/generated/Workspacekinds'; import { Workspaces } from '~/generated/Workspaces'; import { ApiInstance } from '~/shared/api/types'; @@ -9,6 +10,7 @@ export interface NotebookApis { namespaces: ApiInstance; workspaces: ApiInstance; workspaceKinds: ApiInstance; + secrets: ApiInstance; } export const notebookApisImpl = (path: string): NotebookApis => { @@ -19,5 +21,6 @@ export const notebookApisImpl = (path: string): NotebookApis => { namespaces: new Namespaces(commonConfig), workspaces: new Workspaces(commonConfig), workspaceKinds: new Workspacekinds(commonConfig), + secrets: new Secrets(commonConfig), }; }; diff --git a/workspaces/frontend/src/shared/mock/mockBuilder.ts b/workspaces/frontend/src/shared/mock/mockBuilder.ts index cf9655ed..c717f60b 100644 --- a/workspaces/frontend/src/shared/mock/mockBuilder.ts +++ b/workspaces/frontend/src/shared/mock/mockBuilder.ts @@ -3,6 +3,7 @@ import { HealthCheckHealthCheck, HealthCheckServiceStatus, NamespacesNamespace, + SecretsSecretListItem, WorkspacekindsRedirectMessageLevel, WorkspacekindsWorkspaceKind, WorkspacesImageConfig, @@ -475,3 +476,20 @@ export const buildMockWorkspaceList = (args: { } return workspaces; }; + +export const buildMockSecret = ( + secret?: Partial, +): SecretsSecretListItem => ({ + name: 'secret-1', + type: 'Opaque', + immutable: false, + canMount: true, + canUpdate: true, + audit: { + createdAt: new Date(2025, 4, 1).toISOString(), + createdBy: 'test', + updatedAt: new Date(2025, 4, 1).toISOString(), + updatedBy: 'test', + }, + ...secret, +}); diff --git a/workspaces/frontend/src/shared/mock/mockNotebookApis.ts b/workspaces/frontend/src/shared/mock/mockNotebookApis.ts index 3947b6ee..13afb636 100644 --- a/workspaces/frontend/src/shared/mock/mockNotebookApis.ts +++ b/workspaces/frontend/src/shared/mock/mockNotebookApis.ts @@ -4,6 +4,8 @@ import { mockAllWorkspaces, mockedHealthCheckResponse, mockNamespaces, + mockSecretCreate, + mockSecretsList, mockWorkspace1, mockWorkspaceKind1, mockWorkspaceKinds, @@ -80,4 +82,22 @@ export const mockNotebookApisImpl = (): NotebookApis => ({ return { data: mockWorkspaceKind1 }; }, }, + secrets: { + listSecrets: async () => ({ + data: mockSecretsList, + }), + createSecret: async () => ({ + data: mockSecretCreate, + }), + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getSecret: async () => ({ + data: mockSecretCreate, + }), + updateSecret: async () => ({ + data: mockSecretCreate, + }), + deleteSecret: async () => { + await delay(1500); + }, + }, }); diff --git a/workspaces/frontend/src/shared/mock/mockNotebookServiceData.ts b/workspaces/frontend/src/shared/mock/mockNotebookServiceData.ts index 8a2cf687..176d77a5 100644 --- a/workspaces/frontend/src/shared/mock/mockNotebookServiceData.ts +++ b/workspaces/frontend/src/shared/mock/mockNotebookServiceData.ts @@ -1,4 +1,5 @@ import { + SecretsSecretCreate, WorkspacekindsWorkspaceKind, WorkspacesWorkspace, WorkspacesWorkspaceKindInfo, @@ -11,6 +12,7 @@ import { buildMockWorkspaceKind, buildMockWorkspaceKindInfo, buildMockWorkspaceList, + buildMockSecret, } from '~/shared/mock/mockBuilder'; // Health @@ -171,3 +173,23 @@ export const mockAllWorkspaces = [ kind: mockWorkspaceKindInfo1, }), ]; + +export const mockSecretCreate: SecretsSecretCreate = { + name: 'secret-1', + type: 'Opaque', + immutable: false, + contents: { + username: { + base64: 'abcd', + }, + }, +}; + +export const mockSecretsList = [ + buildMockSecret({ + name: 'secret-1', + }), + buildMockSecret({ + name: 'secret-2', + }), +]; From 291607ef252243b051f0e23f0b4b168703e217e1 Mon Sep 17 00:00:00 2001 From: Charles Thao Date: Wed, 29 Oct 2025 15:06:45 -0400 Subject: [PATCH 2/4] Generate frontend client endpoints for Secrets API Signed-off-by: Charles Thao --- .../Form/properties/WorkspaceFormPropertiesSecrets.tsx | 5 ++--- .../frontend/src/shared/mock/mockNotebookServiceData.ts | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) 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 5cffe587..fad7b0d2 100644 --- a/workspaces/frontend/src/app/pages/Workspaces/Form/properties/WorkspaceFormPropertiesSecrets.tsx +++ b/workspaces/frontend/src/app/pages/Workspaces/Form/properties/WorkspaceFormPropertiesSecrets.tsx @@ -23,7 +23,6 @@ import { Dropdown, DropdownItem } from '@patternfly/react-core/dist/esm/componen 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 { SecretsSecretListItem, WorkspacesPodSecretMount } from '~/generated/data-contracts'; import { useNotebookAPI } from '~/app/hooks/useNotebookAPI'; import { useNamespaceContext } from '~/app/context/NamespaceContextProvider'; @@ -51,7 +50,8 @@ export const WorkspaceFormPropertiesSecrets: React.FC(null); const [isDefaultModeValid, setIsDefaultModeValid] = useState(true); const [dropdownOpen, setDropdownOpen] = useState(null); - const [, setAvailableSecrets] = useState([]); + const [availableSecrets, setAvailableSecrets] = useState([]); + const [attachedSecrets, setAttachedSecrets] = useState([]); const { api } = useNotebookAPI(); const { selectedNamespace } = useNamespaceContext(); @@ -174,7 +174,6 @@ export const WorkspaceFormPropertiesSecrets: React.FC} onClick={() => setIsModalOpen(true)} style={{ marginTop: '1rem', width: 'fit-content' }} > diff --git a/workspaces/frontend/src/shared/mock/mockNotebookServiceData.ts b/workspaces/frontend/src/shared/mock/mockNotebookServiceData.ts index 176d77a5..7a744bee 100644 --- a/workspaces/frontend/src/shared/mock/mockNotebookServiceData.ts +++ b/workspaces/frontend/src/shared/mock/mockNotebookServiceData.ts @@ -179,7 +179,7 @@ export const mockSecretCreate: SecretsSecretCreate = { type: 'Opaque', immutable: false, contents: { - username: { + data: { base64: 'abcd', }, }, From 06e1ca2b2f9a5544260f4317b6464b891aa96a73 Mon Sep 17 00:00:00 2001 From: Charles Thao Date: Thu, 30 Oct 2025 11:15:45 -0400 Subject: [PATCH 3/4] draft: attach modal Signed-off-by: Charles Thao --- .../src/app/pages/Workspaces/Form/helpers.ts | 7 + .../WorkspaceFormPropertiesSecrets.tsx | 216 ++++++++---------- .../properties/secrets/SecretsAttachModal.tsx | 144 ++++++++++++ .../properties/secrets/SecretsCreateModal.tsx | 148 ++++++++++++ .../shared/mock/mockNotebookServiceData.ts | 1 + 5 files changed, 397 insertions(+), 119 deletions(-) create mode 100644 workspaces/frontend/src/app/pages/Workspaces/Form/helpers.ts create mode 100644 workspaces/frontend/src/app/pages/Workspaces/Form/properties/secrets/SecretsAttachModal.tsx create mode 100644 workspaces/frontend/src/app/pages/Workspaces/Form/properties/secrets/SecretsCreateModal.tsx 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 00000000..9263694a --- /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 fad7b0d2..5d823288 100644 --- a/workspaces/frontend/src/app/pages/Workspaces/Form/properties/WorkspaceFormPropertiesSecrets.tsx +++ b/workspaces/frontend/src/app/pages/Workspaces/Form/properties/WorkspaceFormPropertiesSecrets.tsx @@ -12,20 +12,17 @@ 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 { 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'; interface WorkspaceFormPropertiesSecretsProps { secrets: WorkspacesPodSecretMount[]; @@ -38,20 +35,19 @@ 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 [attachedSecrets, setAttachedSecrets] = useState([]); + const [attachedMountPath, setAttachedMountPath] = useState(''); + const [attachedDefaultMode, setAttachedDefaultMode] = useState(DEFAULT_MODE_OCTAL); const { api } = useNotebookAPI(); const { selectedNamespace } = useNamespaceContext(); @@ -71,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 ( <> @@ -163,7 +183,9 @@ export const WorkspaceFormPropertiesSecrets: React.FC setDropdownOpen(null)} popperProps={{ position: 'right' }} > - handleEdit(index)}>Edit + {!isAttachedSecret(secret.secretName) && ( + handleEdit(index)}>Edit + )} openDeleteModal(index)}>Remove @@ -173,78 +195,34 @@ 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 00000000..d0ebc13a --- /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 00000000..e941eb61 --- /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/shared/mock/mockNotebookServiceData.ts b/workspaces/frontend/src/shared/mock/mockNotebookServiceData.ts index 7a744bee..6571d511 100644 --- a/workspaces/frontend/src/shared/mock/mockNotebookServiceData.ts +++ b/workspaces/frontend/src/shared/mock/mockNotebookServiceData.ts @@ -191,5 +191,6 @@ export const mockSecretsList = [ }), buildMockSecret({ name: 'secret-2', + canMount: false, }), ]; From 5eb59457dffa51e3f86b37e29699773fa7385e33 Mon Sep 17 00:00:00 2001 From: Charles Thao Date: Fri, 21 Nov 2025 12:48:31 -0500 Subject: [PATCH 4/4] Add details to option descriptions to include mounts and other specs --- .../WorkspaceFormPropertiesSecrets.tsx | 64 ++++---- .../properties/secrets/SecretsAttachModal.tsx | 155 ++++++++++++++---- .../frontend/src/shared/mock/mockBuilder.ts | 4 +- .../shared/mock/mockNotebookServiceData.ts | 16 ++ 4 files changed, 169 insertions(+), 70 deletions(-) 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 5d823288..32fb59d5 100644 --- a/workspaces/frontend/src/app/pages/Workspaces/Form/properties/WorkspaceFormPropertiesSecrets.tsx +++ b/workspaces/frontend/src/app/pages/Workspaces/Form/properties/WorkspaceFormPropertiesSecrets.tsx @@ -45,9 +45,7 @@ export const WorkspaceFormPropertiesSecrets: React.FC(null); 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 [attachedSecretKeys, setAttachedSecretKeys] = useState>(new Set()); const { api } = useNotebookAPI(); const { selectedNamespace } = useNamespaceContext(); @@ -60,6 +58,9 @@ export const WorkspaceFormPropertiesSecrets: React.FC + `${secret.secretName}:${secret.mountPath}:${secret.defaultMode}`; + const openDeleteModal = useCallback((i: number) => { setIsDeleteModalOpen(true); setDeleteIndex(i); @@ -76,26 +77,23 @@ export const WorkspaceFormPropertiesSecrets: React.FC { - const newAttachedSecrets = newSecrets.map((secret) => ({ + const newSecretMounts = 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)); + + // Track the keys of attached secrets + const newKeys = new Set(attachedSecretKeys); + newSecretMounts.forEach((mount) => { + newKeys.add(getSecretKey(mount)); + }); + setAttachedSecretKeys(newKeys); + + setSecrets([...secrets, ...newSecretMounts]); setIsAttachModalOpen(false); }, - [attachedSecrets, secrets, setSecrets], + [secrets, setSecrets, attachedSecretKeys], ); const handleCreateOrEditSubmit = useCallback( @@ -121,8 +119,11 @@ export const WorkspaceFormPropertiesSecrets: React.FC attachedSecrets.some((s) => s.secretName === secretName), - [attachedSecrets], + (index: number): boolean => { + const secret = secrets[index]; + return attachedSecretKeys.has(getSecretKey(secret)); + }, + [secrets, attachedSecretKeys], ); const handleDelete = useCallback(() => { @@ -130,23 +131,18 @@ export const WorkspaceFormPropertiesSecrets: React.FC 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); - } + // Remove from attached keys if it was attached + if (attachedSecretKeys.has(getSecretKey(secretToDelete))) { + const newKeys = new Set(attachedSecretKeys); + newKeys.delete(getSecretKey(secretToDelete)); + setAttachedSecretKeys(newKeys); } + setSecrets(secrets.filter((_, i) => i !== deleteIndex)); setDeleteIndex(null); setIsDeleteModalOpen(false); - }, [deleteIndex, secrets, setSecrets, attachedSecrets, isAttachedSecret]); + }, [deleteIndex, secrets, setSecrets, attachedSecretKeys]); return ( <> @@ -183,7 +179,7 @@ export const WorkspaceFormPropertiesSecrets: React.FC setDropdownOpen(null)} popperProps={{ position: 'right' }} > - {!isAttachedSecret(secret.secretName) && ( + {!isAttachedSecret(index) && ( handleEdit(index)}>Edit )} openDeleteModal(index)}>Remove @@ -212,10 +208,8 @@ export const WorkspaceFormPropertiesSecrets: React.FC secret.secretName)} onClose={handleAttachSecrets} - initialMountPath={attachedMountPath} - initialDefaultMode={attachedDefaultMode} + existingSecretKeys={attachedSecretKeys} /> void; onClose: (secrets: SecretsSecretListItem[], mountPath: string, mode: number) => void; - selectedSecrets: string[]; availableSecrets: SecretsSecretListItem[]; - initialMountPath?: string; - initialDefaultMode?: string; + existingSecretKeys: Set; } +const DEFAULT_MODE_OCTAL = (420).toString(8); + export const SecretsAttachModal: React.FC = ({ isOpen, setIsOpen, onClose, - selectedSecrets, availableSecrets, - initialMountPath = '', - initialDefaultMode = '', + existingSecretKeys, }) => { - const [selected, setSelected] = useState(selectedSecrets); - const [mountPath, setMountPath] = useState(initialMountPath); - const [defaultMode, setDefaultMode] = useState(initialDefaultMode); + const [selected, setSelected] = useState([]); + const [mountPath, setMountPath] = useState(''); + const [defaultMode, setDefaultMode] = useState(DEFAULT_MODE_OCTAL); const [isDefaultModeValid, setIsDefaultModeValid] = useState(true); + const [error, setError] = useState(''); - // Sync state with props when modal opens or props change + // Reset state when modal opens useEffect(() => { if (isOpen) { - setSelected(selectedSecrets); - setMountPath(initialMountPath); - setDefaultMode(initialDefaultMode); + setSelected([]); + setMountPath(''); + setDefaultMode(DEFAULT_MODE_OCTAL); setIsDefaultModeValid(true); + setError(''); } - }, [isOpen, selectedSecrets, initialMountPath, initialDefaultMode]); + }, [isOpen]); + + const getSecretKey = (secretName: string, path: string, mode: number): string => + `${secretName}:${path}:${mode}`; const handleDefaultModeChange = (val: string) => { if (val.length <= 3) { setDefaultMode(val); const isValid = isValidDefaultMode(val); setIsDefaultModeValid(val.length === 3 && isValid); + setError(''); // Clear error when user modifies input + } + }; + + const handleAttach = () => { + const mode = parseInt(defaultMode, 8); + + // Check for duplicates + const duplicates: string[] = []; + selected.forEach((secretName) => { + const key = getSecretKey(secretName, mountPath.trim(), mode); + if (existingSecretKeys.has(key)) { + duplicates.push(secretName); + } + }); + + if (duplicates.length > 0) { + const secretList = duplicates.join(', '); + setError( + `The following secret${duplicates.length > 1 ? 's are' : ' is'} already mounted to "${mountPath.trim()}" with mode ${defaultMode}: ${secretList}`, + ); + return; } + + // No duplicates, proceed with attaching + onClose( + availableSecrets.filter((secret) => selected.includes(secret.name)), + mountPath.trim(), + mode, + ); }; const initialOptions = useMemo( @@ -62,11 +100,57 @@ export const SecretsAttachModal: React.FC = ({ availableSecrets.map((secret) => ({ content: secret.name, value: secret.name, - selected: selectedSecrets.includes(secret.name), isDisabled: !secret.canMount, - description: `Type: ${secret.type}`, + description: ( + // + + + + + Type: {secret.type} + {secret.immutable && '. Immutable'} + + {secret.mounts && ( + + {`Mounted to: `} + mount.name).join(', ')} + position="middle" + /> + + )} + + + + {secret.canMount && ( + + + Created at: {new Date(secret.audit.createdAt).toLocaleString()} {`by `} + {secret.audit.createdBy} + + + Updated at: {new Date(secret.audit.updatedAt).toLocaleString()} {`by `} + {secret.audit.updatedBy} + + + > + diff --git a/workspaces/frontend/src/shared/mock/mockBuilder.ts b/workspaces/frontend/src/shared/mock/mockBuilder.ts index c717f60b..62f1e6cd 100644 --- a/workspaces/frontend/src/shared/mock/mockBuilder.ts +++ b/workspaces/frontend/src/shared/mock/mockBuilder.ts @@ -487,9 +487,9 @@ export const buildMockSecret = ( canUpdate: true, audit: { createdAt: new Date(2025, 4, 1).toISOString(), - createdBy: 'test', + createdBy: 'admin1', updatedAt: new Date(2025, 4, 1).toISOString(), - updatedBy: 'test', + updatedBy: 'user1', }, ...secret, }); diff --git a/workspaces/frontend/src/shared/mock/mockNotebookServiceData.ts b/workspaces/frontend/src/shared/mock/mockNotebookServiceData.ts index 6571d511..0b2ad60a 100644 --- a/workspaces/frontend/src/shared/mock/mockNotebookServiceData.ts +++ b/workspaces/frontend/src/shared/mock/mockNotebookServiceData.ts @@ -188,9 +188,25 @@ export const mockSecretCreate: SecretsSecretCreate = { export const mockSecretsList = [ buildMockSecret({ name: 'secret-1', + immutable: true, }), buildMockSecret({ name: 'secret-2', canMount: false, }), + buildMockSecret({ + name: 'secret-3', + mounts: [ + { + name: 'workspace-1', + group: 'group-1', + kind: 'kind-1', + }, + { + name: 'workspace-2', + group: 'group-2', + kind: 'kind-2', + }, + ], + }), ];