From b91ec0cd6f8f63e08ada8c867e637799cb952cd5 Mon Sep 17 00:00:00 2001 From: simeng-li Date: Thu, 1 Aug 2024 14:11:44 +0800 Subject: [PATCH] feat(core,console,phrases): add custom data editor to application details page (#6370) * feat(core,console,phrases): add custom data editor to application details page add custom data editor to application details page * chore: add changeset add changeset * fix(core): fix input params bug fix input params bug * fix(test): fix the integration tests fix the integration tests * fix(console): use the form controller element use the form controller element * fix(core,console): remove deepPartial statement remove deepPartial statement from the patch application API payload guard * fix(test): fix backchannel integration test fix backchannel integration test --- .changeset/gold-mails-sin.md | 6 ++ .changeset/sweet-rules-hear.md | 10 +++ .../components/AlwaysIssueRefreshToken.tsx | 1 + .../mdx-components/UriInputField/index.tsx | 3 +- .../ApplicationDetailsContent/Settings.tsx | 19 +++- .../ApplicationDetailsContent/index.tsx | 28 ++++-- .../ApplicationDetailsContent/utils.ts | 87 +++++++++++-------- packages/core/src/queries/application.ts | 9 +- .../applications/application-custom-data.ts | 4 +- .../src/routes/applications/application.ts | 2 +- .../core/src/routes/applications/types.ts | 1 - .../application-custom-data.test.ts | 10 ++- .../tests/api/application/application.test.ts | 5 +- .../tests/console/backchannel-logout.test.ts | 9 +- .../admin-console/application-details.ts | 4 + 15 files changed, 137 insertions(+), 61 deletions(-) create mode 100644 .changeset/gold-mails-sin.md create mode 100644 .changeset/sweet-rules-hear.md diff --git a/.changeset/gold-mails-sin.md b/.changeset/gold-mails-sin.md new file mode 100644 index 00000000000..eb3efbd242c --- /dev/null +++ b/.changeset/gold-mails-sin.md @@ -0,0 +1,6 @@ +--- +"@logto/console": minor +"@logto/phrases": minor +--- + +add the application `custom_data` field editor to the application details page in console diff --git a/.changeset/sweet-rules-hear.md b/.changeset/sweet-rules-hear.md new file mode 100644 index 00000000000..6f8b170023b --- /dev/null +++ b/.changeset/sweet-rules-hear.md @@ -0,0 +1,10 @@ +--- +"@logto/core": minor +--- + +update the jsonb field update mode from `merge` to `replace` for the `PATCH /application/:id` endpoint. +remove the `deepPartial` statement from the `PATCH /application/:id` endpoint payload guard. + +For all the jsonb typed fields in the application entity, the update mode is now `replace` instead of `merge`. This means that when you send a `PATCH` request to update an application, the jsonb fields will be replaced with the new values instead of merging them. + +This change is to make the request behavior more strict aligned with the restful API principles for a `PATCH` request. diff --git a/packages/console/src/assets/docs/guides/web-gpt-plugin/components/AlwaysIssueRefreshToken.tsx b/packages/console/src/assets/docs/guides/web-gpt-plugin/components/AlwaysIssueRefreshToken.tsx index 858c8fe0f8d..9a446392cda 100644 --- a/packages/console/src/assets/docs/guides/web-gpt-plugin/components/AlwaysIssueRefreshToken.tsx +++ b/packages/console/src/assets/docs/guides/web-gpt-plugin/components/AlwaysIssueRefreshToken.tsx @@ -23,6 +23,7 @@ export default function AlwaysIssueRefreshToken() { await api.patch(`api/applications/${app.id}`, { json: { customClientMetadata: { + ...app.customClientMetadata, alwaysIssueRefreshToken: value, }, }, diff --git a/packages/console/src/mdx-components/UriInputField/index.tsx b/packages/console/src/mdx-components/UriInputField/index.tsx index f38915c770c..ce864dc3fdc 100644 --- a/packages/console/src/mdx-components/UriInputField/index.tsx +++ b/packages/console/src/mdx-components/UriInputField/index.tsx @@ -70,13 +70,14 @@ function UriInputField(props: Props) { const title: AdminConsoleKey = nameToKey[name]; const onSubmit = trySubmitSafe(async (value: string[]) => { - if (!appId) { + if (!appId || !data) { return; } const updatedApp = await api .patch(`api/applications/${appId}`, { json: { [type]: { + ...data[type], [name]: value.filter(Boolean), }, }, diff --git a/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/Settings.tsx b/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/Settings.tsx index 3c68077fdb2..09f31626563 100644 --- a/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/Settings.tsx +++ b/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/Settings.tsx @@ -6,17 +6,19 @@ import { Trans, useTranslation } from 'react-i18next'; import FormCard from '@/components/FormCard'; import MultiTextInputField from '@/components/MultiTextInputField'; +import CodeEditor from '@/ds-components/CodeEditor'; import FormField from '@/ds-components/FormField'; import type { MultiTextInputRule } from '@/ds-components/MultiTextInput/types'; import { - createValidatorForRhf, convertRhfErrorMessage, + createValidatorForRhf, } from '@/ds-components/MultiTextInput/utils'; import TextInput from '@/ds-components/TextInput'; import TextLink from '@/ds-components/TextLink'; import useDocumentationUrl from '@/hooks/use-documentation-url'; import ProtectedAppSettings from './ProtectedAppSettings'; +import { type ApplicationForm } from './utils'; type Props = { readonly data: Application; @@ -29,7 +31,7 @@ function Settings({ data }: Props) { control, register, formState: { errors }, - } = useFormContext(); + } = useFormContext(); const { type: applicationType } = data; @@ -161,6 +163,19 @@ function Settings({ data }: Props) { )} /> )} + ( + + + + )} + /> ); } diff --git a/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/index.tsx b/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/index.tsx index 2a2db9a37ef..81ac8561ef3 100644 --- a/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/index.tsx +++ b/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/index.tsx @@ -40,7 +40,7 @@ import Permissions from './Permissions'; import RefreshTokenSettings from './RefreshTokenSettings'; import Settings from './Settings'; import styles from './index.module.scss'; -import { type ApplicationForm, applicationFormDataParser } from './utils'; +import { applicationFormDataParser, type ApplicationForm } from './utils'; type Props = { readonly data: ApplicationResponse; @@ -84,14 +84,24 @@ function ApplicationDetailsContent({ data, secrets, oidcConfig, onApplicationUpd return; } - const updatedData = await api - .patch(`api/applications/${data.id}`, { - json: applicationFormDataParser.toRequestPayload(formData), - }) - .json(); - reset(applicationFormDataParser.fromResponse(updatedData)); - onApplicationUpdated(); - toast.success(t('general.saved')); + const [error, result] = applicationFormDataParser.toRequestPayload(formData); + + if (result) { + const updatedData = await api + .patch(`api/applications/${data.id}`, { + json: result, + }) + .json(); + + reset(applicationFormDataParser.fromResponse(updatedData)); + onApplicationUpdated(); + toast.success(t('general.saved')); + return; + } + + if (error) { + toast.error(String(t(error))); + } }) ); diff --git a/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/utils.ts b/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/utils.ts index 145eae866be..97501b0e95c 100644 --- a/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/utils.ts +++ b/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/utils.ts @@ -1,5 +1,8 @@ +import { type AdminConsoleKey } from '@logto/phrases'; import { customClientMetadataDefault, type ApplicationResponse } from '@logto/schemas'; -import { type DeepPartial, cond } from '@silverhand/essentials'; +import { cond, type DeepPartial, type Nullable } from '@silverhand/essentials'; + +import { safeParseJsonObject } from '@/utils/json'; type ProtectedAppMetadataType = ApplicationResponse['protectedAppMetadata']; @@ -11,6 +14,7 @@ export type ApplicationForm = { isAdmin?: ApplicationResponse['isAdmin']; // eslint-disable-next-line @typescript-eslint/ban-types protectedAppMetadata?: Omit, 'customDomains'>; // Custom domains are handled separately + customData: string; }; const mapToUriFormatArrays = (value?: string[]) => @@ -29,6 +33,7 @@ export const applicationFormDataParser = { isAdmin, /** Specific metadata for protected apps */ protectedAppMetadata, + customData, } = data; return { @@ -52,9 +57,12 @@ export const applicationFormDataParser = { }, } ), + customData: JSON.stringify(customData, null, 2), }; }, - toRequestPayload: (data: ApplicationForm): DeepPartial => { + toRequestPayload: ( + data: ApplicationForm + ): [Nullable, DeepPartial?] => { const { name, description, @@ -62,39 +70,50 @@ export const applicationFormDataParser = { customClientMetadata, isAdmin, protectedAppMetadata, + customData, } = data; - return { - name, - ...cond( - !protectedAppMetadata && { - description, - oidcClientMetadata: { - ...oidcClientMetadata, - redirectUris: mapToUriFormatArrays(oidcClientMetadata?.redirectUris), - postLogoutRedirectUris: mapToUriFormatArrays( - oidcClientMetadata?.postLogoutRedirectUris - ), - // Empty string is not a valid URL - backchannelLogoutUri: cond(oidcClientMetadata?.backchannelLogoutUri), - }, - customClientMetadata: { - ...customClientMetadata, - corsAllowedOrigins: mapToUriOriginFormatArrays( - customClientMetadata?.corsAllowedOrigins - ), - }, - isAdmin, - } - ), - ...cond( - protectedAppMetadata && { - protectedAppMetadata: { - ...protectedAppMetadata, - sessionDuration: protectedAppMetadata.sessionDuration * 3600 * 24, - }, - } - ), - }; + const parsedCustomData = safeParseJsonObject(customData); + + if (!parsedCustomData.success) { + return ['application_details.custom_data_invalid']; + } + + return [ + null, + { + name, + ...cond( + !protectedAppMetadata && { + description, + oidcClientMetadata: { + ...oidcClientMetadata, + redirectUris: mapToUriFormatArrays(oidcClientMetadata?.redirectUris), + postLogoutRedirectUris: mapToUriFormatArrays( + oidcClientMetadata?.postLogoutRedirectUris + ), + // Empty string is not a valid URL + backchannelLogoutUri: cond(oidcClientMetadata?.backchannelLogoutUri), + }, + customClientMetadata: { + ...customClientMetadata, + corsAllowedOrigins: mapToUriOriginFormatArrays( + customClientMetadata?.corsAllowedOrigins + ), + }, + customData: parsedCustomData.data, + isAdmin, + } + ), + ...cond( + protectedAppMetadata && { + protectedAppMetadata: { + ...protectedAppMetadata, + sessionDuration: protectedAppMetadata.sessionDuration * 3600 * 24, + }, + } + ), + }, + ]; }, }; diff --git a/packages/core/src/queries/application.ts b/packages/core/src/queries/application.ts index 12d8a48ce44..3f060e12d6c 100644 --- a/packages/core/src/queries/application.ts +++ b/packages/core/src/queries/application.ts @@ -14,10 +14,10 @@ import { buildInsertIntoWithPool } from '#src/database/insert-into.js'; import { getTotalRowCountWithPool } from '#src/database/row-count.js'; import { buildUpdateWhereWithPool } from '#src/database/update-where.js'; import { DeletionError } from '#src/errors/SlonikError/index.js'; -import { buildConditionsFromSearch } from '#src/utils/search.js'; import type { Search } from '#src/utils/search.js'; -import { convertToIdentifiers, conditionalSql, conditionalArraySql } from '#src/utils/sql.js'; +import { buildConditionsFromSearch } from '#src/utils/search.js'; import type { OmitAutoSetFields } from '#src/utils/sql.js'; +import { conditionalArraySql, conditionalSql, convertToIdentifiers } from '#src/utils/sql.js'; import ApplicationUserConsentOrganizationsQuery from './application-user-consent-organizations.js'; import { @@ -145,8 +145,9 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => { const updateApplicationById = async ( id: string, - set: Partial> - ) => updateApplication({ set, where: { id }, jsonbMode: 'merge' }); + set: Partial>, + jsonbMode: 'merge' | 'replace' = 'merge' + ) => updateApplication({ set, where: { id }, jsonbMode }); const countAllApplications = async () => countApplications({ diff --git a/packages/core/src/routes/applications/application-custom-data.ts b/packages/core/src/routes/applications/application-custom-data.ts index 6af47260b2f..d4c6f21f2b1 100644 --- a/packages/core/src/routes/applications/application-custom-data.ts +++ b/packages/core/src/routes/applications/application-custom-data.ts @@ -19,10 +19,8 @@ export default function applicationCustomDataRoutes( } ctx.body = await (Object.keys(rest).length > 0 - ? queries.applications.updateApplicationById(id, rest) + ? queries.applications.updateApplicationById(id, rest, 'replace') : queries.applications.findApplicationById(id)); return next(); diff --git a/packages/core/src/routes/applications/types.ts b/packages/core/src/routes/applications/types.ts index 135e64b643c..33ca10fc99b 100644 --- a/packages/core/src/routes/applications/types.ts +++ b/packages/core/src/routes/applications/types.ts @@ -18,7 +18,6 @@ export const applicationCreateGuard = originalApplicationCreateGuard }); export const applicationPatchGuard = originalApplicationPatchGuard - .deepPartial() .omit({ protectedAppMetadata: true, }) diff --git a/packages/integration-tests/src/tests/api/application/application-custom-data.test.ts b/packages/integration-tests/src/tests/api/application/application-custom-data.test.ts index 508ec08a09c..ccd860fb87f 100644 --- a/packages/integration-tests/src/tests/api/application/application-custom-data.test.ts +++ b/packages/integration-tests/src/tests/api/application/application-custom-data.test.ts @@ -23,6 +23,12 @@ describe('application custom data API', () => { expect(fetchedApplication.customData.key).toEqual(customData.key); + await patchApplicationCustomData(application.id, { key: 'new-value', test: 'foo' }); + + const updatedApplication = await getApplication(application.id); + expect(updatedApplication.customData.key).toEqual('new-value'); + expect(updatedApplication.customData.test).toEqual('foo'); + await deleteApplication(application.id); }); @@ -37,10 +43,10 @@ describe('application custom data API', () => { expect(result.key).toEqual(customData.key); await updateApplication(application.id, { - customData: { key: 'bar' }, + customData: {}, }); const fetchedApplication = await getApplication(application.id); - expect(fetchedApplication.customData.key).toEqual('bar'); + expect(Object.keys(fetchedApplication.customData)).toHaveLength(0); }); }); diff --git a/packages/integration-tests/src/tests/api/application/application.test.ts b/packages/integration-tests/src/tests/api/application/application.test.ts index 346d9ba105e..6093436f26b 100644 --- a/packages/integration-tests/src/tests/api/application/application.test.ts +++ b/packages/integration-tests/src/tests/api/application/application.test.ts @@ -3,10 +3,10 @@ import { HTTPError } from 'ky'; import { createApplication, - getApplication, - updateApplication, deleteApplication, + getApplication, getApplications, + updateApplication, } from '#src/api/index.js'; import { expectRejects } from '#src/helpers/index.js'; @@ -108,6 +108,7 @@ describe('application APIs', () => { await updateApplication(application.id, { description: newApplicationDescription, oidcClientMetadata: { + ...application.oidcClientMetadata, redirectUris: newRedirectUris, }, customClientMetadata: { rotateRefreshToken: true, refreshTokenTtlInDays: 10 }, diff --git a/packages/integration-tests/src/tests/console/backchannel-logout.test.ts b/packages/integration-tests/src/tests/console/backchannel-logout.test.ts index ff505d7f7fa..27e807bf078 100644 --- a/packages/integration-tests/src/tests/console/backchannel-logout.test.ts +++ b/packages/integration-tests/src/tests/console/backchannel-logout.test.ts @@ -17,9 +17,9 @@ * from the Console and check if the backchannel logout endpoint is called. */ -import { type Server, type RequestListener, createServer } from 'node:http'; +import { createServer, type RequestListener, type Server } from 'node:http'; -import { adminConsoleApplicationId } from '@logto/schemas'; +import { adminConsoleApplicationId, type Application } from '@logto/schemas'; import { authedAdminTenantApi } from '#src/api/api.js'; import ExpectConsole from '#src/ui-helpers/expect-console.js'; @@ -91,9 +91,14 @@ describe('backchannel logout', () => { }); it('should call the backchannel logout endpoint when a user logs out', async () => { + const application = await authedAdminTenantApi + .get('applications/' + adminConsoleApplicationId) + .json(); + await authedAdminTenantApi.patch('applications/' + adminConsoleApplicationId, { json: { oidcClientMetadata: { + ...application.oidcClientMetadata, backchannelLogoutUri, }, }, 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 a276f7c0ab1..242a7a75d39 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 @@ -94,6 +94,10 @@ const application_details = { session_duration: 'Session duration (days)', try_it: 'Try it', no_organization_placeholder: 'No organization found. Go to organizations', + field_custom_data: 'Custom data', + field_custom_data_tip: + 'Additional custom application metadata not listed in the pre-defined application properties, ', + custom_data_invalid: 'Custom data must be a valid JSON object', branding: { name: 'Branding', description: 'Customize your app logo and branding color for the app-level experience.',