Skip to content

Commit

Permalink
feat(core,console,phrases): add custom data editor to application det…
Browse files Browse the repository at this point in the history
…ails 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
  • Loading branch information
simeng-li authored Aug 1, 2024
1 parent 2d0502a commit b91ec0c
Show file tree
Hide file tree
Showing 15 changed files with 137 additions and 61 deletions.
6 changes: 6 additions & 0 deletions .changeset/gold-mails-sin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@logto/console": minor
"@logto/phrases": minor
---

add the application `custom_data` field editor to the application details page in console
10 changes: 10 additions & 0 deletions .changeset/sweet-rules-hear.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export default function AlwaysIssueRefreshToken() {
await api.patch(`api/applications/${app.id}`, {
json: {
customClientMetadata: {
...app.customClientMetadata,
alwaysIssueRefreshToken: value,
},
},
Expand Down
3 changes: 2 additions & 1 deletion packages/console/src/mdx-components/UriInputField/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -29,7 +31,7 @@ function Settings({ data }: Props) {
control,
register,
formState: { errors },
} = useFormContext<Application>();
} = useFormContext<ApplicationForm>();

const { type: applicationType } = data;

Expand Down Expand Up @@ -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>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)));
}
})
);

Expand Down
Original file line number Diff line number Diff line change
@@ -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'];

Expand All @@ -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[]) =>
Expand All @@ -29,6 +33,7 @@ export const applicationFormDataParser = {
isAdmin,
/** Specific metadata for protected apps */
protectedAppMetadata,
customData,
} = data;

return {
Expand All @@ -52,49 +57,63 @@ export const applicationFormDataParser = {
},
}
),
customData: JSON.stringify(customData, null, 2),
};
},
toRequestPayload: (data: ApplicationForm): DeepPartial<ApplicationResponse> => {
toRequestPayload: (
data: ApplicationForm
): [Nullable<AdminConsoleKey>, DeepPartial<ApplicationResponse>?] => {
const {
name,
description,
oidcClientMetadata,
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,
},
}
),
},
];
},
};
9 changes: 5 additions & 4 deletions packages/core/src/queries/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/routes/applications/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
1 change: 0 additions & 1 deletion packages/core/src/routes/applications/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ export const applicationCreateGuard = originalApplicationCreateGuard
});

export const applicationPatchGuard = originalApplicationPatchGuard
.deepPartial()
.omit({
protectedAppMetadata: true,
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand All @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -108,6 +108,7 @@ describe('application APIs', () => {
await updateApplication(application.id, {
description: newApplicationDescription,
oidcClientMetadata: {
...application.oidcClientMetadata,
redirectUris: newRedirectUris,
},
customClientMetadata: { rotateRefreshToken: true, refreshTokenTtlInDays: 10 },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down

0 comments on commit b91ec0c

Please sign in to comment.