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:
parent
3a9a69381d
commit
e8a55b38d0
6 changed files with 340 additions and 12 deletions
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Add table
Reference in a new issue