mirror of
https://github.com/logto-io/logto.git
synced 2025-03-24 22:41:28 -05:00
feat(console): user personal access tokens (#6444)
This commit is contained in:
parent
e9b7b83aaa
commit
1c6b9321dd
5 changed files with 327 additions and 0 deletions
|
@ -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;
|
|
@ -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;
|
||||||
|
}
|
|
@ -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;
|
|
@ -12,6 +12,7 @@ import DetailsForm from '@/components/DetailsForm';
|
||||||
import FormCard from '@/components/FormCard';
|
import FormCard from '@/components/FormCard';
|
||||||
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
|
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
|
||||||
import { profilePropertyReferenceLink } from '@/consts';
|
import { profilePropertyReferenceLink } from '@/consts';
|
||||||
|
import { isDevFeaturesEnabled } from '@/consts/env';
|
||||||
import CodeEditor from '@/ds-components/CodeEditor';
|
import CodeEditor from '@/ds-components/CodeEditor';
|
||||||
import FormField from '@/ds-components/FormField';
|
import FormField from '@/ds-components/FormField';
|
||||||
import TextInput from '@/ds-components/TextInput';
|
import TextInput from '@/ds-components/TextInput';
|
||||||
|
@ -27,6 +28,7 @@ import { type UserDetailsForm, type UserDetailsOutletContext } from '../types';
|
||||||
import { userDetailsParser } from '../utils';
|
import { userDetailsParser } from '../utils';
|
||||||
|
|
||||||
import UserMfaVerifications from './UserMfaVerifications';
|
import UserMfaVerifications from './UserMfaVerifications';
|
||||||
|
import PersonalAccessTokens from './components/PersonalAccessTokens';
|
||||||
import UserSocialIdentities from './components/UserSocialIdentities';
|
import UserSocialIdentities from './components/UserSocialIdentities';
|
||||||
import UserSsoIdentities from './components/UserSsoIdentities';
|
import UserSsoIdentities from './components/UserSsoIdentities';
|
||||||
|
|
||||||
|
@ -164,6 +166,7 @@ function UserSettings() {
|
||||||
<FormField title="user_details.mfa.field_name">
|
<FormField title="user_details.mfa.field_name">
|
||||||
<UserMfaVerifications userId={user.id} />
|
<UserMfaVerifications userId={user.id} />
|
||||||
</FormField>
|
</FormField>
|
||||||
|
{isDevFeaturesEnabled && <PersonalAccessTokens userId={user.id} />}
|
||||||
</FormCard>
|
</FormCard>
|
||||||
<FormCard title="user_details.user_profile">
|
<FormCard title="user_details.user_profile">
|
||||||
<FormField title="user_details.field_name">
|
<FormField title="user_details.field_name">
|
||||||
|
|
|
@ -97,6 +97,34 @@ const user_details = {
|
||||||
},
|
},
|
||||||
warning_no_sign_in_identifier:
|
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?',
|
'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);
|
export default Object.freeze(user_details);
|
||||||
|
|
Loading…
Add table
Reference in a new issue