Skip to content

Commit bde6b4b

Browse files
authored
feat(ws): Notebooks v2 add secrets to workspace creation properties (#303)
* feat(ws): Add secrets to workspace creation properties form Signed-off-by: Charles Thao <[email protected]> * Fix typos Signed-off-by: Charles Thao <[email protected]> * Add descriptions to Secrets in Workspace creation wizard Signed-off-by: Charles Thao <[email protected]> --------- Signed-off-by: Charles Thao <[email protected]>
1 parent afc3bf7 commit bde6b4b

File tree

4 files changed

+296
-2
lines changed

4 files changed

+296
-2
lines changed
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
import React, { useCallback, useState } from 'react';
2+
import { EllipsisVIcon } from '@patternfly/react-icons';
3+
import { Table, Thead, Tbody, Tr, Th, Td, TableVariant } from '@patternfly/react-table';
4+
import {
5+
Button,
6+
Modal,
7+
ModalVariant,
8+
TextInput,
9+
Dropdown,
10+
DropdownItem,
11+
MenuToggle,
12+
ModalBody,
13+
ModalFooter,
14+
Form,
15+
FormGroup,
16+
ModalHeader,
17+
ValidatedOptions,
18+
HelperText,
19+
HelperTextItem,
20+
} from '@patternfly/react-core';
21+
import { WorkspaceSecret } from '~/shared/types';
22+
23+
interface WorkspaceCreationPropertiesSecretsProps {
24+
secrets: WorkspaceSecret[];
25+
setSecrets: React.Dispatch<React.SetStateAction<WorkspaceSecret[]>>;
26+
}
27+
28+
export const WorkspaceCreationPropertiesSecrets: React.FC<
29+
WorkspaceCreationPropertiesSecretsProps
30+
> = ({ secrets, setSecrets }) => {
31+
const [isModalOpen, setIsModalOpen] = useState(false);
32+
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
33+
const [formData, setFormData] = useState<WorkspaceSecret>({
34+
secretName: '',
35+
mountPath: '',
36+
defaultMode: 420,
37+
});
38+
const [editIndex, setEditIndex] = useState<number | null>(null);
39+
const [defaultMode, setDefaultMode] = useState('644');
40+
const [deleteIndex, setDeleteIndex] = useState<number | null>(null);
41+
const [isDefaultModeValid, setIsDefaultModeValid] = useState(true);
42+
const [dropdownOpen, setDropdownOpen] = useState<number | null>(null);
43+
44+
const openDeleteModal = useCallback((i: number) => {
45+
setIsDeleteModalOpen(true);
46+
setDeleteIndex(i);
47+
}, []);
48+
49+
const handleEdit = useCallback(
50+
(index: number) => {
51+
setFormData(secrets[index]);
52+
setDefaultMode(secrets[index].defaultMode.toString(8));
53+
setEditIndex(index);
54+
setIsModalOpen(true);
55+
},
56+
[secrets],
57+
);
58+
59+
const handleDefaultModeInput = useCallback(
60+
(val: string) => {
61+
if (val.length <= 3) {
62+
// 0 no permissions, 4 read only, 5 read + execute, 6 read + write, 7 all permissions
63+
setDefaultMode(val);
64+
const permissions = ['0', '4', '5', '6', '7'];
65+
const isValid = Array.from(val).every((char) => permissions.includes(char));
66+
if (val.length < 3 || !isValid) {
67+
setIsDefaultModeValid(false);
68+
} else {
69+
setIsDefaultModeValid(true);
70+
}
71+
const decimalVal = parseInt(val, 8);
72+
setFormData({ ...formData, defaultMode: decimalVal });
73+
}
74+
},
75+
[setFormData, setIsDefaultModeValid, setDefaultMode, formData],
76+
);
77+
78+
const clearForm = useCallback(() => {
79+
setFormData({ secretName: '', mountPath: '', defaultMode: 420 });
80+
setEditIndex(null);
81+
setIsModalOpen(false);
82+
setIsDefaultModeValid(true);
83+
}, []);
84+
85+
const handleAddOrEditSubmit = useCallback(() => {
86+
setSecrets((prev) => {
87+
if (editIndex === null) {
88+
return [...prev, formData];
89+
}
90+
const updated = prev;
91+
updated[editIndex] = formData;
92+
return updated;
93+
});
94+
clearForm();
95+
}, [editIndex, setSecrets, clearForm, formData]);
96+
97+
const handleDelete = useCallback(() => {
98+
if (deleteIndex !== null) {
99+
setSecrets((prev) => {
100+
prev.splice(deleteIndex, 1);
101+
return prev;
102+
});
103+
setDeleteIndex(null);
104+
setIsDeleteModalOpen(false);
105+
}
106+
}, [setSecrets, deleteIndex]);
107+
108+
return (
109+
<>
110+
{secrets.length > 0 && (
111+
<Table variant={TableVariant.compact} aria-label="Secrets Table">
112+
<Thead>
113+
<Tr>
114+
<Th>Secret Name</Th>
115+
<Th>Mount Path</Th>
116+
<Th>Default Mode</Th>
117+
<Th />
118+
</Tr>
119+
</Thead>
120+
<Tbody>
121+
{secrets.map((secret, index) => (
122+
<Tr key={index}>
123+
<Td>{secret.secretName}</Td>
124+
<Td>{secret.mountPath}</Td>
125+
<Td>{secret.defaultMode.toString(8)}</Td>
126+
<Td isActionCell>
127+
<Dropdown
128+
toggle={(toggleRef) => (
129+
<MenuToggle
130+
ref={toggleRef}
131+
isExpanded={dropdownOpen === index}
132+
onClick={() => setDropdownOpen(dropdownOpen === index ? null : index)}
133+
variant="plain"
134+
aria-label="plain kebab"
135+
>
136+
<EllipsisVIcon />
137+
</MenuToggle>
138+
)}
139+
isOpen={dropdownOpen === index}
140+
onSelect={() => setDropdownOpen(null)}
141+
popperProps={{ position: 'right' }}
142+
>
143+
<DropdownItem onClick={() => handleEdit(index)}>Edit</DropdownItem>
144+
<DropdownItem onClick={() => openDeleteModal(index)}>Remove</DropdownItem>
145+
</Dropdown>
146+
</Td>
147+
</Tr>
148+
))}
149+
</Tbody>
150+
</Table>
151+
)}
152+
<Button variant="primary" onClick={() => setIsModalOpen(true)} style={{ marginTop: '1rem' }}>
153+
Create Secret
154+
</Button>
155+
<Modal isOpen={isModalOpen} onClose={clearForm} variant={ModalVariant.small}>
156+
<ModalHeader
157+
title={editIndex === null ? 'Create Secret' : 'Edit Secret'}
158+
labelId="secret-modal-title"
159+
description={
160+
editIndex === null
161+
? 'Add a secret to securely use API keys, tokens, or other credentials in your workspace.'
162+
: ''
163+
}
164+
/>
165+
<ModalBody id="secret-modal-box-body">
166+
<Form onSubmit={handleAddOrEditSubmit}>
167+
<FormGroup label="Secret Name" isRequired fieldId="secret-name">
168+
<TextInput
169+
name="secretName"
170+
isRequired
171+
type="text"
172+
value={formData.secretName}
173+
onChange={(_, val) => setFormData({ ...formData, secretName: val })}
174+
id="secret-name"
175+
/>
176+
</FormGroup>
177+
<FormGroup label="Mount Path" isRequired fieldId="mount-path">
178+
<TextInput
179+
name="mountPath"
180+
isRequired
181+
type="text"
182+
value={formData.mountPath}
183+
onChange={(_, val) => setFormData({ ...formData, mountPath: val })}
184+
id="mount-path"
185+
/>
186+
</FormGroup>
187+
<FormGroup label="Default Mode" isRequired fieldId="default-mode">
188+
<TextInput
189+
name="defaultMode"
190+
isRequired
191+
type="text"
192+
value={defaultMode}
193+
validated={!isDefaultModeValid ? ValidatedOptions.error : undefined}
194+
onChange={(_, val) => handleDefaultModeInput(val)}
195+
id="default-mode"
196+
/>
197+
{!isDefaultModeValid && (
198+
<HelperText>
199+
<HelperTextItem variant="error">
200+
Must be a valid UNIX file system permission value (i.e. 644)
201+
</HelperTextItem>
202+
</HelperText>
203+
)}
204+
</FormGroup>
205+
</Form>
206+
</ModalBody>
207+
<ModalFooter>
208+
<Button
209+
key="confirm"
210+
variant="primary"
211+
onClick={handleAddOrEditSubmit}
212+
isDisabled={!isDefaultModeValid}
213+
>
214+
{editIndex !== null ? 'Save' : 'Create'}
215+
</Button>
216+
<Button key="cancel" variant="link" onClick={clearForm}>
217+
Cancel
218+
</Button>
219+
</ModalFooter>
220+
</Modal>
221+
<Modal
222+
isOpen={isDeleteModalOpen}
223+
onClose={() => setIsDeleteModalOpen(false)}
224+
variant={ModalVariant.small}
225+
>
226+
<ModalHeader
227+
title="Remove Secret?"
228+
description="The secret will be removed from the workspace."
229+
/>
230+
<ModalFooter>
231+
<Button key="remove" variant="danger" onClick={handleDelete}>
232+
Remove
233+
</Button>
234+
<Button key="cancel" variant="link" onClick={() => setIsDeleteModalOpen(false)}>
235+
Cancel
236+
</Button>
237+
</ModalFooter>
238+
</Modal>
239+
</>
240+
);
241+
};
242+
243+
export default WorkspaceCreationPropertiesSecrets;

workspaces/frontend/src/app/pages/Workspaces/Creation/properties/WorkspaceCreationPropertiesSelection.tsx

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ import {
1313
} from '@patternfly/react-core';
1414
import { WorkspaceCreationImageDetails } from '~/app/pages/Workspaces/Creation/image/WorkspaceCreationImageDetails';
1515
import { WorkspaceCreationPropertiesVolumes } from '~/app/pages/Workspaces/Creation/properties/WorkspaceCreationPropertiesVolumes';
16-
import { WorkspaceImage, WorkspaceVolumes, WorkspaceVolume } from '~/shared/types';
16+
import { WorkspaceImage, WorkspaceVolumes, WorkspaceVolume, WorkspaceSecret } from '~/shared/types';
17+
import { WorkspaceCreationPropertiesSecrets } from './WorkspaceCreationPropertiesSecrets';
1718

1819
interface WorkspaceCreationPropertiesSelectionProps {
1920
selectedImage: WorkspaceImage | undefined;
@@ -25,9 +26,13 @@ const WorkspaceCreationPropertiesSelection: React.FunctionComponent<
2526
const [workspaceName, setWorkspaceName] = useState('');
2627
const [deferUpdates, setDeferUpdates] = useState(false);
2728
const [homeDirectory, setHomeDirectory] = useState('');
28-
const [volumes, setVolumes] = useState<WorkspaceVolumes>({ home: '', data: [] });
29+
const [volumes, setVolumes] = useState<WorkspaceVolumes>({ home: '', data: [], secrets: [] });
2930
const [volumesData, setVolumesData] = useState<WorkspaceVolume[]>([]);
31+
const [secrets, setSecrets] = useState<WorkspaceSecret[]>(
32+
volumes.secrets.length ? volumes.secrets : [],
33+
);
3034
const [isVolumesExpanded, setIsVolumesExpanded] = useState(false);
35+
const [isSecretsExpanded, setIsSecretsExpanded] = useState(false);
3136

3237
useEffect(() => {
3338
setVolumes((prev) => ({
@@ -115,6 +120,31 @@ const WorkspaceCreationPropertiesSelection: React.FunctionComponent<
115120
</div>
116121
</div>
117122
)}
123+
<div className="pf-u-mb-0">
124+
<ExpandableSection
125+
toggleText="Secrets"
126+
onToggle={() => setIsSecretsExpanded((prev) => !prev)}
127+
isExpanded={isSecretsExpanded}
128+
isIndented
129+
>
130+
{isSecretsExpanded && (
131+
<FormGroup fieldId="secrets-table" style={{ marginTop: '1rem' }}>
132+
<WorkspaceCreationPropertiesSecrets
133+
secrets={secrets}
134+
setSecrets={setSecrets}
135+
/>
136+
</FormGroup>
137+
)}
138+
</ExpandableSection>
139+
</div>
140+
{!isSecretsExpanded && (
141+
<div style={{ paddingLeft: '36px', marginTop: '-10px' }}>
142+
<div>Secrets enable your project to securely access and manage credentials.</div>
143+
<div className="pf-u-font-size-sm">
144+
<strong>{secrets.length} added</strong>
145+
</div>
146+
</div>
147+
)}
118148
</Form>
119149
</div>
120150
</SplitItem>

workspaces/frontend/src/app/pages/Workspaces/Workspaces.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,13 @@ export const Workspaces: React.FunctionComponent = () => {
8989
readOnly: false,
9090
},
9191
],
92+
secrets: [
93+
{
94+
secretName: 'Secret-2',
95+
mountPath: '/data',
96+
defaultMode: 420,
97+
},
98+
],
9299
},
93100
endpoints: [
94101
{
@@ -144,6 +151,13 @@ export const Workspaces: React.FunctionComponent = () => {
144151
readOnly: false,
145152
},
146153
],
154+
secrets: [
155+
{
156+
secretName: 'workspace-secret',
157+
mountPath: '/secrets/my-secret',
158+
defaultMode: 420,
159+
},
160+
],
147161
},
148162
endpoints: [
149163
{

workspaces/frontend/src/shared/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ export interface WorkspaceVolume {
2929
export interface WorkspaceVolumes {
3030
home: string;
3131
data: WorkspaceVolume[];
32+
secrets: WorkspaceSecret[];
33+
}
34+
35+
export interface WorkspaceSecret {
36+
defaultMode: number;
37+
secretName: string;
38+
mountPath: string;
3239
}
3340

3441
export interface WorkspaceProperties {

0 commit comments

Comments
 (0)