Skip to content

Commit

Permalink
feat(ui): Permissions Management (#1939)
Browse files Browse the repository at this point in the history
Signed-off-by: Remington Breeze <remington@breeze.software>
  • Loading branch information
rbreeze authored and krancour committed May 4, 2024
1 parent 04b205b commit 3d475f9
Show file tree
Hide file tree
Showing 14 changed files with 694 additions and 11 deletions.
1 change: 1 addition & 0 deletions ui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export const App = () => (
element={<Project tab='analysisTemplates' />}
/>
<Route path={paths.projectEvents} element={<Project tab='events' />} />
<Route path={paths.projectRoles} element={<Project tab='roles' />} />
<Route path={paths.stage} element={<Project />} />
<Route path={paths.freight} element={<Project />} />
</Route>
Expand Down
1 change: 1 addition & 0 deletions ui/src/config/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const paths = {
projectCredentials: '/project/:name/credentials',
projectAnalysisTemplates: '/project/:name/analysis-templates',
projectEvents: '/project/:name/events',
projectRoles: '/project/:name/roles',
stage: '/project/:name/stage/:stageName',
freight: '/project/:name/freight/:freightName',

Expand Down
19 changes: 13 additions & 6 deletions ui/src/features/common/confirm-modal/confirm-modal.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { Modal } from 'antd';

import { ModalProps } from '../modal/use-modal';
import { Modal, ModalFuncProps } from 'antd';

export interface ConfirmProps {
title: string | React.ReactNode;
onOk: () => void;
hide: () => void;
content?: string | React.ReactNode;
}

Expand All @@ -13,15 +12,23 @@ export const ConfirmModal = ({
title = 'Are you sure?',
content,
hide,
visible
}: ConfirmProps & ModalProps) => {
visible,
...props
}: ConfirmProps & ModalFuncProps) => {
const onConfirm = () => {
onOk();
hide();
};

return (
<Modal open={visible} onCancel={hide} okText='Confirm' onOk={onConfirm} title={title}>
<Modal
open={visible}
onCancel={hide}
okText='Confirm'
onOk={onConfirm}
title={title}
{...props}
>
{content}
</Modal>
);
Expand Down
4 changes: 3 additions & 1 deletion ui/src/features/common/form/field-container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,20 @@ interface Props<T extends FieldValues> extends UseControllerProps<T> {
children: (props: UseControllerReturn<T>) => React.ReactNode;
label?: string;
formItemOptions?: Omit<FormItemProps, 'label'>;
className?: string;
}

export const FieldContainer = <T extends FieldValues>({
children,
label,
formItemOptions,
className,
...props
}: Props<T>) => {
const controller = useController(props);

return (
<Form layout='vertical' component='div'>
<Form layout='vertical' component='div' className={className}>
<Form.Item
{...{ ...formItemOptions, label }}
help={controller.fieldState.error?.message}
Expand Down
98 changes: 98 additions & 0 deletions ui/src/features/common/form/multi-string-editor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { faPlus } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Button, Input, Tag, Space, TagProps } from 'antd';
import { useEffect, useState } from 'react';

export const MultiStringEditor = ({
value,
onChange,
placeholder,
label,
className
}: {
value: string[];
onChange: (value: string[]) => void;
placeholder?: string;
label?: string;
className?: string;
}) => {
const [values, _setValues] = useState(value);
const [newValue, setNewValue] = useState('');

const setValues = (values: string[]) => {
_setValues(values);
onChange(values);
};

const addValue = () => {
if (!newValue || newValue === '') return;
setValues([...(values || []), newValue]);
setNewValue('');
};

// necessary for form to be reset properly
useEffect(() => {
_setValues(value);
}, [value]);

const _Tag = (props: TagProps) => (
<Tag className='py-1 px-2 text-sm' {...props}>
{props.children}
</Tag>
);

return (
<div className={className}>
<div className='flex items-center h-8'>
{label && <div className='text-xs uppercase font-semibold text-gray-500'>{label}</div>}
<div className='ml-auto flex items-center'>
{values?.length > 1 && (
<div
className='text-xs text-gray-400 cursor-pointer mr-2'
onClick={() => setValues([])}
>
Clear All
</div>
)}
</div>
</div>
<div className='rounded bg-gray-100 p-2'>
<div className='flex items-center mb-2 min-h-8 flex-wrap gap-2'>
{(values || []).map((v, i) => (
<_Tag
key={i}
closable
onClose={() => {
setValues(values.filter((_, j) => i !== j));
onChange(values.filter((_, j) => i !== j));
}}
>
<span style={{ paddingRight: '1px' }}>{v}</span>
</_Tag>
))}

{(values || []).length === 0 && (
<div className='text-gray-400 text-sm mx-auto'>Type below to add values</div>
)}
</div>

<div className='flex items-center w-full'>
<Space.Compact className='w-full'>
<Input
value={newValue}
placeholder={placeholder}
onChange={(e) => {
setNewValue(e.target.value);
}}
onSubmit={addValue}
onPressEnter={addValue}
/>
<Button type='primary' onClick={addValue}>
<FontAwesomeIcon icon={faPlus} />
</Button>
</Space.Compact>
</div>
</div>
</div>
);
};
2 changes: 2 additions & 0 deletions ui/src/features/common/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ export const DESCRIPTION_ANNOTATION_KEY = 'kargo.akuity.io/description';
export const getAlias = (freight?: Freight): string | undefined => {
return freight?.metadata?.labels[ALIAS_LABEL_KEY] || undefined;
};

export const dnsRegex = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$/;
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { z } from 'zod';
import { FieldContainer } from '@ui/features/common/form/field-container';
import { ModalComponentProps } from '@ui/features/common/modal/modal-context';
import { SegmentLabel } from '@ui/features/common/segment-label';
import { dnsRegex } from '@ui/features/common/utils';
import {
createCredentials,
updateCredentials
Expand All @@ -18,13 +19,11 @@ import { zodValidators } from '@ui/utils/validators';

import { constructDefaults, labelForKey, typeLabel } from './utils';

const credentialsNameRegex = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$/;

const createFormSchema = (editing?: boolean) =>
z
.object({
name: zodValidators.requiredString.regex(
credentialsNameRegex,
dnsRegex,
'Credentials name must be a valid DNS subdomain.'
),
description: z.string().optional(),
Expand Down
138 changes: 138 additions & 0 deletions ui/src/features/project/roles/create-role.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { useMutation } from '@connectrpc/connect-query';
import { faPeopleGroup } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { zodResolver } from '@hookform/resolvers/zod';
import { Button, Drawer, Input, Typography } from 'antd';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';

import { FieldContainer } from '@ui/features/common/form/field-container';
import { MultiStringEditor } from '@ui/features/common/form/multi-string-editor';
import { dnsRegex } from '@ui/features/common/utils';
import { PolicyRule } from '@ui/gen/k8s.io/api/rbac/v1/generated_pb';
import { Role } from '@ui/gen/rbac/v1alpha1/generated_pb';
import { createRole, updateRole } from '@ui/gen/service/v1alpha1/service-KargoService_connectquery';
import { zodValidators } from '@ui/utils/validators';

import { RuleEditor } from './rule-editor';
import { RulesTable } from './rules-table';

type Props = {
project: string;
onSuccess: () => void;
editing?: Role;
hide: () => void;
};

type AllowedFields = 'name' | 'emails' | 'subs' | 'groups';

const nonZeroArray = (name: string) =>
z.array(z.string()).min(0, `At least one ${name} is required`);

const formSchema = z.object({
name: zodValidators.requiredString.regex(dnsRegex, 'Role name must be a valid DNS subdomain.'),
emails: nonZeroArray('email'),
subs: nonZeroArray('sub'),
groups: nonZeroArray('group')
});

const multiFields: { name: AllowedFields; label?: string; placeholder: string }[] = [
{ name: 'emails', placeholder: 'email@corp.com' },
{ name: 'subs', label: 'Subjects', placeholder: 'mysubject' },
{ name: 'groups', placeholder: 'mygroup' }
];

export const CreateRole = ({ editing, onSuccess, project, hide }: Props) => {
const { control, handleSubmit } = useForm({
resolver: zodResolver(formSchema),
values: {
name: editing?.metadata?.name || '',
emails: editing?.emails || [],
subs: editing?.subs || [],
groups: editing?.groups || []
}
});

const { mutate } = useMutation(createRole, {
onSuccess: () => {
hide();
onSuccess();
}
});

const { mutate: update } = useMutation(updateRole, {
onSuccess: () => {
hide();
onSuccess();
}
});

const onSubmit = handleSubmit((values) => {
if (editing) {
return update({
role: { ...values, rules, metadata: { namespace: project, name: editing?.metadata?.name } }
});
} else {
mutate({ role: { ...values, rules, metadata: { name: values.name, namespace: project } } });
}
});

const [rules, setRules] = useState<PolicyRule[]>(editing?.rules || []);

return (
<Drawer open={true} onClose={() => hide()} width={'85%'} closable={false}>
<Typography.Title
level={2}
style={{ margin: 0, marginBottom: '0.5em' }}
className='flex items-center'
>
<FontAwesomeIcon icon={faPeopleGroup} className='mr-2' />
{editing ? 'Edit' : 'Create'} Role
<Button type='primary' className='ml-auto' onClick={onSubmit}>
Save
</Button>
</Typography.Title>
<div className='mb-6'>
<FieldContainer
label='Name'
name='name'
control={control}
formItemOptions={{ className: 'mb-4' }}
>
{({ field }) => (
<Input {...field} type='text' placeholder='my-role' disabled={!!editing} />
)}
</FieldContainer>
<div className='text-lg font-semibold mb-4'>OIDC Bindings</div>
<div className='flex items-start gap-4'>
{multiFields.map(({ name, placeholder, label }) => (
<FieldContainer
name={name}
control={control}
key={name}
className='w-1/3'
formItemOptions={{ className: 'mb-3' }}
>
{({ field }) => (
<MultiStringEditor
value={field.value as string[]}
onChange={field.onChange}
placeholder={placeholder}
label={label ? label : name}
/>
)}
</FieldContainer>
))}
</div>
</div>
<div>
<div className='text-lg font-semibold mb-4'>Rules</div>
<div className='flex gap-4'>
<RulesTable rules={rules} setRules={setRules} />
<RuleEditor onSuccess={(rule) => setRules([...rules, rule])} style={{ width: '600px' }} />
</div>
</div>
</Drawer>
);
};
36 changes: 36 additions & 0 deletions ui/src/features/project/roles/delete-role-modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { useMutation } from '@connectrpc/connect-query';
import { Modal } from 'antd';
import { useParams } from 'react-router-dom';

import { deleteRole } from '@ui/gen/service/v1alpha1/service-KargoService_connectquery';

export const DeleteRoleModal = ({
name,
hide,
onSuccess
}: {
name: string;
hide: () => void;
onSuccess: () => void;
}) => {
const { name: project } = useParams();
const { mutate } = useMutation(deleteRole, {
onSuccess: () => {
hide();
onSuccess();
}
});

return (
<Modal
title='Delete Role'
visible
onOk={() => {
mutate({ project, name });
}}
onCancel={hide}
>
<p>Are you sure you want to delete the role {name}?</p>
</Modal>
);
};
Loading

0 comments on commit 3d475f9

Please sign in to comment.