diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6b26d1bbb5..5cc65bbc1d 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: npm run precommit + frontend-build: runs-on: ubuntu-latest defaults: 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..fd6e9ec928 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -297,6 +297,24 @@ "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", + "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?", + "not_permissions_title": "No permissions", + "not_permissions_description": "You don't have permissions for managing secrets", + "validation": { + "secret_name_format": "Invalid secret name" + } + }, "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/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 new file mode 100644 index 0000000000..9aee0cde89 --- /dev/null +++ b/frontend/src/pages/Project/Secrets/index.tsx @@ -0,0 +1,215 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Button, ButtonWithConfirmation, Header, ListEmptyMessage, Modal, Pagination, SpaceBetween, Table } from 'components'; + +import { useCollection, useNotifications, usePermissionGuard } from 'hooks'; +import { getServerError } from 'libs'; +import { + useDeleteSecretsMutation, + useGetAllSecretsQuery, + useLazyGetSecretQuery, + useUpdateSecretMutation, +} from 'services/secrets'; +import { GlobalUserRole, ProjectUserRole } from 'types'; + +import { SecretForm } from './Form'; + +import { IProps, TFormValues } from './types'; + +import styles from './styles.module.scss'; + +export const ProjectSecrets: React.FC = ({ project, loading }) => { + const { t } = useTranslation(); + const [initialFormValues, setInitialFormValues] = useState(); + const projectName = project?.project_name ?? ''; + const [pushNotification] = useNotifications(); + + 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: hasPermissionForSecretsManaging ? ( + + ) : ( + + ), + }, + pagination: { pageSize: 10 }, + selection: {}, + }); + + const { selectedItems } = collectionProps; + + const deleteSelectedSecrets = () => { + const names = selectedItems?.map((s) => s.name ?? ''); + + if (names?.length) { + deleteSecret({ project_name: projectName, names }); + } + }; + + const removeSecretByName = (name: IProjectSecret['name']) => { + deleteSecret({ project_name: projectName, names: [name] }); + }; + + const updateOrCreateSecret = ({ name, value }: TFormValues) => { + if (!name || !value) { + return; + } + + updateSecret({ project_name: projectName, name, value }) + .unwrap() + .then(() => setInitialFormValues(undefined)) + .catch((error) => { + pushNotification({ + type: 'error', + content: t('common.server_error', { error: getServerError(error) }), + }); + }); + }; + + 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: (secret: IProjectSecret) => secret.name, + }, + { + id: 'value', + header: t('projects.edit.secrets.value'), + cell: (secret: IProjectSecret) => { + return ( +
+
************************
+ +
+
+
+ ); + }, + }, + ]; + + const addSecretHandler = () => { + setInitialFormValues({}); + }; + + const renderActions = () => { + if (!hasPermissionForSecretsManaging) { + return null; + } + + const actions = [ + , + + + {t('common.delete')} + , + ]; + + return actions.length > 0 ? ( + + {actions} + + ) : undefined; + }; + + const isShowModal = !!initialFormValues; + + return ( + <> + + {t('projects.edit.secrets.section_title')} + + } + pagination={} + /> + + {hasPermissionForSecretsManaging && ( + + {isShowModal && ( + + )} + + )} + + ); +}; 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..5a82457878 --- /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; +} diff --git a/frontend/src/pages/Project/Secrets/types.ts b/frontend/src/pages/Project/Secrets/types.ts new file mode 100644 index 0000000000..ee630b27af --- /dev/null +++ b/frontend/src/pages/Project/Secrets/types.ts @@ -0,0 +1,6 @@ +export interface IProps { + loading?: boolean; + project?: IProject; +} + +export type TFormValues = Partial; diff --git a/frontend/src/services/secrets.ts b/frontend/src/services/secrets.ts new file mode 100644 index 0000000000..6753c29e4b --- /dev/null +++ b/frontend/src/services/secrets.ts @@ -0,0 +1,72 @@ +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, + useLazyGetSecretQuery, + 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; +}