0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(console): user personal access tokens (#6444)

This commit is contained in:
wangsijie 2024-08-20 10:52:34 +08:00 committed by GitHub
parent e9b7b83aaa
commit 1c6b9321dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 327 additions and 0 deletions

View file

@ -0,0 +1,148 @@
import { type PersonalAccessToken } from '@logto/schemas';
import { addDays, format } from 'date-fns';
import { useCallback, useEffect, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import ReactModal from 'react-modal';
import Button from '@/ds-components/Button';
import DangerousRaw from '@/ds-components/DangerousRaw';
import FormField from '@/ds-components/FormField';
import ModalLayout from '@/ds-components/ModalLayout';
import Select from '@/ds-components/Select';
import TextInput from '@/ds-components/TextInput';
import useApi from '@/hooks/use-api';
import modalStyles from '@/scss/modal.module.scss';
import { trySubmitSafe } from '@/utils/form';
type FormData = { name: string; expiration: string };
type Props = {
readonly userId: string;
readonly isOpen: boolean;
readonly onClose: (createdToken?: PersonalAccessToken) => void;
};
const days = Object.freeze([7, 30, 180, 365]);
const neverExpires = '-1';
function CreateTokenModal({ userId, isOpen, onClose }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const {
register,
control,
watch,
formState: { errors, isSubmitting },
handleSubmit,
reset,
} = useForm<FormData>({ defaultValues: { name: '', expiration: String(days[0]) } });
const onCloseHandler = useCallback(
(created?: PersonalAccessToken) => {
reset();
onClose(created);
},
[onClose, reset]
);
const api = useApi();
const expirationDays = watch('expiration');
const [expirationDate, setExpirationDate] = useState<Date>();
useEffect(() => {
const setDate = () => {
if (expirationDays === neverExpires) {
setExpirationDate(undefined);
} else {
setExpirationDate(addDays(new Date(), Number(expirationDays)));
}
};
const interval = setInterval(setDate, 1000);
setDate();
return () => {
clearInterval(interval);
};
}, [expirationDays]);
const submit = handleSubmit(
trySubmitSafe(async ({ expiration, ...rest }) => {
const createdData = await api
.post(`api/users/${userId}/personal-access-tokens`, {
json: { ...rest, expiresAt: expirationDate?.valueOf() },
})
.json<PersonalAccessToken>();
toast.success(
t('organization_template.roles.create_modal.created', { name: createdData.name })
);
onCloseHandler(createdData);
})
);
return (
<ReactModal
isOpen={isOpen}
className={modalStyles.content}
overlayClassName={modalStyles.overlay}
onRequestClose={() => {
onCloseHandler();
}}
>
<ModalLayout
title="user_details.personal_access_tokens.create_modal.title"
footer={
<Button type="primary" title="general.create" isLoading={isSubmitting} onClick={submit} />
}
onClose={onCloseHandler}
>
<FormField isRequired title="general.name">
<TextInput
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
placeholder="My PAT"
error={Boolean(errors.name)}
{...register('name', { required: true })}
/>
</FormField>
<Controller
control={control}
name="expiration"
render={({ field }) => (
<FormField
title="user_details.personal_access_tokens.create_modal.expiration"
description={
expirationDate ? (
<DangerousRaw>
{t('user_details.personal_access_tokens.create_modal.expiration_description', {
date: format(expirationDate, 'Pp'),
})}
</DangerousRaw>
) : (
'user_details.personal_access_tokens.create_modal.expiration_description_never'
)
}
>
<Select
options={[
...days.map((count) => ({
title: t('user_details.personal_access_tokens.create_modal.days', { count }),
value: String(count),
})),
{
title: t('user_details.personal_access_tokens.never'),
value: neverExpires,
},
]}
value={field.value}
onChange={(value) => {
field.onChange(value);
}}
/>
</FormField>
)}
/>
</ModalLayout>
</ReactModal>
);
}
export default CreateTokenModal;

View file

@ -0,0 +1,15 @@
@use '@/scss/underscore' as _;
button.add {
margin-top: _.unit(2);
}
.table {
margin-top: _.unit(2);
}
.empty {
font: var(--font-body-2);
color: var(--color-text-secondary);
margin: _.unit(3) 0;
}

View file

@ -0,0 +1,133 @@
import { type PersonalAccessToken as Token } from '@logto/schemas';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import useSWR from 'swr';
import CirclePlus from '@/assets/icons/circle-plus.svg?react';
import Plus from '@/assets/icons/plus.svg?react';
import ActionsButton from '@/components/ActionsButton';
import Breakable from '@/components/Breakable';
import Button from '@/ds-components/Button';
import CopyToClipboard from '@/ds-components/CopyToClipboard';
import FormField from '@/ds-components/FormField';
import Table from '@/ds-components/Table';
import { type Column } from '@/ds-components/Table/types';
import useApi, { type RequestError } from '@/hooks/use-api';
import CreateTokenModal from './CreateTokenModal';
import styles from './index.module.scss';
type Props = {
readonly userId: string;
};
function PersonalAccessTokens({ userId }: Props) {
const [showCreateTokenModal, setShowCreateTokenModal] = useState(false);
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { data, mutate, error, isLoading } = useSWR<Token[], RequestError>(
`api/users/${userId}/personal-access-tokens`
);
const api = useApi();
const tableColumns: Array<Column<Token>> = useMemo(
() => [
{
title: t('general.name'),
dataIndex: 'name',
colSpan: 3,
render: ({ name }) => <Breakable>{name}</Breakable>,
},
{
title: t('user_details.personal_access_tokens.value'),
dataIndex: 'value',
colSpan: 6,
render: ({ value }) => (
<CopyToClipboard hasVisibilityToggle displayType="block" value={value} variant="text" />
),
},
{
title: t('user_details.personal_access_tokens.expires_at'),
dataIndex: 'expiresAt',
colSpan: 3,
render: ({ expiresAt }) => (
<Breakable>
{expiresAt
? new Date(expiresAt).toLocaleString()
: t('user_details.personal_access_tokens.never')}
</Breakable>
),
},
{
title: '',
dataIndex: 'actions',
render: ({ name }) => (
<ActionsButton
fieldName="user_details.personal_access_tokens.title_short"
deleteConfirmation="user_details.personal_access_tokens.delete_confirmation"
onDelete={async () => {
await api.delete(
`api/users/${userId}/personal-access-tokens/${encodeURIComponent(name)}`
);
void mutate();
}}
/>
),
},
],
[api, userId, mutate, t]
);
return (
<FormField title="user_details.personal_access_tokens.title_other">
{data?.length === 0 && !error ? (
<>
<div className={styles.empty}>
{t('organizations.empty_placeholder', {
entity: t('user_details.personal_access_tokens.title').toLowerCase(),
})}
</div>
<Button
icon={<Plus />}
title="general.add"
onClick={() => {
setShowCreateTokenModal(true);
}}
/>
</>
) : (
<>
<Table
hasBorder
isRowHoverEffectDisabled
rowIndexKey="name"
isLoading={isLoading}
errorMessage={error?.body?.message ?? error?.message}
rowGroups={[{ key: 'personal_access_tokens', data: data ?? [] }]}
columns={tableColumns}
className={styles.table}
/>
<Button
size="small"
type="text"
className={styles.add}
title="user_details.personal_access_tokens.create_new_token"
icon={<CirclePlus />}
onClick={() => {
setShowCreateTokenModal(true);
}}
/>
</>
)}
<CreateTokenModal
userId={userId}
isOpen={showCreateTokenModal}
onClose={() => {
setShowCreateTokenModal(false);
void mutate();
}}
/>
</FormField>
);
}
export default PersonalAccessTokens;

View file

@ -12,6 +12,7 @@ import DetailsForm from '@/components/DetailsForm';
import FormCard from '@/components/FormCard';
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
import { profilePropertyReferenceLink } from '@/consts';
import { isDevFeaturesEnabled } from '@/consts/env';
import CodeEditor from '@/ds-components/CodeEditor';
import FormField from '@/ds-components/FormField';
import TextInput from '@/ds-components/TextInput';
@ -27,6 +28,7 @@ import { type UserDetailsForm, type UserDetailsOutletContext } from '../types';
import { userDetailsParser } from '../utils';
import UserMfaVerifications from './UserMfaVerifications';
import PersonalAccessTokens from './components/PersonalAccessTokens';
import UserSocialIdentities from './components/UserSocialIdentities';
import UserSsoIdentities from './components/UserSsoIdentities';
@ -164,6 +166,7 @@ function UserSettings() {
<FormField title="user_details.mfa.field_name">
<UserMfaVerifications userId={user.id} />
</FormField>
{isDevFeaturesEnabled && <PersonalAccessTokens userId={user.id} />}
</FormCard>
<FormCard title="user_details.user_profile">
<FormField title="user_details.field_name">

View file

@ -97,6 +97,34 @@ const user_details = {
},
warning_no_sign_in_identifier:
'User needs to have at least one of the sign-in identifiers (username, email, phone number or social) to sign in. Are you sure you want to continue?',
personal_access_tokens: {
title: 'Personal access token',
title_other: 'Personal access tokens',
title_short: 'token',
value: 'Value',
created_at: 'Created at',
expires_at: 'Expires at',
never: 'Never',
create_new_token: 'Create new token',
delete_confirmation:
'This action cannot be undone. Are you sure you want to delete this token?',
expired: 'Expired',
expired_tooltip: 'This token was expired on {{date}}.',
create_modal: {
title: 'Create personal access token',
expiration: 'Expiration',
expiration_description: 'The token will expire at {{date}}.',
expiration_description_never:
'The token will never expire. We recommend setting an expiration date for enhanced security.',
days: '{{count}} day',
days_other: '{{count}} days',
created: 'The token {{name}} has been successfully created.',
},
edit_modal: {
title: 'Edit personal access token',
edited: 'The token {{name}} has been successfully edited.',
},
},
};
export default Object.freeze(user_details);