0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-24 22:41:28 -05:00

feat(console): support multiple app secrets

This commit is contained in:
Gao Sun 2024-07-22 16:26:19 +08:00
parent 3a9a69381d
commit e8a55b38d0
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
6 changed files with 340 additions and 12 deletions

View file

@ -0,0 +1,148 @@
import { type ApplicationSecret } 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 * as modalStyles from '@/scss/modal.module.scss';
import { trySubmitSafe } from '@/utils/form';
type FormData = { name: string; expiration: string };
type Props = {
readonly appId: string;
readonly isOpen: boolean;
readonly onClose: (createdAppSecret?: ApplicationSecret) => void;
};
const days = Object.freeze([7, 30, 180, 365]);
const neverExpires = '-1';
function CreateSecretModal({ appId, 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?: ApplicationSecret) => {
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/applications/${appId}/secrets`, {
json: { ...rest, expiresAt: expirationDate?.valueOf() },
})
.json<ApplicationSecret>();
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="application_details.secrets.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 secret"
error={Boolean(errors.name)}
{...register('name', { required: true })}
/>
</FormField>
<Controller
control={control}
name="expiration"
render={({ field }) => (
<FormField
title="application_details.secrets.create_modal.expiration"
description={
expirationDate ? (
<DangerousRaw>
{t('application_details.secrets.create_modal.expiration_description', {
date: format(expirationDate, 'Pp'),
})}
</DangerousRaw>
) : (
'application_details.secrets.create_modal.expiration_description_never'
)
}
>
<Select
options={[
...days.map((count) => ({
title: t('application_details.secrets.create_modal.days', { count }),
value: String(count),
})),
{
title: t('application_details.secrets.never'),
value: neverExpires,
},
]}
value={field.value}
onChange={(value) => {
field.onChange(value);
}}
/>
</FormField>
)}
/>
</ModalLayout>
</ReactModal>
);
}
export default CreateSecretModal;

View file

@ -1,39 +1,67 @@
import {
type ApplicationSecret,
ApplicationType,
DomainStatus,
type Application,
type SnakeCaseOidcConfig,
internalPrefix,
} from '@logto/schemas';
import { appendPath } from '@silverhand/essentials';
import { useCallback, useContext, useState } from 'react';
import { useCallback, useContext, useMemo, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import useSWR from 'swr';
import CaretDown from '@/assets/icons/caret-down.svg';
import CaretUp from '@/assets/icons/caret-up.svg';
import CirclePlus from '@/assets/icons/circle-plus.svg';
import Plus from '@/assets/icons/plus.svg';
import ActionsButton from '@/components/ActionsButton';
import FormCard from '@/components/FormCard';
import { isDevFeaturesEnabled } from '@/consts/env';
import { openIdProviderConfigPath, openIdProviderPath } from '@/consts/oidc';
import { AppDataContext } from '@/contexts/AppDataProvider';
import Button from '@/ds-components/Button';
import CopyToClipboard from '@/ds-components/CopyToClipboard';
import DynamicT from '@/ds-components/DynamicT';
import FormField from '@/ds-components/FormField';
import Table from '@/ds-components/Table';
import { type Column } from '@/ds-components/Table/types';
import TextLink from '@/ds-components/TextLink';
import useApi, { type RequestError } from '@/hooks/use-api';
import useCustomDomain from '@/hooks/use-custom-domain';
import CreateSecretModal from './CreateSecretModal';
import * as styles from './index.module.scss';
const isLegacySecret = (secret: string) => !secret.startsWith(internalPrefix);
type ApplicationSecretRow = Pick<ApplicationSecret, 'name' | 'value' | 'expiresAt'> & {
isLegacy?: boolean;
};
type Props = {
readonly app: Application;
readonly oidcConfig: SnakeCaseOidcConfig;
readonly onApplicationUpdated: () => void;
};
function EndpointsAndCredentials({ app: { type, secret, id, isThirdParty }, oidcConfig }: Props) {
function EndpointsAndCredentials({
app: { type, secret, id, isThirdParty },
oidcConfig,
onApplicationUpdated,
}: Props) {
const { tenantEndpoint } = useContext(AppDataContext);
const [showMoreEndpoints, setShowMoreEndpoints] = useState(false);
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { data: customDomain, applyDomain: applyCustomDomain } = useCustomDomain();
const [showCreateSecretModal, setShowCreateSecretModal] = useState(false);
const secrets = useSWR<ApplicationSecretRow[], RequestError>(`api/applications/${id}/secrets`);
const api = useApi();
const shouldShowAppSecrets = [
ApplicationType.Traditional,
ApplicationType.MachineToMachine,
ApplicationType.Protected,
].includes(type);
const toggleShowMoreEndpoints = useCallback(() => {
setShowMoreEndpoints((previous) => !previous);
@ -41,6 +69,73 @@ function EndpointsAndCredentials({ app: { type, secret, id, isThirdParty }, oidc
const ToggleVisibleCaretIcon = showMoreEndpoints ? CaretUp : CaretDown;
const secretsData = useMemo(
() => [
...(isLegacySecret(secret)
? [
{
name: t('application_details.secrets.legacy_secret'),
value: secret,
expiresAt: null,
isLegacy: true,
},
]
: []),
...(secrets.data ?? []),
],
[secret, secrets.data, t]
);
const tableColumns: Array<Column<ApplicationSecretRow>> = useMemo(
() => [
{
title: t('general.name'),
dataIndex: 'name',
colSpan: 3,
render: ({ name }) => <span>{name}</span>,
},
{
title: t('application_details.secrets.value'),
dataIndex: 'value',
colSpan: 6,
render: ({ value }) => (
<CopyToClipboard hasVisibilityToggle displayType="block" value={value} variant="text" />
),
},
{
title: t('application_details.secrets.expires_at'),
dataIndex: 'expiresAt',
colSpan: 3,
render: ({ expiresAt }) => (
<span>
{expiresAt
? new Date(expiresAt).toLocaleString()
: t('application_details.secrets.never')}
</span>
),
},
{
title: '',
dataIndex: 'actions',
render: ({ name, isLegacy }) => (
<ActionsButton
fieldName="application_details.application_secret"
deleteConfirmation="application_details.secrets.delete_confirmation"
onDelete={async () => {
if (isLegacy) {
await api.delete(`api/applications/${id}/legacy-secret`);
onApplicationUpdated();
} else {
await api.delete(`api/applications/${id}/secrets/${encodeURIComponent(name)}`);
void secrets.mutate();
}
}}
/>
),
},
],
[api, id, onApplicationUpdated, secrets, t]
);
return (
<FormCard
title="application_details.endpoints_and_credentials"
@ -148,11 +243,7 @@ function EndpointsAndCredentials({ app: { type, secret, id, isThirdParty }, oidc
<FormField title="application_details.application_id">
<CopyToClipboard displayType="block" value={id} variant="border" />
</FormField>
{[
ApplicationType.Traditional,
ApplicationType.MachineToMachine,
ApplicationType.Protected,
].includes(type) && (
{!isDevFeaturesEnabled && shouldShowAppSecrets && (
<FormField title="application_details.application_secret">
<CopyToClipboard
hasVisibilityToggle
@ -162,6 +253,57 @@ function EndpointsAndCredentials({ app: { type, secret, id, isThirdParty }, oidc
/>
</FormField>
)}
{isDevFeaturesEnabled && shouldShowAppSecrets && (
<FormField title="application_details.application_secret_other">
{secretsData.length === 0 && !secrets.error ? (
<>
<div className={styles.empty}>
{t('organizations.empty_placeholder', {
entity: t('application_details.application_secret').toLowerCase(),
})}
</div>
<Button
icon={<Plus />}
title="general.add"
onClick={() => {
setShowCreateSecretModal(true);
}}
/>
</>
) : (
<>
<Table
hasBorder
isRowHoverEffectDisabled
rowIndexKey="name"
isLoading={!secrets.data && !secrets.error}
errorMessage={secrets.error?.body?.message ?? secrets.error?.message}
rowGroups={[{ key: 'application_secrets', data: secretsData }]}
columns={tableColumns}
className={styles.table}
/>
<Button
size="small"
type="text"
className={styles.add}
title="application_details.secrets.create_new_secret"
icon={<CirclePlus />}
onClick={() => {
setShowCreateSecretModal(true);
}}
/>
</>
)}
<CreateSecretModal
appId={id}
isOpen={showCreateSecretModal}
onClose={() => {
setShowCreateSecretModal(false);
void secrets.mutate();
}}
/>
</FormField>
)}
</FormCard>
);
}

View file

@ -287,7 +287,7 @@ function ProtectedAppSettings({ data }: Props) {
</InlineNotification>
</FormField>
</FormCard>
<EndpointsAndCredentials app={data} oidcConfig={oidcConfig} />
<EndpointsAndCredentials app={data} oidcConfig={oidcConfig} onApplicationUpdated={mutate} />
<SessionForm data={data} />
</>
);

View file

@ -37,3 +37,17 @@
display: flex;
}
}
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

@ -215,7 +215,11 @@ function ApplicationDetailsContent({ data, oidcConfig, onApplicationUpdated }: P
<Settings data={data} />
{/* Protected apps will reference this section in <ProtectedAppSettings /> component */}
{data.type !== ApplicationType.Protected && (
<EndpointsAndCredentials app={data} oidcConfig={oidcConfig} />
<EndpointsAndCredentials
app={data}
oidcConfig={oidcConfig}
onApplicationUpdated={onApplicationUpdated}
/>
)}
{![ApplicationType.MachineToMachine, ApplicationType.Protected].includes(data.type) && (
<RefreshTokenSettings data={data} />

View file

@ -32,7 +32,8 @@ const application_details = {
application_id: 'App ID',
application_id_tip:
'The unique application identifier normally generated by Logto. It also stands for “<a>client_id</a>” in OpenID Connect.',
application_secret: 'App Secret',
application_secret: 'App secret',
application_secret_other: 'App secrets',
redirect_uri: 'Redirect URI',
redirect_uris: 'Redirect URIs',
redirect_uri_placeholder: 'https://your.website.com/app',
@ -162,6 +163,25 @@ const application_details = {
search: 'Search by role name, description or ID',
empty: 'No role available',
},
secrets: {
value: 'Value',
created_at: 'Created at',
expires_at: 'Expires at',
never: 'Never',
create_new_secret: 'Create new secret',
delete_confirmation:
'This action cannot be undone. Are you sure you want to delete this secret?',
legacy_secret: 'Legacy secret',
create_modal: {
title: 'Create application secret',
expiration: 'Expiration',
expiration_description: 'The secret will expire at {{date}}.',
expiration_description_never:
'The secret will never expire. We strongly recommend setting an expiration date.',
days: '{{count}} day',
days_other: '{{count}} days',
},
},
};
export default Object.freeze(application_details);