Files
infisical/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenSection.tsx
T

373 lines
13 KiB
TypeScript

import { useEffect, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { faCheck, faCopy, faKey, faPlus, faTrashCan } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
import {
Button,
Checkbox,
DeleteActionModal,
EmptyState,
FormControl,
IconButton,
Input,
Modal,
ModalClose,
ModalContent,
ModalTrigger,
Select,
SelectItem,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from '@app/components/v2';
import { usePopUp, useToggle } from '@app/hooks';
import { ServiceToken, WorkspaceEnv } from '@app/hooks/api/types';
const apiTokenExpiry = [
{ label: '1 Day', value: 86400 },
{ label: '7 Days', value: 604800 },
{ label: '1 Month', value: 2592000 },
{ label: '6 months', value: 15552000 },
{ label: '12 months', value: 31104000 },
{ label: 'Never', value: null }
];
const createServiceTokenSchema = yup.object({
name: yup.string().required().label('Service Token Name'),
environment: yup.string().required().label('Environment'),
expiresIn: yup.string().optional().label('Service Token Expiration'),
permissions: yup
.object()
.shape({
read: yup.boolean().required(),
write: yup.boolean().required()
})
.defined()
.required()
});
export type CreateServiceToken = yup.InferType<typeof createServiceTokenSchema>;
type Props = {
tokens: ServiceToken[];
isLoading?: boolean;
workspaceName: string;
environments: WorkspaceEnv[];
onDeleteToken: (serviceTokenID: string) => Promise<void>;
onCreateToken: (data: CreateServiceToken) => Promise<string>;
};
type DeleteModalData = { name: string; id: string };
export const ServiceTokenSection = ({
tokens = [],
isLoading,
onDeleteToken,
workspaceName,
environments = [],
onCreateToken
}: Props): JSX.Element => {
const [newToken, setToken] = useState('');
const { t } = useTranslation();
const [isTokenCopied, setIsTokenCopied] = useToggle(false);
useEffect(() => {
let timer: NodeJS.Timeout;
if (isTokenCopied) {
timer = setTimeout(() => setIsTokenCopied.off(), 2000);
}
return () => clearTimeout(timer);
}, [isTokenCopied]);
const copyTokenToClipboard = () => {
navigator.clipboard.writeText(newToken);
setIsTokenCopied.on();
};
const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([
'createAPIToken',
'deleteAPITokenConfirmation'
] as const);
const {
control,
reset,
handleSubmit,
formState: { isSubmitting }
} = useForm<CreateServiceToken>({
resolver: yupResolver(createServiceTokenSchema)
});
const hasServiceToken = Boolean(newToken);
const onFormSubmit = async (data: CreateServiceToken) => {
const token = await onCreateToken(data);
setToken(token);
};
const onDeleteApproved = async () => {
await onDeleteToken((popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.id);
handlePopUpClose('deleteAPITokenConfirmation');
};
return (
<div className="mt-4 mb-4 flex w-full flex-col items-start rounded-md bg-white/5 p-6">
<div className="flex w-full flex-row justify-between">
<div className="flex w-full flex-col">
<p className="mb-3 text-xl font-semibold">{t('section.token.service-tokens')}</p>
<p className="text-sm text-gray-400">{t('section.token.service-tokens-description')}</p>
<p className="mb-4 text-sm text-gray-400">
Please, make sure you are on the
<a
className="ml-1 text-primary underline underline-offset-2"
href="https://infisical.com/docs/cli/overview"
target="_blank"
rel="noreferrer"
>
latest version of CLI
</a>
.
</p>
</div>
<div>
<Modal
isOpen={popUp?.createAPIToken?.isOpen}
onOpenChange={(open) => {
handlePopUpToggle('createAPIToken', open);
reset();
setToken('');
}}
>
<ModalTrigger asChild>
<Button color="mineshaft" leftIcon={<FontAwesomeIcon icon={faPlus} />}>
{t('section.token.add-new')}
</Button>
</ModalTrigger>
<ModalContent
title={
t('section.token.add-dialog.title', {
target: workspaceName
}) as string
}
subTitle={t('section.token.add-dialog.description') as string}
>
{!hasServiceToken ? (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="name"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label={t('section.token.add-dialog.name')}
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Type your token name" />
</FormControl>
)}
/>
<Controller
control={control}
name="environment"
defaultValue={environments?.[0]?.slug}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Environment"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{environments.map(({ name, slug }) => (
<SelectItem value={slug} key={slug}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="expiresIn"
defaultValue={String(apiTokenExpiry?.[0]?.value)}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Expiration"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{apiTokenExpiry.map(({ label, value }) => (
<SelectItem value={String(value || '')} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="permissions"
defaultValue={{
read: true,
write: false
}}
render={({ field: { onChange, value }, fieldState: { error } }) => {
const options = [
{
label: 'Read (default)',
value: 'read'
},
{
label: 'Write (optional)',
value: 'write'
}
];
return (
<FormControl
label="Permissions"
errorText={error?.message}
isError={Boolean(error)}
>
<>
{options.map(({ label, value: optionValue }) => {
// TODO: refactor
return (
<Checkbox
id={value[optionValue]}
key={optionValue}
className="data-[state=checked]:bg-primary"
isChecked={value[optionValue]}
isDisabled={optionValue === 'read'}
onCheckedChange={(state) => {
onChange({
...value,
[optionValue]: state
});
}}
>
{label}
</Checkbox>
);
})}
</>
</FormControl>
);
}}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
type="submit"
isDisabled={isSubmitting}
isLoading={isSubmitting}
>
Create
</Button>
<ModalClose asChild>
<Button variant="plain" colorSchema="secondary">
Cancel
</Button>
</ModalClose>
</div>
</form>
) : (
<div className="mt-2 mb-3 mr-2 flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{newToken}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={copyTokenToClipboard}
>
<FontAwesomeIcon icon={isTokenCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
{t('common.click-to-copy')}
</span>
</IconButton>
</div>
)}
</ModalContent>
</Modal>
</div>
</div>
<DeleteActionModal
isOpen={popUp.deleteAPITokenConfirmation.isOpen}
title={`Delete ${
(popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.name || ' '
} service token?`}
onChange={(isOpen) => handlePopUpToggle('deleteAPITokenConfirmation', isOpen)}
deleteKey={(popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.name}
onClose={() => handlePopUpClose('deleteAPITokenConfirmation')}
onDeleteApproved={onDeleteApproved}
/>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Token Name</Th>
<Th>Environment</Th>
<Th>Valid Until</Th>
<Th aria-label="button" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={4} key="project-service-tokens" />}
{!isLoading &&
tokens.map((row) => (
<Tr key={row._id}>
<Td>{row.name}</Td>
<Td>{row.environment}</Td>
<Td>{row.expiresAt && new Date(row.expiresAt).toUTCString()}</Td>
<Td className="flex items-center justify-end">
<IconButton
onClick={() =>
handlePopUpOpen('deleteAPITokenConfirmation', {
name: row.name,
id: row._id
})
}
colorSchema="danger"
ariaLabel="delete"
>
<FontAwesomeIcon icon={faTrashCan} />
</IconButton>
</Td>
</Tr>
))}
{!isLoading && tokens?.length === 0 && (
<Tr>
<Td colSpan={4} className="py-6 text-center text-bunker-400">
<EmptyState title="No service tokens found" icon={faKey} />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
</div>
);
};