0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

fix(console): add in-line error message (#6386)

* fix(console): add in-line error message

add in-line error message

* refactor(console): remove old validation logic

remove old validation logic
This commit is contained in:
simeng-li 2024-08-02 12:02:07 +08:00 committed by GitHub
parent 08e7c52365
commit ce63bba018
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 73 additions and 77 deletions

View file

@ -16,6 +16,7 @@ import {
import TextInput from '@/ds-components/TextInput'; import TextInput from '@/ds-components/TextInput';
import TextLink from '@/ds-components/TextLink'; import TextLink from '@/ds-components/TextLink';
import useDocumentationUrl from '@/hooks/use-documentation-url'; import useDocumentationUrl from '@/hooks/use-documentation-url';
import { isJsonObject } from '@/utils/json';
import ProtectedAppSettings from './ProtectedAppSettings'; import ProtectedAppSettings from './ProtectedAppSettings';
import { type ApplicationForm } from './utils'; import { type ApplicationForm } from './utils';
@ -167,12 +168,21 @@ function Settings({ data }: Props) {
name="customData" name="customData"
control={control} control={control}
defaultValue="{}" defaultValue="{}"
rules={{
validate: (value) =>
isJsonObject(value ?? '') ? true : t('application_details.custom_data_invalid'),
}}
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<FormField <FormField
title="application_details.field_custom_data" title="application_details.field_custom_data"
tip={t('application_details.field_custom_data_tip')} tip={t('application_details.field_custom_data_tip')}
> >
<CodeEditor language="json" value={value} onChange={onChange} /> <CodeEditor
language="json"
value={value}
error={errors.customData?.message}
onChange={onChange}
/>
</FormField> </FormField>
)} )}
/> />

View file

@ -84,24 +84,17 @@ function ApplicationDetailsContent({ data, secrets, oidcConfig, onApplicationUpd
return; return;
} }
const [error, result] = applicationFormDataParser.toRequestPayload(formData); const json = applicationFormDataParser.toRequestPayload(formData);
if (result) { const updatedData = await api
const updatedData = await api .patch(`api/applications/${data.id}`, {
.patch(`api/applications/${data.id}`, { json,
json: result, })
}) .json<ApplicationResponse>();
.json<ApplicationResponse>();
reset(applicationFormDataParser.fromResponse(updatedData)); reset(applicationFormDataParser.fromResponse(updatedData));
onApplicationUpdated(); onApplicationUpdated();
toast.success(t('general.saved')); toast.success(t('general.saved'));
return;
}
if (error) {
toast.error(String(t(error)));
}
}) })
); );

View file

@ -1,8 +1,7 @@
import { type AdminConsoleKey } from '@logto/phrases';
import { customClientMetadataDefault, type ApplicationResponse } from '@logto/schemas'; import { customClientMetadataDefault, type ApplicationResponse } from '@logto/schemas';
import { cond, type DeepPartial, type Nullable } from '@silverhand/essentials'; import { cond, conditional, type DeepPartial } from '@silverhand/essentials';
import { safeParseJsonObject } from '@/utils/json'; import { isJsonObject } from '@/utils/json';
type ProtectedAppMetadataType = ApplicationResponse['protectedAppMetadata']; type ProtectedAppMetadataType = ApplicationResponse['protectedAppMetadata'];
@ -14,7 +13,7 @@ export type ApplicationForm = {
isAdmin?: ApplicationResponse['isAdmin']; isAdmin?: ApplicationResponse['isAdmin'];
// eslint-disable-next-line @typescript-eslint/ban-types // eslint-disable-next-line @typescript-eslint/ban-types
protectedAppMetadata?: Omit<Exclude<ProtectedAppMetadataType, null>, 'customDomains'>; // Custom domains are handled separately protectedAppMetadata?: Omit<Exclude<ProtectedAppMetadataType, null>, 'customDomains'>; // Custom domains are handled separately
customData: string; customData?: string;
}; };
const mapToUriFormatArrays = (value?: string[]) => const mapToUriFormatArrays = (value?: string[]) =>
@ -46,6 +45,7 @@ export const applicationFormDataParser = {
...customClientMetadataDefault, ...customClientMetadataDefault,
...customClientMetadata, ...customClientMetadata,
}, },
customData: JSON.stringify(customData, null, 2),
isAdmin, isAdmin,
} }
), ),
@ -57,12 +57,9 @@ export const applicationFormDataParser = {
}, },
} }
), ),
customData: JSON.stringify(customData, null, 2),
}; };
}, },
toRequestPayload: ( toRequestPayload: (data: ApplicationForm): DeepPartial<ApplicationResponse> => {
data: ApplicationForm
): [Nullable<AdminConsoleKey>, DeepPartial<ApplicationResponse>?] => {
const { const {
name, name,
description, description,
@ -73,47 +70,42 @@ export const applicationFormDataParser = {
customData, customData,
} = data; } = data;
const parsedCustomData = safeParseJsonObject(customData); return {
name,
if (!parsedCustomData.success) { ...cond(
return ['application_details.custom_data_invalid']; !protectedAppMetadata && {
} description,
oidcClientMetadata: {
return [ ...oidcClientMetadata,
null, redirectUris: mapToUriFormatArrays(oidcClientMetadata?.redirectUris),
{ postLogoutRedirectUris: mapToUriFormatArrays(
name, oidcClientMetadata?.postLogoutRedirectUris
...cond( ),
!protectedAppMetadata && { // Empty string is not a valid URL
description, backchannelLogoutUri: cond(oidcClientMetadata?.backchannelLogoutUri),
oidcClientMetadata: { },
...oidcClientMetadata, customClientMetadata: {
redirectUris: mapToUriFormatArrays(oidcClientMetadata?.redirectUris), ...customClientMetadata,
postLogoutRedirectUris: mapToUriFormatArrays( corsAllowedOrigins: mapToUriOriginFormatArrays(
oidcClientMetadata?.postLogoutRedirectUris customClientMetadata?.corsAllowedOrigins
), ),
// Empty string is not a valid URL },
backchannelLogoutUri: cond(oidcClientMetadata?.backchannelLogoutUri), ...conditional(
}, // Invalid JSON string will be guarded by the form field validation
customClientMetadata: { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
...customClientMetadata, customData && isJsonObject(customData) && { customData: JSON.parse(customData) }
corsAllowedOrigins: mapToUriOriginFormatArrays( ),
customClientMetadata?.corsAllowedOrigins isAdmin,
), }
}, ),
customData: parsedCustomData.data, ...cond(
isAdmin, protectedAppMetadata && {
} protectedAppMetadata: {
), ...protectedAppMetadata,
...cond( sessionDuration: protectedAppMetadata.sessionDuration * 3600 * 24,
protectedAppMetadata && { },
protectedAppMetadata: { }
...protectedAppMetadata, ),
sessionDuration: protectedAppMetadata.sessionDuration * 3600 * 24, };
},
}
),
},
];
}, },
}; };

View file

@ -1,4 +1,4 @@
import { type SignInExperience, type Organization, Theme } from '@logto/schemas'; import { Theme, type Organization, type SignInExperience } from '@logto/schemas';
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { Trans, useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
@ -20,12 +20,13 @@ import useApi, { type RequestError } from '@/hooks/use-api';
import { mfa } from '@/hooks/use-console-routes/routes/mfa'; import { mfa } from '@/hooks/use-console-routes/routes/mfa';
import useDocumentationUrl from '@/hooks/use-documentation-url'; import useDocumentationUrl from '@/hooks/use-documentation-url';
import { trySubmitSafe } from '@/utils/form'; import { trySubmitSafe } from '@/utils/form';
import { isJsonObject } from '@/utils/json';
import { type OrganizationDetailsOutletContext } from '../types'; import { type OrganizationDetailsOutletContext } from '../types';
import JitSettings from './JitSettings'; import JitSettings from './JitSettings';
import styles from './index.module.scss'; import styles from './index.module.scss';
import { assembleData, isJsonObject, normalizeData, type FormData } from './utils'; import { assembleData, normalizeData, type FormData } from './utils';
function Settings() { function Settings() {
const { isDeleting, data, jit, onUpdated } = useOutletContext<OrganizationDetailsOutletContext>(); const { isDeleting, data, jit, onUpdated } = useOutletContext<OrganizationDetailsOutletContext>();

View file

@ -1,5 +1,4 @@
import { type Organization } from '@logto/schemas'; import { type Organization } from '@logto/schemas';
import { trySafe } from '@silverhand/essentials';
import { type Option } from '@/ds-components/Select/MultiSelect'; import { type Option } from '@/ds-components/Select/MultiSelect';
import { emptyBranding } from '@/types/sign-in-experience'; import { emptyBranding } from '@/types/sign-in-experience';
@ -11,11 +10,6 @@ export type FormData = Partial<Omit<Organization, 'customData'> & { customData:
jitSsoConnectorIds: string[]; jitSsoConnectorIds: string[];
}; };
export const isJsonObject = (value: string) => {
const parsed = trySafe<unknown>(() => JSON.parse(value));
return Boolean(parsed && typeof parsed === 'object');
};
export const normalizeData = ( export const normalizeData = (
data: Organization, data: Organization,
jit: { emailDomains: string[]; roles: Array<Option<string>>; ssoConnectorIds: string[] } jit: { emailDomains: string[]; roles: Array<Option<string>>; ssoConnectorIds: string[] }

View file

@ -1,4 +1,5 @@
import { jsonGuard, type Json, jsonObjectGuard, type JsonObject } from '@logto/schemas'; import { jsonGuard, jsonObjectGuard, type Json, type JsonObject } from '@logto/schemas';
import { trySafe } from '@silverhand/essentials';
import { t } from 'i18next'; import { t } from 'i18next';
export const safeParseJson = ( export const safeParseJson = (
@ -24,3 +25,8 @@ export const safeParseJsonObject = (
return { success: false, error: t('admin_console.errors.invalid_json_format') }; return { success: false, error: t('admin_console.errors.invalid_json_format') };
} }
}; };
export const isJsonObject = (value: string) => {
const parsed = trySafe<unknown>(() => JSON.parse(value));
return Boolean(parsed && typeof parsed === 'object');
};

View file

@ -96,7 +96,7 @@ const application_details = {
no_organization_placeholder: 'No organization found. <a>Go to organizations</a>', no_organization_placeholder: 'No organization found. <a>Go to organizations</a>',
field_custom_data: 'Custom data', field_custom_data: 'Custom data',
field_custom_data_tip: field_custom_data_tip:
'Additional custom application metadata not listed in the pre-defined application properties, ', 'Additional custom application info not listed in the pre-defined application properties, such as business-specific settings and configurations.',
custom_data_invalid: 'Custom data must be a valid JSON object', custom_data_invalid: 'Custom data must be a valid JSON object',
branding: { branding: {
name: 'Branding', name: 'Branding',