From 40a8a9a9bb176ec1917f58a4594d217e1d12d519 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Mon, 29 Jul 2024 18:59:17 +0800 Subject: [PATCH] feat: allow app secret edit (#6352) --- .../CreateSecretModal.tsx | 2 +- .../EditSecretModal.tsx | 86 +++++++++++++++++++ .../EndpointsAndCredentials/index.tsx | 25 +++++- .../use-secret-table-columns.tsx | 43 ++++++---- .../src/middleware/koa-security-headers.ts | 8 +- .../core/src/queries/application-secrets.ts | 4 + .../application-secret.openapi.json | 29 +++++++ .../routes/applications/application-secret.ts | 23 +++++ .../integration-tests/src/api/application.ts | 11 +++ .../application/application.secrets.test.ts | 20 +++++ .../admin-console/application-details.ts | 5 ++ 11 files changed, 237 insertions(+), 19 deletions(-) create mode 100644 packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/EditSecretModal.tsx diff --git a/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/CreateSecretModal.tsx b/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/CreateSecretModal.tsx index e39e4e45c..b301a40af 100644 --- a/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/CreateSecretModal.tsx +++ b/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/CreateSecretModal.tsx @@ -74,7 +74,7 @@ function CreateSecretModal({ appId, isOpen, onClose }: Props) { }) .json(); toast.success( - t('organization_template.roles.create_modal.created', { name: createdData.name }) + t('application_details.secrets.create_modal.created', { name: createdData.name }) ); onCloseHandler(createdData); }) diff --git a/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/EditSecretModal.tsx b/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/EditSecretModal.tsx new file mode 100644 index 000000000..c851b8276 --- /dev/null +++ b/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/EditSecretModal.tsx @@ -0,0 +1,86 @@ +import { type ApplicationSecret } from '@logto/schemas'; +import { useCallback } from 'react'; +import { 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 FormField from '@/ds-components/FormField'; +import ModalLayout from '@/ds-components/ModalLayout'; +import TextInput from '@/ds-components/TextInput'; +import useApi from '@/hooks/use-api'; +import modalStyles from '@/scss/modal.module.scss'; +import { trySubmitSafe } from '@/utils/form'; + +import { type ApplicationSecretRow } from './EndpointsAndCredentials/use-secret-table-columns'; + +type FormData = { name: string; expiration: string }; + +type Props = { + readonly appId: string; + readonly secret: ApplicationSecretRow; + readonly isOpen: boolean; + readonly onClose: (updated: boolean) => void; +}; + +function EditSecretModal({ appId, secret, isOpen, onClose }: Props) { + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + const { + register, + formState: { errors, isSubmitting }, + handleSubmit, + reset, + } = useForm({ defaultValues: { name: secret.name } }); + const onCloseHandler = useCallback( + (updated?: boolean) => { + reset(); + onClose(updated ?? false); + }, + [onClose, reset] + ); + const api = useApi(); + + const submit = handleSubmit( + trySubmitSafe(async (data) => { + const createdData = await api + .patch(`api/applications/${appId}/secrets/${encodeURIComponent(secret.name)}`, { + json: data, + }) + .json(); + toast.success(t('application_details.secrets.edit_modal.edited', { name: createdData.name })); + onCloseHandler(true); + }) + ); + + return ( + { + onCloseHandler(); + }} + > + + } + onClose={onCloseHandler} + > + + + + + + ); +} + +export default EditSecretModal; diff --git a/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/EndpointsAndCredentials/index.tsx b/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/EndpointsAndCredentials/index.tsx index b57b89e3f..09f880b62 100644 --- a/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/EndpointsAndCredentials/index.tsx +++ b/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/EndpointsAndCredentials/index.tsx @@ -27,6 +27,7 @@ import { type RequestError } from '@/hooks/use-api'; import useCustomDomain from '@/hooks/use-custom-domain'; import CreateSecretModal from '../CreateSecretModal'; +import EditSecretModal from '../EditSecretModal'; import styles from './index.module.scss'; import { type ApplicationSecretRow, useSecretTableColumns } from './use-secret-table-columns'; @@ -51,6 +52,7 @@ function EndpointsAndCredentials({ const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { data: customDomain, applyDomain: applyCustomDomain } = useCustomDomain(); const [showCreateSecretModal, setShowCreateSecretModal] = useState(false); + const [editSecret, setEditSecret] = useState(); const secrets = useSWR(`api/applications/${id}/secrets`); const shouldShowAppSecrets = hasSecrets(type); @@ -87,9 +89,13 @@ function EndpointsAndCredentials({ }, [onApplicationUpdated, secrets] ); + const onEditSecret = useCallback((secret: ApplicationSecretRow) => { + setEditSecret(secret); + }, []); const tableColumns = useSecretTableColumns({ appId: id, onUpdated, + onEdit: onEditSecret, }); return ( { + onClose={(created) => { + if (created) { + void secrets.mutate(); + } setShowCreateSecretModal(false); - void secrets.mutate(); }} /> + {editSecret && ( + { + if (updated) { + void secrets.mutate(); + } + setEditSecret(undefined); + }} + /> + )} )} diff --git a/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/EndpointsAndCredentials/use-secret-table-columns.tsx b/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/EndpointsAndCredentials/use-secret-table-columns.tsx index 72082f38c..d494c0b90 100644 --- a/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/EndpointsAndCredentials/use-secret-table-columns.tsx +++ b/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/EndpointsAndCredentials/use-secret-table-columns.tsx @@ -16,6 +16,8 @@ export type ApplicationSecretRow = Pick compareDesc(expiresAt, new Date()) === 1; + function Expired({ expiresAt }: { readonly expiresAt: Date }) { const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); return ( @@ -31,10 +33,11 @@ function Expired({ expiresAt }: { readonly expiresAt: Date }) { type UseSecretTableColumns = { appId: string; + onEdit: (secret: ApplicationSecretRow) => void; onUpdated: (isLegacy: boolean) => void; }; -export const useSecretTableColumns = ({ appId, onUpdated }: UseSecretTableColumns) => { +export const useSecretTableColumns = ({ appId, onUpdated, onEdit }: UseSecretTableColumns) => { const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const api = useApi(); const tableColumns: Array> = useMemo( @@ -66,7 +69,7 @@ export const useSecretTableColumns = ({ appId, onUpdated }: UseSecretTableColumn render: ({ expiresAt }) => ( {expiresAt ? ( - compareDesc(expiresAt, new Date()) === 1 ? ( + isExpired(expiresAt) ? ( ) : ( {expiresAt} @@ -80,21 +83,31 @@ export const useSecretTableColumns = ({ appId, onUpdated }: UseSecretTableColumn { title: '', dataIndex: 'actions', - render: ({ name, isLegacy }) => ( - { - await (isLegacy - ? api.delete(`api/applications/${appId}/legacy-secret`) - : api.delete(`api/applications/${appId}/secrets/${encodeURIComponent(name)}`)); - onUpdated(isLegacy ?? false); - }} - /> - ), + render: (secret) => { + const { expiresAt, isLegacy, name } = secret; + return ( + { + onEdit(secret); + } + } + onDelete={async () => { + await (isLegacy + ? api.delete(`api/applications/${appId}/legacy-secret`) + : api.delete(`api/applications/${appId}/secrets/${encodeURIComponent(name)}`)); + onUpdated(isLegacy ?? false); + }} + /> + ); + }, }, ], - [api, appId, onUpdated, t] + [api, appId, onEdit, onUpdated, t] ); return tableColumns; diff --git a/packages/core/src/middleware/koa-security-headers.ts b/packages/core/src/middleware/koa-security-headers.ts index 5e3203739..00cfa54e8 100644 --- a/packages/core/src/middleware/koa-security-headers.ts +++ b/packages/core/src/middleware/koa-security-headers.ts @@ -38,7 +38,13 @@ export default function koaSecurityHeaders( const coreOrigins = urlSet.origins; const developmentOrigins = isProduction ? [] - : ['ws:', ...['6001', '6002', '6003'].map((port) => `ws://localhost:${port}`)]; + : [ + 'ws:', + ...['6001', '6002', '6003'].flatMap((port) => [ + `ws://localhost:${port}`, + `http://localhost:${port}`, + ]), + ]; const logtoOrigin = 'https://*.logto.io'; /** Google Sign-In (GSI) origin for Google One Tap. */ const gsiOrigin = 'https://accounts.google.com/gsi/'; diff --git a/packages/core/src/queries/application-secrets.ts b/packages/core/src/queries/application-secrets.ts index 8124ee221..80faadbfb 100644 --- a/packages/core/src/queries/application-secrets.ts +++ b/packages/core/src/queries/application-secrets.ts @@ -5,6 +5,8 @@ import { buildInsertIntoWithPool } from '#src/database/insert-into.js'; import { DeletionError } from '#src/errors/SlonikError/index.js'; import { convertToIdentifiers } from '#src/utils/sql.js'; +import { buildUpdateWhereWithPool } from '../database/update-where.js'; + type ApplicationCredentials = ApplicationSecret & { /** The original application secret that stored in the `applications` table. */ originalSecret: string; @@ -17,6 +19,8 @@ export class ApplicationSecretQueries { returning: true, }); + public readonly update = buildUpdateWhereWithPool(this.pool)(ApplicationSecrets, true); + constructor(public readonly pool: CommonQueryMethods) {} async findByCredentials(appId: string, appSecret: string) { diff --git a/packages/core/src/routes/applications/application-secret.openapi.json b/packages/core/src/routes/applications/application-secret.openapi.json index ea8be00c0..43f826d74 100644 --- a/packages/core/src/routes/applications/application-secret.openapi.json +++ b/packages/core/src/routes/applications/application-secret.openapi.json @@ -69,6 +69,35 @@ "description": "The secret was deleted successfully." } } + }, + "patch": { + "summary": "Update application secret", + "description": "Update a secret for the application by name.", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "The name of the secret." + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "name": { + "description": "The secret name to update. Must be unique within the application." + } + } + } + } + } + }, + "responses": { + "204": { + "description": "The secret was updated successfully." + } + } } } } diff --git a/packages/core/src/routes/applications/application-secret.ts b/packages/core/src/routes/applications/application-secret.ts index 1728e6cbc..42a79e859 100644 --- a/packages/core/src/routes/applications/application-secret.ts +++ b/packages/core/src/routes/applications/application-secret.ts @@ -104,4 +104,27 @@ export default function applicationSecretRoutes( return next(); } ); + + router.patch( + '/applications/:id/secrets/:name', + koaGuard({ + params: z.object({ id: z.string(), name: z.string() }), + body: ApplicationSecrets.updateGuard.pick({ name: true }).required(), + response: ApplicationSecrets.guard, + status: [200, 400, 404], + }), + async (ctx, next) => { + const { + params: { id: appId, name }, + body, + } = ctx.guard; + + ctx.body = await queries.applicationSecrets.update({ + where: { applicationId: appId, name }, + set: body, + jsonbMode: 'replace', + }); + return next(); + } + ); } diff --git a/packages/integration-tests/src/api/application.ts b/packages/integration-tests/src/api/application.ts index c0f68b989..b33296ec7 100644 --- a/packages/integration-tests/src/api/application.ts +++ b/packages/integration-tests/src/api/application.ts @@ -148,6 +148,17 @@ export const getApplicationSecrets = async (applicationId: string) => export const deleteApplicationSecret = async (applicationId: string, secretName: string) => authedAdminApi.delete(`applications/${applicationId}/secrets/${secretName}`); +export const updateApplicationSecret = async ( + applicationId: string, + secretName: string, + body: Record +) => + authedAdminApi + .patch(`applications/${applicationId}/secrets/${secretName}`, { + json: body, + }) + .json(); + export const deleteLegacyApplicationSecret = async (applicationId: string) => authedAdminApi.delete(`applications/${applicationId}/legacy-secret`); diff --git a/packages/integration-tests/src/tests/api/application/application.secrets.test.ts b/packages/integration-tests/src/tests/api/application/application.secrets.test.ts index 40962bccc..3f4634271 100644 --- a/packages/integration-tests/src/tests/api/application/application.secrets.test.ts +++ b/packages/integration-tests/src/tests/api/application/application.secrets.test.ts @@ -8,6 +8,7 @@ import { deleteApplication, deleteApplicationSecret, getApplicationSecrets, + updateApplicationSecret, } from '#src/api/application.js'; import { randomString } from '#src/utils.js'; @@ -157,4 +158,23 @@ describe('application secrets', () => { ]); expect(await getApplicationSecrets(application.id)).toEqual([]); }); + + it('should be able to update application secret', async () => { + const application = await createApplication('application', ApplicationType.MachineToMachine); + const secretName = randomString(); + await createApplicationSecret({ + applicationId: application.id, + name: secretName, + }); + + const newSecretName = randomString(); + const updatedSecret = await updateApplicationSecret(application.id, secretName, { + name: newSecretName, + }); + expect(updatedSecret).toEqual( + expect.objectContaining({ applicationId: application.id, name: newSecretName }) + ); + + await deleteApplicationSecret(application.id, newSecretName); + }); }); diff --git a/packages/phrases/src/locales/en/translation/admin-console/application-details.ts b/packages/phrases/src/locales/en/translation/admin-console/application-details.ts index 5508658b9..a276f7c0a 100644 --- a/packages/phrases/src/locales/en/translation/admin-console/application-details.ts +++ b/packages/phrases/src/locales/en/translation/admin-console/application-details.ts @@ -182,6 +182,11 @@ const application_details = { 'The secret will never expire. We recommend setting an expiration date for enhanced security.', days: '{{count}} day', days_other: '{{count}} days', + created: 'The secret {{name}} has been successfully created.', + }, + edit_modal: { + title: 'Edit application secret', + edited: 'The secret {{name}} has been successfully edited.', }, }, };