From 246cf6647cd6cff0a5cf530b091ea875a3cd7b50 Mon Sep 17 00:00:00 2001 From: Oleg Vavilov Date: Wed, 16 Jul 2025 21:56:39 +0300 Subject: [PATCH 1/8] [Feature]: Add UI for managing Secrets #2882 --- frontend/src/api.ts | 7 + frontend/src/locale/en.json | 9 + .../pages/Project/Details/Settings/index.tsx | 3 + frontend/src/pages/Project/Secrets/index.tsx | 233 ++++++++++++++++++ .../pages/Project/Secrets/styles.module.scss | 17 ++ frontend/src/pages/Project/Secrets/types.ts | 8 + frontend/src/services/secrets.ts | 66 +++++ frontend/src/store.ts | 3 + frontend/src/types/project.d.ts | 40 +-- 9 files changed, 369 insertions(+), 17 deletions(-) create mode 100644 frontend/src/pages/Project/Secrets/index.tsx create mode 100644 frontend/src/pages/Project/Secrets/styles.module.scss create mode 100644 frontend/src/pages/Project/Secrets/types.ts create mode 100644 frontend/src/services/secrets.ts diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 46b3730f65..7150a2cfcc 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -98,6 +98,13 @@ export const API = { // METRICS JOB_METRICS: (projectName: IProject['project_name'], runName: IRun['run_spec']['run_name']) => `${API.BASE()}/project/${projectName}/metrics/job/${runName}`, + + // SECRETS + SECRETS_LIST: (projectName: IProject['project_name']) => `${API.BASE()}/project/${projectName}/secrets/list`, + SECRET_GET: (projectName: IProject['project_name']) => `${API.BASE()}/project/${projectName}/secrets/get`, + SECRETS_UPDATE: (projectName: IProject['project_name']) => + `${API.BASE()}/project/${projectName}/secrets/create_or_update`, + SECRETS_DELETE: (projectName: IProject['project_name']) => `${API.BASE()}/project/${projectName}/secrets/delete`, }, BACKENDS: { diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index e1e0c90f25..d532d36d62 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -297,6 +297,15 @@ "name": "User name", "role": "Project role" }, + "secrets": { + "section_title": "Secrets", + "empty_message_title": "No secrets", + "empty_message_text": "No secrets to display.", + "name": "Secret name", + "value": "Secret value", + "delete_confirm_title": "Delete secrets", + "delete_confirm_message": "Are you sure you want to delete these secrets?" + }, "error_notification": "Update project error", "validation": { "user_name_format": "Only letters, numbers, - or _" diff --git a/frontend/src/pages/Project/Details/Settings/index.tsx b/frontend/src/pages/Project/Details/Settings/index.tsx index a945c513da..036681885f 100644 --- a/frontend/src/pages/Project/Details/Settings/index.tsx +++ b/frontend/src/pages/Project/Details/Settings/index.tsx @@ -36,6 +36,7 @@ import { useBackendsTable } from '../../Backends/hooks'; import { BackendsTable } from '../../Backends/Table'; import { GatewaysTable } from '../../Gateways'; import { useGatewaysTable } from '../../Gateways/hooks'; +import { ProjectSecrets } from '../../Secrets'; import { CLI_INFO } from './constants'; import styles from './styles.module.scss'; @@ -238,6 +239,8 @@ export const ProjectSettings: React.FC = () => { project={data} /> + + {t('common.danger_zone')}}>
diff --git a/frontend/src/pages/Project/Secrets/index.tsx b/frontend/src/pages/Project/Secrets/index.tsx new file mode 100644 index 0000000000..b485114a55 --- /dev/null +++ b/frontend/src/pages/Project/Secrets/index.tsx @@ -0,0 +1,233 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useFieldArray, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import { + Button, + ButtonWithConfirmation, + FormInput, + Header, + ListEmptyMessage, + Pagination, + SpaceBetween, + Table, +} from 'components'; + +import { useCollection } from 'hooks'; +import { useDeleteSecretsMutation, useGetAllSecretsQuery, useUpdateSecretMutation } from 'services/secrets'; + +import { IProps, TFormSecretValue, TFormValues, TProjectSecretWithIndex } from './types'; + +import styles from './styles.module.scss'; + +export const ProjectSecrets: React.FC = ({ project, loading }) => { + const { t } = useTranslation(); + const [editableRowIndex, setEditableRowIndex] = useState(null); + const projectName = project?.project_name ?? ''; + + const { data, isLoading, isFetching } = useGetAllSecretsQuery({ project_name: projectName }); + const [updateSecret, { isLoading: isUpdating }] = useUpdateSecretMutation(); + const [deleteSecret, { isLoading: isDeleting }] = useDeleteSecretsMutation(); + + const { handleSubmit, control, getValues, setValue } = useForm({ + defaultValues: { secrets: [] }, + }); + + useEffect(() => { + if (data) { + setValue( + 'secrets', + data.map((s) => ({ ...s, serverId: s.id })), + ); + } + }, [data]); + + const { fields, append, remove } = useFieldArray({ + control, + name: 'secrets', + }); + + const fieldsWithIndex = useMemo(() => { + return fields.map((field, index) => ({ ...field, index })); + }, [fields]); + + const { items, paginationProps, collectionProps } = useCollection(fieldsWithIndex, { + filtering: { + empty: ( + + ), + }, + pagination: { pageSize: 10 }, + selection: {}, + }); + + const { selectedItems } = collectionProps; + + const deleteSelectedSecrets = () => { + const names = selectedItems?.map((s) => s.name ?? ''); + + if (names?.length) { + deleteSecret({ project_name: projectName, names }).then(() => { + selectedItems?.forEach((s) => remove(s.index)); + }); + } + }; + + const removeSecretByIndex = (index: number) => { + const secretData = getValues().secrets?.[index]; + + if (!secretData || !secretData.name) { + return; + } + + deleteSecret({ project_name: projectName, names: [secretData.name] }).then(() => { + remove(index); + }); + }; + + const saveSecretByIndex = (index: number) => { + const secretData = getValues().secrets?.[index]; + + if (!secretData || !secretData.name || !secretData.value) { + return; + } + + updateSecret({ project_name: projectName, name: secretData.name, value: secretData.value }) + .unwrap() + .then(() => { + setEditableRowIndex(null); + }); + }; + + const isDisabledEditableRowActions = loading || isLoading || isFetching || isUpdating; + const isDisabledNotEditableRowActions = loading || isLoading || isFetching || isDeleting; + + const COLUMN_DEFINITIONS = [ + { + id: 'name', + header: t('projects.edit.secrets.name'), + cell: (field: TFormSecretValue & { index: number }) => { + const isEditable = editableRowIndex === field.index; + + return ( +
+
+ +
+
+ ); + }, + }, + { + id: 'value', + header: t('projects.edit.secrets.value'), + cell: (field: TFormSecretValue & { index: number }) => { + const isEditable = editableRowIndex === field.index; + + return ( +
+
+ +
+ +
+ {isEditable && ( +
+
+ ); + }, + }, + ]; + + const addSecretHandler = () => { + append({}); + setEditableRowIndex(fields.length); + }; + + const renderActions = () => { + const actions = [ + , + + + {t('common.delete')} + , + ]; + + return actions.length > 0 ? ( + + {actions} + + ) : undefined; + }; + + return ( +
{})}> + + {t('projects.edit.secrets.section_title')} + + } + pagination={} + /> + + ); +}; diff --git a/frontend/src/pages/Project/Secrets/styles.module.scss b/frontend/src/pages/Project/Secrets/styles.module.scss new file mode 100644 index 0000000000..4e79b27124 --- /dev/null +++ b/frontend/src/pages/Project/Secrets/styles.module.scss @@ -0,0 +1,17 @@ +.value { + display: flex; + align-items: center; + gap: 20px; +} +.valueFieldWrapper { + flex-grow: 1; + flex-basis: 0; + max-width: 400px; +} +.buttonsWrapper { + min-width: 96px; + display: flex; + gap: 8px; + justify-content: flex-end; + margin-left: auto; +} \ No newline at end of file diff --git a/frontend/src/pages/Project/Secrets/types.ts b/frontend/src/pages/Project/Secrets/types.ts new file mode 100644 index 0000000000..b07a328883 --- /dev/null +++ b/frontend/src/pages/Project/Secrets/types.ts @@ -0,0 +1,8 @@ +export interface IProps { + loading?: boolean; + project?: IProject; +} + +export type TFormSecretValue = Partial; +export type TProjectSecretWithIndex = TFormSecretValue & { index: number }; +export type TFormValues = { secrets: TFormSecretValue[] }; diff --git a/frontend/src/services/secrets.ts b/frontend/src/services/secrets.ts new file mode 100644 index 0000000000..41e29878a3 --- /dev/null +++ b/frontend/src/services/secrets.ts @@ -0,0 +1,66 @@ +import { API } from 'api'; +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; + +import fetchBaseQueryHeaders from 'libs/fetchBaseQueryHeaders'; + +export const secretApi = createApi({ + reducerPath: 'secretApi', + baseQuery: fetchBaseQuery({ + prepareHeaders: fetchBaseQueryHeaders, + }), + + tagTypes: ['Secrets'], + + endpoints: (builder) => ({ + getAllSecrets: builder.query({ + query: ({ project_name }) => { + return { + url: API.PROJECTS.SECRETS_LIST(project_name), + method: 'POST', + }; + }, + + providesTags: (result) => + result ? [...result.map(({ id }) => ({ type: 'Secrets' as const, id: id })), 'Secrets'] : ['Secrets'], + }), + + getSecret: builder.query({ + query: ({ project_name, name }) => ({ + url: API.PROJECTS.SECRET_GET(project_name), + method: 'POST', + body: { + name, + }, + }), + + providesTags: (result) => (result ? [{ type: 'Secrets' as const, id: result.id }, 'Secrets'] : ['Secrets']), + }), + + updateSecret: builder.mutation< + void, + { project_name: IProject['project_name']; name: IProjectSecret['name']; value: IProjectSecret['value'] } + >({ + query: ({ project_name, ...body }) => ({ + url: API.PROJECTS.SECRETS_UPDATE(project_name), + method: 'POST', + body: body, + }), + + invalidatesTags: () => ['Secrets'], + }), + + deleteSecrets: builder.mutation({ + query: ({ project_name, names }) => ({ + url: API.PROJECTS.SECRETS_DELETE(project_name), + method: 'POST', + body: { + secrets_names: names, + }, + }), + + invalidatesTags: () => ['Secrets'], + }), + }), +}); + +export const { useGetAllSecretsQuery, useGetSecretQuery, useUpdateSecretMutation, useDeleteSecretsMutation } = secretApi; diff --git a/frontend/src/store.ts b/frontend/src/store.ts index d57b01a7f7..7d6afb8cf5 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -10,6 +10,7 @@ import { instanceApi } from 'services/instance'; import { mainApi } from 'services/mainApi'; import { projectApi } from 'services/project'; import { runApi } from 'services/run'; +import { secretApi } from 'services/secrets'; import { serverApi } from 'services/server'; import { userApi } from 'services/user'; import { volumeApi } from 'services/volume'; @@ -30,6 +31,7 @@ export const store = configureStore({ [authApi.reducerPath]: authApi.reducer, [serverApi.reducerPath]: serverApi.reducer, [volumeApi.reducerPath]: volumeApi.reducer, + [secretApi.reducerPath]: secretApi.reducer, [mainApi.reducerPath]: mainApi.reducer, }, @@ -47,6 +49,7 @@ export const store = configureStore({ .concat(authApi.middleware) .concat(serverApi.middleware) .concat(volumeApi.middleware) + .concat(secretApi.middleware) .concat(mainApi.middleware), }); diff --git a/frontend/src/types/project.d.ts b/frontend/src/types/project.d.ts index 6121b6e6ff..77eab81966 100644 --- a/frontend/src/types/project.d.ts +++ b/frontend/src/types/project.d.ts @@ -1,27 +1,33 @@ declare type TProjectBackend = { - name: string, - config: IBackendAWS | IBackendAzure | IBackendGCP | IBackendLambda | IBackendLocal | IBackendDstack -} + name: string; + config: IBackendAWS | IBackendAzure | IBackendGCP | IBackendLambda | IBackendLocal | IBackendDstack; +}; declare interface IProject { - project_name: string, - members: IProjectMember[], - backends: TProjectBackend[] - owner: IUser | {username: string}, - created_at: string, - isPublic: boolean + project_name: string; + members: IProjectMember[]; + backends: TProjectBackend[]; + owner: IUser | { username: string }; + created_at: string; + isPublic: boolean; } declare interface IProjectMember { - project_role: TProjectRole, - user: IUser | {username: string} + project_role: TProjectRole; + user: IUser | { username: string }; } declare type TSetProjectMembersParams = { - project_name: string, + project_name: string; members: Array<{ - project_role: TProjectRole, - username: string - }> -} + project_role: TProjectRole; + username: string; + }>; +}; -declare type TProjectRole = TUserRole | 'manager' +declare type TProjectRole = TUserRole | 'manager'; + +declare interface IProjectSecret { + id: string; + name: string; + value?: string; +} From e4154ed1394378432b5f9a0656220ae7a5725538 Mon Sep 17 00:00:00 2001 From: Oleg Vavilov Date: Wed, 16 Jul 2025 22:08:05 +0300 Subject: [PATCH 2/8] Added run lint in github actions --- .github/workflows/build.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6b26d1bbb5..192268ad60 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -35,6 +35,18 @@ jobs: - run: uv tool install pre-commit - run: pre-commit run -a --show-diff-on-failure + frontend-lint: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - name: Install modules + run: npm install + - name: Run Eslint + run: eslint --ext .js,.jsx,.ts,.tsx --max-warnings=0 --no-warn-ignored + frontend-build: runs-on: ubuntu-latest defaults: From bed024ba1533b2fd1f283804902d7e5ad4ba54ef Mon Sep 17 00:00:00 2001 From: Oleg Vavilov Date: Wed, 16 Jul 2025 22:12:01 +0300 Subject: [PATCH 3/8] Added run lint in github actions --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 192268ad60..645e15c596 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -42,6 +42,7 @@ jobs: working-directory: frontend steps: - uses: actions/checkout@v4 + - uses: stefanoeb/eslint-action@1.0.2 - name: Install modules run: npm install - name: Run Eslint From 6484b2e820d8a04204065da60ad36e4717e92503 Mon Sep 17 00:00:00 2001 From: Oleg Vavilov Date: Wed, 16 Jul 2025 22:18:49 +0300 Subject: [PATCH 4/8] Added run lint in github actions --- .github/workflows/build.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 645e15c596..5cc65bbc1d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -42,11 +42,10 @@ jobs: working-directory: frontend steps: - uses: actions/checkout@v4 - - uses: stefanoeb/eslint-action@1.0.2 - name: Install modules run: npm install - name: Run Eslint - run: eslint --ext .js,.jsx,.ts,.tsx --max-warnings=0 --no-warn-ignored + run: npm run precommit frontend-build: runs-on: ubuntu-latest From 565fe351c7ef54b33b6715636418f93c8833efc2 Mon Sep 17 00:00:00 2001 From: Oleg Vavilov Date: Mon, 28 Jul 2025 21:47:08 +0300 Subject: [PATCH 5/8] [UI] Fix secrets after review #2882 --- frontend/src/locale/en.json | 11 +- .../src/pages/Project/Secrets/Form/index.tsx | 71 ++++++ frontend/src/pages/Project/Secrets/index.tsx | 208 +++++++----------- frontend/src/pages/Project/Secrets/types.ts | 4 +- frontend/src/services/secrets.ts | 10 +- 5 files changed, 171 insertions(+), 133 deletions(-) create mode 100644 frontend/src/pages/Project/Secrets/Form/index.tsx diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index d532d36d62..9da4316698 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -303,8 +303,15 @@ "empty_message_text": "No secrets to display.", "name": "Secret name", "value": "Secret value", - "delete_confirm_title": "Delete secrets", - "delete_confirm_message": "Are you sure you want to delete these secrets?" + "create_secret": "Create secret", + "update_secret": "Update secret", + "delete_confirm_title": "Delete secret", + "delete_confirm_message": "Are you sure you want to delete the {{name}} secret?", + "multiple_delete_confirm_title": "Delete secrets", + "multiple_delete_confirm_message": "Are you sure you want to delete {{count}} secrets?", + "validation": { + "secret_name_format": "Invalid secret name" + } }, "error_notification": "Update project error", "validation": { diff --git a/frontend/src/pages/Project/Secrets/Form/index.tsx b/frontend/src/pages/Project/Secrets/Form/index.tsx new file mode 100644 index 0000000000..3ccce97493 --- /dev/null +++ b/frontend/src/pages/Project/Secrets/Form/index.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import { Box, Button, FormInput, FormTextarea, SpaceBetween } from 'components'; + +import { TFormValues } from '../types'; + +export type IFormProps = { + initialValues?: TFormValues; + onSubmit: (values: TFormValues) => void; + onCancel?: () => void; + loading?: boolean; +}; + +export const SecretForm: React.FC = ({ initialValues, onSubmit: onSubmitProp, loading, onCancel }) => { + const { t } = useTranslation(); + const { handleSubmit, control } = useForm({ + defaultValues: { + ...initialValues, + }, + }); + + const onSubmit = (values: TFormValues) => onSubmitProp(values); + + return ( +
+ + + + + + + + {onCancel && ( + + )} + + + + + + + ); +}; diff --git a/frontend/src/pages/Project/Secrets/index.tsx b/frontend/src/pages/Project/Secrets/index.tsx index b485114a55..0082189abb 100644 --- a/frontend/src/pages/Project/Secrets/index.tsx +++ b/frontend/src/pages/Project/Secrets/index.tsx @@ -1,57 +1,35 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { useFieldArray, useForm } from 'react-hook-form'; +import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { Button, ButtonWithConfirmation, Header, ListEmptyMessage, Modal, Pagination, SpaceBetween, Table } from 'components'; + +import { useCollection, useNotifications } from 'hooks'; import { - Button, - ButtonWithConfirmation, - FormInput, - Header, - ListEmptyMessage, - Pagination, - SpaceBetween, - Table, -} from 'components'; + useDeleteSecretsMutation, + useGetAllSecretsQuery, + useLazyGetSecretQuery, + useUpdateSecretMutation, +} from 'services/secrets'; -import { useCollection } from 'hooks'; -import { useDeleteSecretsMutation, useGetAllSecretsQuery, useUpdateSecretMutation } from 'services/secrets'; +import { getServerError } from '../../../libs'; +import { SecretForm } from './Form'; -import { IProps, TFormSecretValue, TFormValues, TProjectSecretWithIndex } from './types'; +import { IProps, TFormValues } from './types'; import styles from './styles.module.scss'; export const ProjectSecrets: React.FC = ({ project, loading }) => { const { t } = useTranslation(); - const [editableRowIndex, setEditableRowIndex] = useState(null); + const [initialFormValues, setInitialFormValues] = useState(); const projectName = project?.project_name ?? ''; + const [pushNotification] = useNotifications(); const { data, isLoading, isFetching } = useGetAllSecretsQuery({ project_name: projectName }); const [updateSecret, { isLoading: isUpdating }] = useUpdateSecretMutation(); const [deleteSecret, { isLoading: isDeleting }] = useDeleteSecretsMutation(); + const [getSecret, { isLoading: isGettingSecrets }] = useLazyGetSecretQuery(); - const { handleSubmit, control, getValues, setValue } = useForm({ - defaultValues: { secrets: [] }, - }); - - useEffect(() => { - if (data) { - setValue( - 'secrets', - data.map((s) => ({ ...s, serverId: s.id })), - ); - } - }, [data]); - - const { fields, append, remove } = useFieldArray({ - control, - name: 'secrets', - }); - - const fieldsWithIndex = useMemo(() => { - return fields.map((field, index) => ({ ...field, index })); - }, [fields]); - - const { items, paginationProps, collectionProps } = useCollection(fieldsWithIndex, { + const { items, paginationProps, collectionProps } = useCollection(data ?? [], { filtering: { empty: ( = ({ project, loading }) => { const names = selectedItems?.map((s) => s.name ?? ''); if (names?.length) { - deleteSecret({ project_name: projectName, names }).then(() => { - selectedItems?.forEach((s) => remove(s.index)); - }); + deleteSecret({ project_name: projectName, names }); } }; - const removeSecretByIndex = (index: number) => { - const secretData = getValues().secrets?.[index]; - - if (!secretData || !secretData.name) { - return; - } - - deleteSecret({ project_name: projectName, names: [secretData.name] }).then(() => { - remove(index); - }); + const removeSecretByName = (name: IProjectSecret['name']) => { + deleteSecret({ project_name: projectName, names: [name] }); }; - const saveSecretByIndex = (index: number) => { - const secretData = getValues().secrets?.[index]; - - if (!secretData || !secretData.name || !secretData.value) { + const updateOrCreateSecret = ({ name, value }: TFormValues) => { + if (!name || !value) { return; } - updateSecret({ project_name: projectName, name: secretData.name, value: secretData.value }) + updateSecret({ project_name: projectName, name, value }) .unwrap() - .then(() => { - setEditableRowIndex(null); + .then(() => setInitialFormValues(undefined)) + .catch((error) => { + pushNotification({ + type: 'error', + content: t('common.server_error', { error: getServerError(error) }), + }); }); }; - const isDisabledEditableRowActions = loading || isLoading || isFetching || isUpdating; - const isDisabledNotEditableRowActions = loading || isLoading || isFetching || isDeleting; + const editSecret = ({ name }: IProjectSecret) => { + getSecret({ project_name: projectName, name }) + .unwrap() + .then((secret) => setInitialFormValues(secret)); + }; + + const closeModal = () => setInitialFormValues(undefined); + + const isDisabledActions = loading || isLoading || isFetching || isDeleting || isGettingSecrets; const COLUMN_DEFINITIONS = [ { id: 'name', header: t('projects.edit.secrets.name'), - cell: (field: TFormSecretValue & { index: number }) => { - const isEditable = editableRowIndex === field.index; - - return ( -
-
- -
-
- ); - }, + cell: (secret: IProjectSecret) => secret.name, }, { id: 'value', header: t('projects.edit.secrets.value'), - cell: (field: TFormSecretValue & { index: number }) => { - const isEditable = editableRowIndex === field.index; - + cell: (secret: IProjectSecret) => { return (
-
- -
+
************************
- {isEditable && ( -
); @@ -184,8 +122,7 @@ export const ProjectSecrets: React.FC = ({ project, loading }) => { ]; const addSecretHandler = () => { - append({}); - setEditableRowIndex(fields.length); + setInitialFormValues({}); }; const renderActions = () => { @@ -196,11 +133,11 @@ export const ProjectSecrets: React.FC = ({ project, loading }) => { {t('common.delete')} , @@ -213,8 +150,10 @@ export const ProjectSecrets: React.FC = ({ project, loading }) => { ) : undefined; }; + const isShowModal = !!initialFormValues; + return ( -
{})}> + <>
= ({ project, loading }) => { } pagination={} /> - + + + {isShowModal && ( + + )} + + ); }; diff --git a/frontend/src/pages/Project/Secrets/types.ts b/frontend/src/pages/Project/Secrets/types.ts index b07a328883..ee630b27af 100644 --- a/frontend/src/pages/Project/Secrets/types.ts +++ b/frontend/src/pages/Project/Secrets/types.ts @@ -3,6 +3,4 @@ export interface IProps { project?: IProject; } -export type TFormSecretValue = Partial; -export type TProjectSecretWithIndex = TFormSecretValue & { index: number }; -export type TFormValues = { secrets: TFormSecretValue[] }; +export type TFormValues = Partial; diff --git a/frontend/src/services/secrets.ts b/frontend/src/services/secrets.ts index 41e29878a3..6753c29e4b 100644 --- a/frontend/src/services/secrets.ts +++ b/frontend/src/services/secrets.ts @@ -24,7 +24,7 @@ export const secretApi = createApi({ result ? [...result.map(({ id }) => ({ type: 'Secrets' as const, id: id })), 'Secrets'] : ['Secrets'], }), - getSecret: builder.query({ + getSecret: builder.query({ query: ({ project_name, name }) => ({ url: API.PROJECTS.SECRET_GET(project_name), method: 'POST', @@ -63,4 +63,10 @@ export const secretApi = createApi({ }), }); -export const { useGetAllSecretsQuery, useGetSecretQuery, useUpdateSecretMutation, useDeleteSecretsMutation } = secretApi; +export const { + useGetAllSecretsQuery, + useGetSecretQuery, + useLazyGetSecretQuery, + useUpdateSecretMutation, + useDeleteSecretsMutation, +} = secretApi; From 6fdcb36337e8e2958caf20ab79d4d770caa0a3c4 Mon Sep 17 00:00:00 2001 From: Oleg Vavilov Date: Wed, 30 Jul 2025 00:25:56 +0300 Subject: [PATCH 6/8] [UI] Fix secrets after review #2882 --- frontend/src/locale/en.json | 2 + frontend/src/pages/Project/Secrets/index.tsx | 68 ++++++++++++++------ 2 files changed, 49 insertions(+), 21 deletions(-) diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 9da4316698..171953c642 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -309,6 +309,8 @@ "delete_confirm_message": "Are you sure you want to delete the {{name}} secret?", "multiple_delete_confirm_title": "Delete secrets", "multiple_delete_confirm_message": "Are you sure you want to delete {{count}} secrets?", + "not_permissions_title": "Permission denied", + "not_permissions_description": "You don't have permission for managing secrets", "validation": { "secret_name_format": "Invalid secret name" } diff --git a/frontend/src/pages/Project/Secrets/index.tsx b/frontend/src/pages/Project/Secrets/index.tsx index 0082189abb..9aee0cde89 100644 --- a/frontend/src/pages/Project/Secrets/index.tsx +++ b/frontend/src/pages/Project/Secrets/index.tsx @@ -3,15 +3,16 @@ import { useTranslation } from 'react-i18next'; import { Button, ButtonWithConfirmation, Header, ListEmptyMessage, Modal, Pagination, SpaceBetween, Table } from 'components'; -import { useCollection, useNotifications } from 'hooks'; +import { useCollection, useNotifications, usePermissionGuard } from 'hooks'; +import { getServerError } from 'libs'; import { useDeleteSecretsMutation, useGetAllSecretsQuery, useLazyGetSecretQuery, useUpdateSecretMutation, } from 'services/secrets'; +import { GlobalUserRole, ProjectUserRole } from 'types'; -import { getServerError } from '../../../libs'; import { SecretForm } from './Form'; import { IProps, TFormValues } from './types'; @@ -24,18 +25,31 @@ export const ProjectSecrets: React.FC = ({ project, loading }) => { const projectName = project?.project_name ?? ''; const [pushNotification] = useNotifications(); - const { data, isLoading, isFetching } = useGetAllSecretsQuery({ project_name: projectName }); + const [hasPermissionForSecretsManaging] = usePermissionGuard({ + allowedProjectRoles: [ProjectUserRole.ADMIN], + allowedGlobalRoles: [GlobalUserRole.ADMIN], + }); + + const { data, isLoading, isFetching } = useGetAllSecretsQuery( + { project_name: projectName }, + { skip: !hasPermissionForSecretsManaging }, + ); const [updateSecret, { isLoading: isUpdating }] = useUpdateSecretMutation(); const [deleteSecret, { isLoading: isDeleting }] = useDeleteSecretsMutation(); const [getSecret, { isLoading: isGettingSecrets }] = useLazyGetSecretQuery(); const { items, paginationProps, collectionProps } = useCollection(data ?? [], { filtering: { - empty: ( + empty: hasPermissionForSecretsManaging ? ( + ) : ( + ), }, pagination: { pageSize: 10 }, @@ -126,6 +140,10 @@ export const ProjectSecrets: React.FC = ({ project, loading }) => { }; const renderActions = () => { + if (!hasPermissionForSecretsManaging) { + return null; + } + const actions = [