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 (
+
+ );
+};
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;
+}