Skip to content

Commit 246cf66

Browse files
committed
[Feature]: Add UI for managing Secrets #2882
1 parent 3c9a9d5 commit 246cf66

File tree

9 files changed

+369
-17
lines changed

9 files changed

+369
-17
lines changed

frontend/src/api.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,13 @@ export const API = {
9898
// METRICS
9999
JOB_METRICS: (projectName: IProject['project_name'], runName: IRun['run_spec']['run_name']) =>
100100
`${API.BASE()}/project/${projectName}/metrics/job/${runName}`,
101+
102+
// SECRETS
103+
SECRETS_LIST: (projectName: IProject['project_name']) => `${API.BASE()}/project/${projectName}/secrets/list`,
104+
SECRET_GET: (projectName: IProject['project_name']) => `${API.BASE()}/project/${projectName}/secrets/get`,
105+
SECRETS_UPDATE: (projectName: IProject['project_name']) =>
106+
`${API.BASE()}/project/${projectName}/secrets/create_or_update`,
107+
SECRETS_DELETE: (projectName: IProject['project_name']) => `${API.BASE()}/project/${projectName}/secrets/delete`,
101108
},
102109

103110
BACKENDS: {

frontend/src/locale/en.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,15 @@
297297
"name": "User name",
298298
"role": "Project role"
299299
},
300+
"secrets": {
301+
"section_title": "Secrets",
302+
"empty_message_title": "No secrets",
303+
"empty_message_text": "No secrets to display.",
304+
"name": "Secret name",
305+
"value": "Secret value",
306+
"delete_confirm_title": "Delete secrets",
307+
"delete_confirm_message": "Are you sure you want to delete these secrets?"
308+
},
300309
"error_notification": "Update project error",
301310
"validation": {
302311
"user_name_format": "Only letters, numbers, - or _"

frontend/src/pages/Project/Details/Settings/index.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { useBackendsTable } from '../../Backends/hooks';
3636
import { BackendsTable } from '../../Backends/Table';
3737
import { GatewaysTable } from '../../Gateways';
3838
import { useGatewaysTable } from '../../Gateways/hooks';
39+
import { ProjectSecrets } from '../../Secrets';
3940
import { CLI_INFO } from './constants';
4041

4142
import styles from './styles.module.scss';
@@ -238,6 +239,8 @@ export const ProjectSettings: React.FC = () => {
238239
project={data}
239240
/>
240241

242+
<ProjectSecrets project={data} />
243+
241244
<Container header={<Header variant="h2">{t('common.danger_zone')}</Header>}>
242245
<SpaceBetween size="l">
243246
<div className={styles.dangerSectionGrid}>
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import React, { useEffect, useMemo, useState } from 'react';
2+
import { useFieldArray, useForm } from 'react-hook-form';
3+
import { useTranslation } from 'react-i18next';
4+
5+
import {
6+
Button,
7+
ButtonWithConfirmation,
8+
FormInput,
9+
Header,
10+
ListEmptyMessage,
11+
Pagination,
12+
SpaceBetween,
13+
Table,
14+
} from 'components';
15+
16+
import { useCollection } from 'hooks';
17+
import { useDeleteSecretsMutation, useGetAllSecretsQuery, useUpdateSecretMutation } from 'services/secrets';
18+
19+
import { IProps, TFormSecretValue, TFormValues, TProjectSecretWithIndex } from './types';
20+
21+
import styles from './styles.module.scss';
22+
23+
export const ProjectSecrets: React.FC<IProps> = ({ project, loading }) => {
24+
const { t } = useTranslation();
25+
const [editableRowIndex, setEditableRowIndex] = useState<number | null>(null);
26+
const projectName = project?.project_name ?? '';
27+
28+
const { data, isLoading, isFetching } = useGetAllSecretsQuery({ project_name: projectName });
29+
const [updateSecret, { isLoading: isUpdating }] = useUpdateSecretMutation();
30+
const [deleteSecret, { isLoading: isDeleting }] = useDeleteSecretsMutation();
31+
32+
const { handleSubmit, control, getValues, setValue } = useForm<TFormValues>({
33+
defaultValues: { secrets: [] },
34+
});
35+
36+
useEffect(() => {
37+
if (data) {
38+
setValue(
39+
'secrets',
40+
data.map((s) => ({ ...s, serverId: s.id })),
41+
);
42+
}
43+
}, [data]);
44+
45+
const { fields, append, remove } = useFieldArray({
46+
control,
47+
name: 'secrets',
48+
});
49+
50+
const fieldsWithIndex = useMemo(() => {
51+
return fields.map<TProjectSecretWithIndex>((field, index) => ({ ...field, index }));
52+
}, [fields]);
53+
54+
const { items, paginationProps, collectionProps } = useCollection(fieldsWithIndex, {
55+
filtering: {
56+
empty: (
57+
<ListEmptyMessage
58+
title={t('projects.edit.secrets.empty_message_title')}
59+
message={t('projects.edit.secrets.empty_message_text')}
60+
/>
61+
),
62+
},
63+
pagination: { pageSize: 10 },
64+
selection: {},
65+
});
66+
67+
const { selectedItems } = collectionProps;
68+
69+
const deleteSelectedSecrets = () => {
70+
const names = selectedItems?.map((s) => s.name ?? '');
71+
72+
if (names?.length) {
73+
deleteSecret({ project_name: projectName, names }).then(() => {
74+
selectedItems?.forEach((s) => remove(s.index));
75+
});
76+
}
77+
};
78+
79+
const removeSecretByIndex = (index: number) => {
80+
const secretData = getValues().secrets?.[index];
81+
82+
if (!secretData || !secretData.name) {
83+
return;
84+
}
85+
86+
deleteSecret({ project_name: projectName, names: [secretData.name] }).then(() => {
87+
remove(index);
88+
});
89+
};
90+
91+
const saveSecretByIndex = (index: number) => {
92+
const secretData = getValues().secrets?.[index];
93+
94+
if (!secretData || !secretData.name || !secretData.value) {
95+
return;
96+
}
97+
98+
updateSecret({ project_name: projectName, name: secretData.name, value: secretData.value })
99+
.unwrap()
100+
.then(() => {
101+
setEditableRowIndex(null);
102+
});
103+
};
104+
105+
const isDisabledEditableRowActions = loading || isLoading || isFetching || isUpdating;
106+
const isDisabledNotEditableRowActions = loading || isLoading || isFetching || isDeleting;
107+
108+
const COLUMN_DEFINITIONS = [
109+
{
110+
id: 'name',
111+
header: t('projects.edit.secrets.name'),
112+
cell: (field: TFormSecretValue & { index: number }) => {
113+
const isEditable = editableRowIndex === field.index;
114+
115+
return (
116+
<div className={styles.value}>
117+
<div className={styles.valueFieldWrapper}>
118+
<FormInput
119+
key={field.name}
120+
control={control}
121+
name={`secrets.${field.index}.name`}
122+
disabled={loading || !isEditable || !!field.serverId}
123+
/>
124+
</div>
125+
</div>
126+
);
127+
},
128+
},
129+
{
130+
id: 'value',
131+
header: t('projects.edit.secrets.value'),
132+
cell: (field: TFormSecretValue & { index: number }) => {
133+
const isEditable = editableRowIndex === field.index;
134+
135+
return (
136+
<div className={styles.value}>
137+
<div className={styles.valueFieldWrapper}>
138+
<FormInput
139+
readOnly={!isEditable}
140+
key={field.value}
141+
control={control}
142+
name={`secrets.${field.index}.value`}
143+
disabled={loading || !isEditable}
144+
/>
145+
</div>
146+
147+
<div className={styles.buttonsWrapper}>
148+
{isEditable && (
149+
<Button
150+
disabled={isDisabledEditableRowActions}
151+
formAction="none"
152+
onClick={() => saveSecretByIndex(field.index)}
153+
variant="icon"
154+
iconName="check"
155+
/>
156+
)}
157+
158+
{!isEditable && (
159+
<Button
160+
disabled={isDisabledNotEditableRowActions}
161+
formAction="none"
162+
onClick={() => setEditableRowIndex(field.index)}
163+
variant="icon"
164+
iconName="edit"
165+
/>
166+
)}
167+
168+
{!isEditable && (
169+
<ButtonWithConfirmation
170+
disabled={isDisabledNotEditableRowActions}
171+
formAction="none"
172+
onClick={() => removeSecretByIndex(field.index)}
173+
confirmTitle={t('projects.edit.secrets.delete_confirm_title')}
174+
confirmContent={t('projects.edit.secrets.delete_confirm_message')}
175+
variant="icon"
176+
iconName="remove"
177+
/>
178+
)}
179+
</div>
180+
</div>
181+
);
182+
},
183+
},
184+
];
185+
186+
const addSecretHandler = () => {
187+
append({});
188+
setEditableRowIndex(fields.length);
189+
};
190+
191+
const renderActions = () => {
192+
const actions = [
193+
<Button key="add" formAction="none" onClick={addSecretHandler}>
194+
{t('common.add')}
195+
</Button>,
196+
197+
<ButtonWithConfirmation
198+
key="delete"
199+
disabled={isDisabledNotEditableRowActions || !selectedItems?.length}
200+
formAction="none"
201+
onClick={deleteSelectedSecrets}
202+
confirmTitle={t('projects.edit.secrets.delete_confirm_title')}
203+
confirmContent={t('projects.edit.secrets.delete_confirm_message')}
204+
>
205+
{t('common.delete')}
206+
</ButtonWithConfirmation>,
207+
];
208+
209+
return actions.length > 0 ? (
210+
<SpaceBetween size="xs" direction="horizontal">
211+
{actions}
212+
</SpaceBetween>
213+
) : undefined;
214+
};
215+
216+
return (
217+
<form onSubmit={handleSubmit(() => {})}>
218+
<Table
219+
{...collectionProps}
220+
selectionType="multi"
221+
columnDefinitions={COLUMN_DEFINITIONS}
222+
items={items}
223+
loading={isLoading}
224+
header={
225+
<Header variant="h2" counter={`(${items?.length})`} actions={renderActions()}>
226+
{t('projects.edit.secrets.section_title')}
227+
</Header>
228+
}
229+
pagination={<Pagination {...paginationProps} />}
230+
/>
231+
</form>
232+
);
233+
};
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
.value {
2+
display: flex;
3+
align-items: center;
4+
gap: 20px;
5+
}
6+
.valueFieldWrapper {
7+
flex-grow: 1;
8+
flex-basis: 0;
9+
max-width: 400px;
10+
}
11+
.buttonsWrapper {
12+
min-width: 96px;
13+
display: flex;
14+
gap: 8px;
15+
justify-content: flex-end;
16+
margin-left: auto;
17+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export interface IProps {
2+
loading?: boolean;
3+
project?: IProject;
4+
}
5+
6+
export type TFormSecretValue = Partial<IProjectSecret & { serverId: IProjectSecret['id'] }>;
7+
export type TProjectSecretWithIndex = TFormSecretValue & { index: number };
8+
export type TFormValues = { secrets: TFormSecretValue[] };

frontend/src/services/secrets.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { API } from 'api';
2+
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
3+
4+
import fetchBaseQueryHeaders from 'libs/fetchBaseQueryHeaders';
5+
6+
export const secretApi = createApi({
7+
reducerPath: 'secretApi',
8+
baseQuery: fetchBaseQuery({
9+
prepareHeaders: fetchBaseQueryHeaders,
10+
}),
11+
12+
tagTypes: ['Secrets'],
13+
14+
endpoints: (builder) => ({
15+
getAllSecrets: builder.query<IProjectSecret[], { project_name: string }>({
16+
query: ({ project_name }) => {
17+
return {
18+
url: API.PROJECTS.SECRETS_LIST(project_name),
19+
method: 'POST',
20+
};
21+
},
22+
23+
providesTags: (result) =>
24+
result ? [...result.map(({ id }) => ({ type: 'Secrets' as const, id: id })), 'Secrets'] : ['Secrets'],
25+
}),
26+
27+
getSecret: builder.query<IProjectSecret, { project_name: IProject['project_name']; name: IProjectSecret['name'][] }>({
28+
query: ({ project_name, name }) => ({
29+
url: API.PROJECTS.SECRET_GET(project_name),
30+
method: 'POST',
31+
body: {
32+
name,
33+
},
34+
}),
35+
36+
providesTags: (result) => (result ? [{ type: 'Secrets' as const, id: result.id }, 'Secrets'] : ['Secrets']),
37+
}),
38+
39+
updateSecret: builder.mutation<
40+
void,
41+
{ project_name: IProject['project_name']; name: IProjectSecret['name']; value: IProjectSecret['value'] }
42+
>({
43+
query: ({ project_name, ...body }) => ({
44+
url: API.PROJECTS.SECRETS_UPDATE(project_name),
45+
method: 'POST',
46+
body: body,
47+
}),
48+
49+
invalidatesTags: () => ['Secrets'],
50+
}),
51+
52+
deleteSecrets: builder.mutation<void, { project_name: IProject['project_name']; names: IProjectSecret['name'][] }>({
53+
query: ({ project_name, names }) => ({
54+
url: API.PROJECTS.SECRETS_DELETE(project_name),
55+
method: 'POST',
56+
body: {
57+
secrets_names: names,
58+
},
59+
}),
60+
61+
invalidatesTags: () => ['Secrets'],
62+
}),
63+
}),
64+
});
65+
66+
export const { useGetAllSecretsQuery, useGetSecretQuery, useUpdateSecretMutation, useDeleteSecretsMutation } = secretApi;

frontend/src/store.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { instanceApi } from 'services/instance';
1010
import { mainApi } from 'services/mainApi';
1111
import { projectApi } from 'services/project';
1212
import { runApi } from 'services/run';
13+
import { secretApi } from 'services/secrets';
1314
import { serverApi } from 'services/server';
1415
import { userApi } from 'services/user';
1516
import { volumeApi } from 'services/volume';
@@ -30,6 +31,7 @@ export const store = configureStore({
3031
[authApi.reducerPath]: authApi.reducer,
3132
[serverApi.reducerPath]: serverApi.reducer,
3233
[volumeApi.reducerPath]: volumeApi.reducer,
34+
[secretApi.reducerPath]: secretApi.reducer,
3335
[mainApi.reducerPath]: mainApi.reducer,
3436
},
3537

@@ -47,6 +49,7 @@ export const store = configureStore({
4749
.concat(authApi.middleware)
4850
.concat(serverApi.middleware)
4951
.concat(volumeApi.middleware)
52+
.concat(secretApi.middleware)
5053
.concat(mainApi.middleware),
5154
});
5255

0 commit comments

Comments
 (0)