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:
parent
2d0502a427
commit
b91ec0cd6f
15 changed files with 137 additions and 61 deletions
6
.changeset/gold-mails-sin.md
Normal file
6
.changeset/gold-mails-sin.md
Normal 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
|
10
.changeset/sweet-rules-hear.md
Normal file
10
.changeset/sweet-rules-hear.md
Normal 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.
|
|
@ -23,6 +23,7 @@ export default function AlwaysIssueRefreshToken() {
|
|||
await api.patch(`api/applications/${app.id}`, {
|
||||
json: {
|
||||
customClientMetadata: {
|
||||
...app.customClientMetadata,
|
||||
alwaysIssueRefreshToken: value,
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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),
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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)));
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
),
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -18,7 +18,6 @@ export const applicationCreateGuard = originalApplicationCreateGuard
|
|||
});
|
||||
|
||||
export const applicationPatchGuard = originalApplicationPatchGuard
|
||||
.deepPartial()
|
||||
.omit({
|
||||
protectedAppMetadata: true,
|
||||
})
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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.',
|
||||
|
|
Loading…
Reference in a new issue