Skip to content

Commit 2ba252b

Browse files
authored
Merge pull request #6304 from logto-io/gao-console-app-secrets
feat(console): support multiple app secrets
2 parents 778407e + 6b33a36 commit 2ba252b

File tree

6 files changed

+342
-12
lines changed

6 files changed

+342
-12
lines changed
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { type ApplicationSecret } from '@logto/schemas';
2+
import { addDays, format } from 'date-fns';
3+
import { useCallback, useEffect, useState } from 'react';
4+
import { Controller, useForm } from 'react-hook-form';
5+
import { toast } from 'react-hot-toast';
6+
import { useTranslation } from 'react-i18next';
7+
import ReactModal from 'react-modal';
8+
9+
import Button from '@/ds-components/Button';
10+
import DangerousRaw from '@/ds-components/DangerousRaw';
11+
import FormField from '@/ds-components/FormField';
12+
import ModalLayout from '@/ds-components/ModalLayout';
13+
import Select from '@/ds-components/Select';
14+
import TextInput from '@/ds-components/TextInput';
15+
import useApi from '@/hooks/use-api';
16+
import * as modalStyles from '@/scss/modal.module.scss';
17+
import { trySubmitSafe } from '@/utils/form';
18+
19+
type FormData = { name: string; expiration: string };
20+
21+
type Props = {
22+
readonly appId: string;
23+
readonly isOpen: boolean;
24+
readonly onClose: (createdAppSecret?: ApplicationSecret) => void;
25+
};
26+
27+
const days = Object.freeze([7, 30, 180, 365]);
28+
const neverExpires = '-1';
29+
30+
function CreateSecretModal({ appId, isOpen, onClose }: Props) {
31+
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
32+
const {
33+
register,
34+
control,
35+
watch,
36+
formState: { errors, isSubmitting },
37+
handleSubmit,
38+
reset,
39+
} = useForm<FormData>({ defaultValues: { name: '', expiration: String(days[0]) } });
40+
const onCloseHandler = useCallback(
41+
(created?: ApplicationSecret) => {
42+
reset();
43+
onClose(created);
44+
},
45+
[onClose, reset]
46+
);
47+
const api = useApi();
48+
const expirationDays = watch('expiration');
49+
const [expirationDate, setExpirationDate] = useState<Date>();
50+
51+
// Update expiration date every second since our options are relative to the current time (in
52+
// days).
53+
useEffect(() => {
54+
const setDate = () => {
55+
if (expirationDays === neverExpires) {
56+
setExpirationDate(undefined);
57+
} else {
58+
setExpirationDate(addDays(new Date(), Number(expirationDays)));
59+
}
60+
};
61+
const interval = setInterval(setDate, 1000);
62+
setDate();
63+
64+
return () => {
65+
clearInterval(interval);
66+
};
67+
}, [expirationDays]);
68+
69+
const submit = handleSubmit(
70+
trySubmitSafe(async ({ expiration, ...rest }) => {
71+
const createdData = await api
72+
.post(`api/applications/${appId}/secrets`, {
73+
json: { ...rest, expiresAt: expirationDate?.valueOf() },
74+
})
75+
.json<ApplicationSecret>();
76+
toast.success(
77+
t('organization_template.roles.create_modal.created', { name: createdData.name })
78+
);
79+
onCloseHandler(createdData);
80+
})
81+
);
82+
83+
return (
84+
<ReactModal
85+
isOpen={isOpen}
86+
className={modalStyles.content}
87+
overlayClassName={modalStyles.overlay}
88+
onRequestClose={() => {
89+
onCloseHandler();
90+
}}
91+
>
92+
<ModalLayout
93+
title="application_details.secrets.create_modal.title"
94+
footer={
95+
<Button type="primary" title="general.create" isLoading={isSubmitting} onClick={submit} />
96+
}
97+
onClose={onCloseHandler}
98+
>
99+
<FormField isRequired title="general.name">
100+
<TextInput
101+
// eslint-disable-next-line jsx-a11y/no-autofocus
102+
autoFocus
103+
placeholder="My secret"
104+
error={Boolean(errors.name)}
105+
{...register('name', { required: true })}
106+
/>
107+
</FormField>
108+
<Controller
109+
control={control}
110+
name="expiration"
111+
render={({ field }) => (
112+
<FormField
113+
title="application_details.secrets.create_modal.expiration"
114+
description={
115+
expirationDate ? (
116+
<DangerousRaw>
117+
{t('application_details.secrets.create_modal.expiration_description', {
118+
date: format(expirationDate, 'Pp'),
119+
})}
120+
</DangerousRaw>
121+
) : (
122+
'application_details.secrets.create_modal.expiration_description_never'
123+
)
124+
}
125+
>
126+
<Select
127+
options={[
128+
...days.map((count) => ({
129+
title: t('application_details.secrets.create_modal.days', { count }),
130+
value: String(count),
131+
})),
132+
{
133+
title: t('application_details.secrets.never'),
134+
value: neverExpires,
135+
},
136+
]}
137+
value={field.value}
138+
onChange={(value) => {
139+
field.onChange(value);
140+
}}
141+
/>
142+
</FormField>
143+
)}
144+
/>
145+
</ModalLayout>
146+
</ReactModal>
147+
);
148+
}
149+
150+
export default CreateSecretModal;

packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/EndpointsAndCredentials.tsx

Lines changed: 151 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,141 @@
11
import {
2+
type ApplicationSecret,
23
ApplicationType,
34
DomainStatus,
45
type Application,
56
type SnakeCaseOidcConfig,
7+
internalPrefix,
68
} from '@logto/schemas';
79
import { appendPath } from '@silverhand/essentials';
8-
import { useCallback, useContext, useState } from 'react';
10+
import { useCallback, useContext, useMemo, useState } from 'react';
911
import { Trans, useTranslation } from 'react-i18next';
12+
import useSWR from 'swr';
1013

1114
import CaretDown from '@/assets/icons/caret-down.svg';
1215
import CaretUp from '@/assets/icons/caret-up.svg';
16+
import CirclePlus from '@/assets/icons/circle-plus.svg';
17+
import Plus from '@/assets/icons/plus.svg';
18+
import ActionsButton from '@/components/ActionsButton';
1319
import FormCard from '@/components/FormCard';
20+
import { isDevFeaturesEnabled } from '@/consts/env';
1421
import { openIdProviderConfigPath, openIdProviderPath } from '@/consts/oidc';
1522
import { AppDataContext } from '@/contexts/AppDataProvider';
1623
import Button from '@/ds-components/Button';
1724
import CopyToClipboard from '@/ds-components/CopyToClipboard';
1825
import DynamicT from '@/ds-components/DynamicT';
1926
import FormField from '@/ds-components/FormField';
27+
import Table from '@/ds-components/Table';
28+
import { type Column } from '@/ds-components/Table/types';
2029
import TextLink from '@/ds-components/TextLink';
30+
import useApi, { type RequestError } from '@/hooks/use-api';
2131
import useCustomDomain from '@/hooks/use-custom-domain';
2232

33+
import CreateSecretModal from './CreateSecretModal';
2334
import * as styles from './index.module.scss';
2435

36+
const isLegacySecret = (secret: string) => !secret.startsWith(internalPrefix);
37+
38+
type ApplicationSecretRow = Pick<ApplicationSecret, 'name' | 'value' | 'expiresAt'> & {
39+
isLegacy?: boolean;
40+
};
41+
2542
type Props = {
2643
readonly app: Application;
2744
readonly oidcConfig: SnakeCaseOidcConfig;
45+
readonly onApplicationUpdated: () => void;
2846
};
2947

30-
function EndpointsAndCredentials({ app: { type, secret, id, isThirdParty }, oidcConfig }: Props) {
48+
function EndpointsAndCredentials({
49+
app: { type, secret, id, isThirdParty },
50+
oidcConfig,
51+
onApplicationUpdated,
52+
}: Props) {
3153
const { tenantEndpoint } = useContext(AppDataContext);
3254
const [showMoreEndpoints, setShowMoreEndpoints] = useState(false);
33-
3455
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
35-
3656
const { data: customDomain, applyDomain: applyCustomDomain } = useCustomDomain();
57+
const [showCreateSecretModal, setShowCreateSecretModal] = useState(false);
58+
const secrets = useSWR<ApplicationSecretRow[], RequestError>(`api/applications/${id}/secrets`);
59+
const api = useApi();
60+
const shouldShowAppSecrets = [
61+
ApplicationType.Traditional,
62+
ApplicationType.MachineToMachine,
63+
ApplicationType.Protected,
64+
].includes(type);
3765

3866
const toggleShowMoreEndpoints = useCallback(() => {
3967
setShowMoreEndpoints((previous) => !previous);
4068
}, []);
4169

4270
const ToggleVisibleCaretIcon = showMoreEndpoints ? CaretUp : CaretDown;
4371

72+
const secretsData = useMemo(
73+
() => [
74+
...(isLegacySecret(secret)
75+
? [
76+
{
77+
name: t('application_details.secrets.legacy_secret'),
78+
value: secret,
79+
expiresAt: null,
80+
isLegacy: true,
81+
},
82+
]
83+
: []),
84+
...(secrets.data ?? []),
85+
],
86+
[secret, secrets.data, t]
87+
);
88+
const tableColumns: Array<Column<ApplicationSecretRow>> = useMemo(
89+
() => [
90+
{
91+
title: t('general.name'),
92+
dataIndex: 'name',
93+
colSpan: 3,
94+
render: ({ name }) => <span>{name}</span>,
95+
},
96+
{
97+
title: t('application_details.secrets.value'),
98+
dataIndex: 'value',
99+
colSpan: 6,
100+
render: ({ value }) => (
101+
<CopyToClipboard hasVisibilityToggle displayType="block" value={value} variant="text" />
102+
),
103+
},
104+
{
105+
title: t('application_details.secrets.expires_at'),
106+
dataIndex: 'expiresAt',
107+
colSpan: 3,
108+
render: ({ expiresAt }) => (
109+
<span>
110+
{expiresAt
111+
? new Date(expiresAt).toLocaleString()
112+
: t('application_details.secrets.never')}
113+
</span>
114+
),
115+
},
116+
{
117+
title: '',
118+
dataIndex: 'actions',
119+
render: ({ name, isLegacy }) => (
120+
<ActionsButton
121+
fieldName="application_details.application_secret"
122+
deleteConfirmation="application_details.secrets.delete_confirmation"
123+
onDelete={async () => {
124+
if (isLegacy) {
125+
await api.delete(`api/applications/${id}/legacy-secret`);
126+
onApplicationUpdated();
127+
} else {
128+
await api.delete(`api/applications/${id}/secrets/${encodeURIComponent(name)}`);
129+
void secrets.mutate();
130+
}
131+
}}
132+
/>
133+
),
134+
},
135+
],
136+
[api, id, onApplicationUpdated, secrets, t]
137+
);
138+
44139
return (
45140
<FormCard
46141
title="application_details.endpoints_and_credentials"
@@ -148,11 +243,7 @@ function EndpointsAndCredentials({ app: { type, secret, id, isThirdParty }, oidc
148243
<FormField title="application_details.application_id">
149244
<CopyToClipboard displayType="block" value={id} variant="border" />
150245
</FormField>
151-
{[
152-
ApplicationType.Traditional,
153-
ApplicationType.MachineToMachine,
154-
ApplicationType.Protected,
155-
].includes(type) && (
246+
{!isDevFeaturesEnabled && shouldShowAppSecrets && (
156247
<FormField title="application_details.application_secret">
157248
<CopyToClipboard
158249
hasVisibilityToggle
@@ -162,6 +253,57 @@ function EndpointsAndCredentials({ app: { type, secret, id, isThirdParty }, oidc
162253
/>
163254
</FormField>
164255
)}
256+
{isDevFeaturesEnabled && shouldShowAppSecrets && (
257+
<FormField title="application_details.application_secret_other">
258+
{secretsData.length === 0 && !secrets.error ? (
259+
<>
260+
<div className={styles.empty}>
261+
{t('organizations.empty_placeholder', {
262+
entity: t('application_details.application_secret').toLowerCase(),
263+
})}
264+
</div>
265+
<Button
266+
icon={<Plus />}
267+
title="general.add"
268+
onClick={() => {
269+
setShowCreateSecretModal(true);
270+
}}
271+
/>
272+
</>
273+
) : (
274+
<>
275+
<Table
276+
hasBorder
277+
isRowHoverEffectDisabled
278+
rowIndexKey="name"
279+
isLoading={!secrets.data && !secrets.error}
280+
errorMessage={secrets.error?.body?.message ?? secrets.error?.message}
281+
rowGroups={[{ key: 'application_secrets', data: secretsData }]}
282+
columns={tableColumns}
283+
className={styles.table}
284+
/>
285+
<Button
286+
size="small"
287+
type="text"
288+
className={styles.add}
289+
title="application_details.secrets.create_new_secret"
290+
icon={<CirclePlus />}
291+
onClick={() => {
292+
setShowCreateSecretModal(true);
293+
}}
294+
/>
295+
</>
296+
)}
297+
<CreateSecretModal
298+
appId={id}
299+
isOpen={showCreateSecretModal}
300+
onClose={() => {
301+
setShowCreateSecretModal(false);
302+
void secrets.mutate();
303+
}}
304+
/>
305+
</FormField>
306+
)}
165307
</FormCard>
166308
);
167309
}

packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/ProtectedAppSettings/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,7 @@ function ProtectedAppSettings({ data }: Props) {
287287
</InlineNotification>
288288
</FormField>
289289
</FormCard>
290-
<EndpointsAndCredentials app={data} oidcConfig={oidcConfig} />
290+
<EndpointsAndCredentials app={data} oidcConfig={oidcConfig} onApplicationUpdated={mutate} />
291291
<SessionForm data={data} />
292292
</>
293293
);

packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/index.module.scss

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,17 @@
3737
display: flex;
3838
}
3939
}
40+
41+
button.add {
42+
margin-top: _.unit(2);
43+
}
44+
45+
.table {
46+
margin-top: _.unit(2);
47+
}
48+
49+
.empty {
50+
font: var(--font-body-2);
51+
color: var(--color-text-secondary);
52+
margin: _.unit(3) 0;
53+
}

0 commit comments

Comments
 (0)