0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

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
This commit is contained in:
simeng-li 2024-08-01 14:11:44 +08:00 committed by GitHub
parent 2d0502a427
commit b91ec0cd6f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 137 additions and 61 deletions

View file

@ -0,0 +1,6 @@
---
"@logto/console": minor
"@logto/phrases": minor
---
add the application `custom_data` field editor to the application details page in console

View file

@ -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.

View file

@ -23,6 +23,7 @@ export default function AlwaysIssueRefreshToken() {
await api.patch(`api/applications/${app.id}`, {
json: {
customClientMetadata: {
...app.customClientMetadata,
alwaysIssueRefreshToken: value,
},
},

View file

@ -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),
},
},

View file

@ -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<Application>();
} = useFormContext<ApplicationForm>();
const { type: applicationType } = data;
@ -161,6 +163,19 @@ function Settings({ data }: Props) {
)}
/>
)}
<Controller
name="customData"
control={control}
defaultValue="{}"
render={({ field: { value, onChange } }) => (
<FormField
title="application_details.field_custom_data"
tip={t('application_details.field_custom_data_tip')}
>
<CodeEditor language="json" value={value} onChange={onChange} />
</FormField>
)}
/>
</FormCard>
);
}

View file

@ -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<ApplicationResponse>();
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<ApplicationResponse>();
reset(applicationFormDataParser.fromResponse(updatedData));
onApplicationUpdated();
toast.success(t('general.saved'));
return;
}
if (error) {
toast.error(String(t(error)));
}
})
);

View file

@ -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<Exclude<ProtectedAppMetadataType, null>, '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<ApplicationResponse> => {
toRequestPayload: (
data: ApplicationForm
): [Nullable<AdminConsoleKey>, DeepPartial<ApplicationResponse>?] => {
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,
},
}
),
},
];
},
};

View file

@ -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<OmitAutoSetFields<CreateApplication>>
) => updateApplication({ set, where: { id }, jsonbMode: 'merge' });
set: Partial<OmitAutoSetFields<CreateApplication>>,
jsonbMode: 'merge' | 'replace' = 'merge'
) => updateApplication({ set, where: { id }, jsonbMode });
const countAllApplications = async () =>
countApplications({

View file

@ -19,10 +19,8 @@ export default function applicationCustomDataRoutes<T extends ManagementApiRoute
const { applicationId } = ctx.guard.params;
const patchPayload = ctx.guard.body;
const { customData } = await queries.applications.findApplicationById(applicationId);
const application = await queries.applications.updateApplicationById(applicationId, {
customData: { ...customData, ...patchPayload },
customData: patchPayload,
});
ctx.body = application.customData;

View file

@ -327,7 +327,7 @@ export default function applicationRoutes<T extends ManagementApiRouter>(
}
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();

View file

@ -18,7 +18,6 @@ export const applicationCreateGuard = originalApplicationCreateGuard
});
export const applicationPatchGuard = originalApplicationPatchGuard
.deepPartial()
.omit({
protectedAppMetadata: true,
})

View file

@ -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);
});
});

View file

@ -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 },

View file

@ -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<Application>();
await authedAdminTenantApi.patch('applications/' + adminConsoleApplicationId, {
json: {
oidcClientMetadata: {
...application.oidcClientMetadata,
backchannelLogoutUri,
},
},

View file

@ -94,6 +94,10 @@ const application_details = {
session_duration: 'Session duration (days)',
try_it: 'Try it',
no_organization_placeholder: 'No organization found. <a>Go to organizations</a>',
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.',