Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
18 changes: 18 additions & 0 deletions frontend/src/locale/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 _"
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/pages/Project/Details/Settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -238,6 +239,8 @@ export const ProjectSettings: React.FC = () => {
project={data}
/>

<ProjectSecrets project={data} />

<Container header={<Header variant="h2">{t('common.danger_zone')}</Header>}>
<SpaceBetween size="l">
<div className={styles.dangerSectionGrid}>
Expand Down
71 changes: 71 additions & 0 deletions frontend/src/pages/Project/Secrets/Form/index.tsx
Original file line number Diff line number Diff line change
@@ -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<IFormProps> = ({ initialValues, onSubmit: onSubmitProp, loading, onCancel }) => {
const { t } = useTranslation();
const { handleSubmit, control } = useForm<TFormValues>({
defaultValues: {
...initialValues,
},
});

const onSubmit = (values: TFormValues) => onSubmitProp(values);

return (
<form onSubmit={handleSubmit(onSubmit)}>
<SpaceBetween direction="vertical" size="l">
<FormInput
label={t('projects.edit.secrets.name')}
control={control}
name="name"
disabled={loading || !!initialValues?.id}
rules={{
required: t('validation.required'),

pattern: {
value: /^[A-Za-z0-9-_]{1,200}$/,
message: t('projects.edit.secrets.validation.secret_name_format'),
},
}}
/>

<FormTextarea
rows={6}
label={t('projects.edit.secrets.value')}
control={control}
name="value"
disabled={loading}
rules={{
required: t('validation.required'),
}}
/>

<Box float="right">
<SpaceBetween size="l" direction="horizontal">
{onCancel && (
<Button formAction="none" disabled={loading} onClick={onCancel}>
{t('common.cancel')}
</Button>
)}

<Button variant="primary" formAction="submit" loading={loading} disabled={loading}>
{t('common.save')}
</Button>
</SpaceBetween>
</Box>
</SpaceBetween>
</form>
);
};
215 changes: 215 additions & 0 deletions frontend/src/pages/Project/Secrets/index.tsx
Original file line number Diff line number Diff line change
@@ -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<IProps> = ({ project, loading }) => {
const { t } = useTranslation();
const [initialFormValues, setInitialFormValues] = useState<TFormValues | undefined>();
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 ? (
<ListEmptyMessage
title={t('projects.edit.secrets.empty_message_title')}
message={t('projects.edit.secrets.empty_message_text')}
/>
) : (
<ListEmptyMessage
title={t('projects.edit.secrets.not_permissions_title')}
message={t('projects.edit.secrets.not_permissions_description')}
/>
),
},
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 (
<div className={styles.value}>
<div className={styles.valueFieldWrapper}>************************</div>

<div className={styles.buttonsWrapper}>
<Button
disabled={isDisabledActions}
formAction="none"
onClick={() => editSecret(secret)}
variant="icon"
iconName="edit"
/>

<ButtonWithConfirmation
disabled={isDisabledActions}
formAction="none"
onClick={() => removeSecretByName(secret.name)}
confirmTitle={t('projects.edit.secrets.delete_confirm_title')}
confirmContent={t('projects.edit.secrets.delete_confirm_message', { name: secret.name })}
variant="icon"
iconName="remove"
/>
</div>
</div>
);
},
},
];

const addSecretHandler = () => {
setInitialFormValues({});
};

const renderActions = () => {
if (!hasPermissionForSecretsManaging) {
return null;
}

const actions = [
<Button key="add" formAction="none" onClick={addSecretHandler}>
{t('common.add')}
</Button>,

<ButtonWithConfirmation
key="delete"
disabled={isDisabledActions || !selectedItems?.length}
formAction="none"
onClick={deleteSelectedSecrets}
confirmTitle={t('projects.edit.secrets.multiple_delete_confirm_title')}
confirmContent={t('projects.edit.secrets.multiple_delete_confirm_message', { count: selectedItems?.length })}
>
{t('common.delete')}
</ButtonWithConfirmation>,
];

return actions.length > 0 ? (
<SpaceBetween size="xs" direction="horizontal">
{actions}
</SpaceBetween>
) : undefined;
};

const isShowModal = !!initialFormValues;

return (
<>
<Table
{...collectionProps}
selectionType="multi"
columnDefinitions={COLUMN_DEFINITIONS}
items={items}
loading={isLoading}
header={
<Header
variant="h2"
counter={hasPermissionForSecretsManaging ? `(${items?.length})` : ''}
actions={renderActions()}
>
{t('projects.edit.secrets.section_title')}
</Header>
}
pagination={<Pagination {...paginationProps} />}
/>

{hasPermissionForSecretsManaging && (
<Modal
header={
initialFormValues?.id
? t('projects.edit.secrets.update_secret')
: t('projects.edit.secrets.create_secret')
}
visible={isShowModal}
onDismiss={closeModal}
>
{isShowModal && (
<SecretForm
initialValues={initialFormValues}
onSubmit={updateOrCreateSecret}
loading={isLoading || isUpdating}
onCancel={closeModal}
/>
)}
</Modal>
)}
</>
);
};
17 changes: 17 additions & 0 deletions frontend/src/pages/Project/Secrets/styles.module.scss
Original file line number Diff line number Diff line change
@@ -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;
}
6 changes: 6 additions & 0 deletions frontend/src/pages/Project/Secrets/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export interface IProps {
loading?: boolean;
project?: IProject;
}

export type TFormValues = Partial<IProjectSecret>;
Loading
Loading