Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core,console,phrases): add custom data editor to application details page #6370

Merged
merged 7 commits into from
Aug 1, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
simeng-li marked this conversation as resolved.
Show resolved Hide resolved
8 changes: 8 additions & 0 deletions .changeset/sweet-rules-hear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@logto/core": minor
---

update the jsonb field update mode from 'merge' to 'replace' for the PATCH /application/:id endpoint.
simeng-li marked this conversation as resolved.
Show resolved Hide resolved

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.
simeng-li marked this conversation as resolved.
Show resolved Hide resolved
This change is to make the request behavior more strict aligned with the restful API principles for a PATCH request.
simeng-li marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
import { validateRedirectUrl } from '@logto/core-kit';
import type { Application } from '@logto/schemas';
import { ApplicationType } from '@logto/schemas';
import { Controller, useFormContext } from 'react-hook-form';
import { Controller, useController, useFormContext } from 'react-hook-form';
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,9 +31,10 @@ function Settings({ data }: Props) {
control,
register,
formState: { errors },
} = useFormContext<Application>();
} = useFormContext<ApplicationForm>();

const { type: applicationType } = data;
const { field: customData } = useController({ name: 'customData', control });
xiaoyijun marked this conversation as resolved.
Show resolved Hide resolved

const isNativeApp = applicationType === ApplicationType.Native;
const isProtectedApp = applicationType === ApplicationType.Protected;
Expand Down Expand Up @@ -161,6 +164,12 @@ function Settings({ data }: Props) {
)}
/>
)}
<FormField
title="application_details.field_custom_data"
tip={t('application_details.field_custom_data_tip')}
>
<CodeEditor language="json" value={customData.value} onChange={customData.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
@@ -1,4 +1,4 @@
// TODO: @darcyYe refactor this file later to remove disable max line comment

Check warning on line 1 in packages/core/src/routes/applications/application.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/core/src/routes/applications/application.ts#L1

[no-warning-comments] Unexpected 'todo' comment: 'TODO: @darcyYe refactor this file later...'.
/* eslint-disable max-lines */
import type { Role } from '@logto/schemas';
import {
Expand Down Expand Up @@ -327,7 +327,7 @@
}

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
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,11 +17,12 @@
* 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 { authedAdminTenantApi } from '#src/api/api.js';
import { getApplication } from '#src/api/application.js';
import ExpectConsole from '#src/ui-helpers/expect-console.js';
import { waitFor } from '#src/utils.js';

Expand Down Expand Up @@ -91,9 +92,11 @@ describe('backchannel logout', () => {
});

it('should call the backchannel logout endpoint when a user logs out', async () => {
const application = await getApplication(adminConsoleApplicationId);
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
Loading