mirror of
https://github.com/logto-io/logto.git
synced 2025-03-24 22:41:28 -05:00
feat(console): configure cors-allowed-origins (#695)
This commit is contained in:
parent
da49812164
commit
4a0577accd
12 changed files with 231 additions and 150 deletions
|
@ -10,7 +10,7 @@ import * as styles from './index.module.scss';
|
|||
import { MultiTextInputError } from './types';
|
||||
|
||||
type Props = {
|
||||
value: string[];
|
||||
value?: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
error?: MultiTextInputError;
|
||||
};
|
||||
|
@ -21,7 +21,7 @@ const MultiTextInput = ({ value, onChange, error }: Props) => {
|
|||
});
|
||||
|
||||
const fields = useMemo(() => {
|
||||
if (value.length === 0) {
|
||||
if (!value?.length) {
|
||||
return [''];
|
||||
}
|
||||
|
||||
|
|
|
@ -6,7 +6,7 @@ export type MultiTextInputError = {
|
|||
export type MultiTextInputRule = {
|
||||
required?: string;
|
||||
pattern?: {
|
||||
regex: RegExp;
|
||||
verify: (value: string) => boolean;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
|
|
@ -1,29 +1,29 @@
|
|||
import { conditional } from '@silverhand/essentials';
|
||||
|
||||
import { MultiTextInputError, MultiTextInputRule } from './types';
|
||||
|
||||
export const validate = (
|
||||
value: string[],
|
||||
value?: string[],
|
||||
rule?: MultiTextInputRule
|
||||
): MultiTextInputError | undefined => {
|
||||
if (!rule) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requiredError = conditional(value.filter(Boolean).length === 0 && rule.required);
|
||||
if (!value?.filter(Boolean).length) {
|
||||
if (!rule.required) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (requiredError) {
|
||||
return {
|
||||
required: requiredError,
|
||||
required: rule.required,
|
||||
};
|
||||
}
|
||||
|
||||
if (rule.pattern) {
|
||||
const { regex, message } = rule.pattern;
|
||||
const { verify, message } = rule.pattern;
|
||||
|
||||
const inputErrors = Object.fromEntries(
|
||||
value.map((element, index) => {
|
||||
return [index, regex.test(element) ? undefined : message];
|
||||
return [index, verify(element) ? undefined : message];
|
||||
})
|
||||
);
|
||||
|
||||
|
@ -40,7 +40,7 @@ export const validate = (
|
|||
*/
|
||||
export const createValidatorForRhf =
|
||||
(rule: MultiTextInputRule) =>
|
||||
(value: string[]): boolean | string => {
|
||||
(value?: string[]): boolean | string => {
|
||||
const error = validate(value, rule);
|
||||
|
||||
if (error) {
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
import { SnakeCaseOidcConfig } from '@logto/schemas';
|
||||
import React from 'react';
|
||||
|
||||
import CopyToClipboard from '@/components/CopyToClipboard';
|
||||
import FormField from '@/components/FormField';
|
||||
|
||||
import * as styles from '../index.module.scss';
|
||||
|
||||
type Props = {
|
||||
oidcConfig: SnakeCaseOidcConfig;
|
||||
};
|
||||
|
||||
const AdvancedSettings = ({ oidcConfig }: Props) => {
|
||||
return (
|
||||
<>
|
||||
<FormField title="admin_console.application_details.token_endpoint">
|
||||
<CopyToClipboard
|
||||
className={styles.textField}
|
||||
value={oidcConfig.token_endpoint}
|
||||
variant="border"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField title="admin_console.application_details.user_info_endpoint">
|
||||
<CopyToClipboard
|
||||
className={styles.textField}
|
||||
value={oidcConfig.userinfo_endpoint}
|
||||
variant="border"
|
||||
/>
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdvancedSettings;
|
|
@ -0,0 +1,121 @@
|
|||
import { Application, SnakeCaseOidcConfig } from '@logto/schemas';
|
||||
import React from 'react';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import CopyToClipboard from '@/components/CopyToClipboard';
|
||||
import FormField from '@/components/FormField';
|
||||
import MultiTextInput from '@/components/MultiTextInput';
|
||||
import { MultiTextInputRule } from '@/components/MultiTextInput/types';
|
||||
import { createValidatorForRhf, convertRhfErrorMessage } from '@/components/MultiTextInput/utils';
|
||||
import TextInput from '@/components/TextInput';
|
||||
import { uriValidator } from '@/utilities/validator';
|
||||
|
||||
import * as styles from '../index.module.scss';
|
||||
|
||||
type Props = {
|
||||
oidcConfig: SnakeCaseOidcConfig;
|
||||
};
|
||||
|
||||
const Settings = ({ oidcConfig }: Props) => {
|
||||
const { control, register } = useFormContext<Application>();
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
const uriPatternRules: MultiTextInputRule = {
|
||||
pattern: {
|
||||
verify: uriValidator(false),
|
||||
message: t('errors.invalid_uri_format'),
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
isRequired
|
||||
title="admin_console.application_details.application_name"
|
||||
className={styles.textField}
|
||||
>
|
||||
<TextInput {...register('name', { required: true })} />
|
||||
</FormField>
|
||||
<FormField title="admin_console.application_details.description" className={styles.textField}>
|
||||
<TextInput {...register('description')} />
|
||||
</FormField>
|
||||
<FormField
|
||||
title="admin_console.application_details.authorization_endpoint"
|
||||
className={styles.textField}
|
||||
>
|
||||
<CopyToClipboard
|
||||
className={styles.textField}
|
||||
value={oidcConfig.authorization_endpoint}
|
||||
variant="border"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
isRequired
|
||||
title="admin_console.application_details.redirect_uri"
|
||||
className={styles.textField}
|
||||
>
|
||||
<Controller
|
||||
name="oidcClientMetadata.redirectUris"
|
||||
control={control}
|
||||
defaultValue={[]}
|
||||
rules={{
|
||||
validate: createValidatorForRhf({
|
||||
...uriPatternRules,
|
||||
required: t('application_details.redirect_uri_required'),
|
||||
}),
|
||||
}}
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<MultiTextInput
|
||||
value={value}
|
||||
error={convertRhfErrorMessage(error?.message)}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
title="admin_console.application_details.post_sign_out_redirect_uri"
|
||||
className={styles.textField}
|
||||
>
|
||||
<Controller
|
||||
name="oidcClientMetadata.postLogoutRedirectUris"
|
||||
control={control}
|
||||
defaultValue={[]}
|
||||
rules={{
|
||||
validate: createValidatorForRhf(uriPatternRules),
|
||||
}}
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<MultiTextInput
|
||||
value={value}
|
||||
error={convertRhfErrorMessage(error?.message)}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
title="admin_console.application_details.cors_allowed_origins"
|
||||
className={styles.textField}
|
||||
>
|
||||
<Controller
|
||||
name="customClientMetadata.corsAllowedOrigins"
|
||||
control={control}
|
||||
defaultValue={[]}
|
||||
rules={{
|
||||
validate: createValidatorForRhf(uriPatternRules),
|
||||
}}
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<MultiTextInput
|
||||
value={value}
|
||||
error={convertRhfErrorMessage(error?.message)}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
|
@ -1,7 +1,7 @@
|
|||
import { Application, SnakeCaseOidcConfig } from '@logto/schemas';
|
||||
import classNames from 'classnames';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Modal from 'react-modal';
|
||||
|
@ -13,13 +13,9 @@ import Button from '@/components/Button';
|
|||
import Card from '@/components/Card';
|
||||
import CopyToClipboard from '@/components/CopyToClipboard';
|
||||
import Drawer from '@/components/Drawer';
|
||||
import FormField from '@/components/FormField';
|
||||
import ImagePlaceholder from '@/components/ImagePlaceholder';
|
||||
import LinkButton from '@/components/LinkButton';
|
||||
import MultiTextInput from '@/components/MultiTextInput';
|
||||
import { convertRhfErrorMessage, createValidatorForRhf } from '@/components/MultiTextInput/utils';
|
||||
import TabNav, { TabNavLink } from '@/components/TabNav';
|
||||
import TextInput from '@/components/TextInput';
|
||||
import useApi, { RequestError } from '@/hooks/use-api';
|
||||
import Back from '@/icons/Back';
|
||||
import Delete from '@/icons/Delete';
|
||||
|
@ -27,38 +23,37 @@ import More from '@/icons/More';
|
|||
import * as detailsStyles from '@/scss/details.module.scss';
|
||||
import * as modalStyles from '@/scss/modal.module.scss';
|
||||
import { applicationTypeI18nKey } from '@/types/applications';
|
||||
import { noSpaceRegex } from '@/utilities/regex';
|
||||
|
||||
import AdvancedSettings from './components/AdvancedSettings';
|
||||
import DeleteForm from './components/DeleteForm';
|
||||
import Settings from './components/Settings';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
const mapToUriFormatArrays = (value?: string[]) =>
|
||||
value?.filter(Boolean).map((uri) => decodeURIComponent(new URL(uri).toString()));
|
||||
|
||||
const ApplicationDetails = () => {
|
||||
const { id } = useParams();
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
const { data, error, mutate } = useSWR<Application, RequestError>(
|
||||
id && `/api/applications/${id}`
|
||||
);
|
||||
|
||||
const { data: oidcConfig, error: fetchOidcConfigError } = useSWR<
|
||||
SnakeCaseOidcConfig,
|
||||
RequestError
|
||||
>('/oidc/.well-known/openid-configuration');
|
||||
const isLoading = !data && !error && !oidcConfig && !fetchOidcConfigError;
|
||||
|
||||
const [isReadmeOpen, setIsReadmeOpen] = useState(false);
|
||||
|
||||
const [isDeleteFormOpen, setIsDeleteFormOpen] = useState(false);
|
||||
const api = useApi();
|
||||
const formMethods = useForm<Application>();
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
register,
|
||||
reset,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<Application>();
|
||||
} = formMethods;
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) {
|
||||
|
@ -79,9 +74,16 @@ const ApplicationDetails = () => {
|
|||
...formData,
|
||||
oidcClientMetadata: {
|
||||
...formData.oidcClientMetadata,
|
||||
redirectUris: formData.oidcClientMetadata.redirectUris.filter(Boolean),
|
||||
postLogoutRedirectUris:
|
||||
formData.oidcClientMetadata.postLogoutRedirectUris.filter(Boolean),
|
||||
redirectUris: mapToUriFormatArrays(formData.oidcClientMetadata.redirectUris),
|
||||
postLogoutRedirectUris: mapToUriFormatArrays(
|
||||
formData.oidcClientMetadata.postLogoutRedirectUris
|
||||
),
|
||||
},
|
||||
customClientMetadata: {
|
||||
...formData.customClientMetadata,
|
||||
corsAllowedOrigins: mapToUriFormatArrays(
|
||||
formData.customClientMetadata.corsAllowedOrigins
|
||||
),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
@ -92,102 +94,6 @@ const ApplicationDetails = () => {
|
|||
|
||||
const isAdvancedSettings = location.pathname.includes('advanced-settings');
|
||||
|
||||
const SettingsPage = oidcConfig && (
|
||||
<>
|
||||
<FormField
|
||||
isRequired
|
||||
title="admin_console.application_details.application_name"
|
||||
className={styles.textField}
|
||||
>
|
||||
<TextInput {...register('name', { required: true })} />
|
||||
</FormField>
|
||||
<FormField title="admin_console.application_details.description" className={styles.textField}>
|
||||
<TextInput {...register('description')} />
|
||||
</FormField>
|
||||
<FormField
|
||||
title="admin_console.application_details.authorization_endpoint"
|
||||
className={styles.textField}
|
||||
>
|
||||
<CopyToClipboard
|
||||
className={styles.textField}
|
||||
value={oidcConfig.authorization_endpoint}
|
||||
variant="border"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
isRequired
|
||||
title="admin_console.application_details.redirect_uri"
|
||||
className={styles.textField}
|
||||
>
|
||||
<Controller
|
||||
name="oidcClientMetadata.redirectUris"
|
||||
control={control}
|
||||
defaultValue={[]}
|
||||
rules={{
|
||||
validate: createValidatorForRhf({
|
||||
required: t('application_details.redirect_uri_required'),
|
||||
pattern: {
|
||||
regex: noSpaceRegex,
|
||||
message: t('errors.no_space_in_uri'),
|
||||
},
|
||||
}),
|
||||
}}
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<MultiTextInput
|
||||
value={value}
|
||||
error={convertRhfErrorMessage(error?.message)}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
title="admin_console.application_details.post_sign_out_redirect_uri"
|
||||
className={styles.textField}
|
||||
>
|
||||
<Controller
|
||||
name="oidcClientMetadata.postLogoutRedirectUris"
|
||||
control={control}
|
||||
defaultValue={[]}
|
||||
rules={{
|
||||
validate: createValidatorForRhf({
|
||||
pattern: {
|
||||
regex: noSpaceRegex,
|
||||
message: t('errors.no_space_in_uri'),
|
||||
},
|
||||
}),
|
||||
}}
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<MultiTextInput
|
||||
value={value}
|
||||
error={convertRhfErrorMessage(error?.message)}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
|
||||
const AdvancedSettingsPage = oidcConfig && (
|
||||
<>
|
||||
<FormField title="admin_console.application_details.token_endpoint">
|
||||
<CopyToClipboard
|
||||
className={styles.textField}
|
||||
value={oidcConfig.token_endpoint}
|
||||
variant="border"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField title="admin_console.application_details.user_info_endpoint">
|
||||
<CopyToClipboard
|
||||
className={styles.textField}
|
||||
value={oidcConfig.userinfo_endpoint}
|
||||
variant="border"
|
||||
/>
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={detailsStyles.container}>
|
||||
<LinkButton
|
||||
|
@ -197,7 +103,6 @@ const ApplicationDetails = () => {
|
|||
className={styles.backLink}
|
||||
/>
|
||||
{isLoading && <div>loading</div>}
|
||||
{error && <div>{`error occurred: ${error.body.message}`}</div>}
|
||||
{data && oidcConfig && (
|
||||
<>
|
||||
<Card className={styles.header}>
|
||||
|
@ -268,22 +173,28 @@ const ApplicationDetails = () => {
|
|||
{t('application_details.advanced_settings')}
|
||||
</TabNavLink>
|
||||
</TabNav>
|
||||
<form className={styles.form} onSubmit={onSubmit}>
|
||||
<div className={styles.fields}>
|
||||
{isAdvancedSettings ? AdvancedSettingsPage : SettingsPage}
|
||||
</div>
|
||||
<div className={detailsStyles.footer}>
|
||||
<div className={detailsStyles.footerMain}>
|
||||
<Button
|
||||
isLoading={isSubmitting}
|
||||
htmlType="submit"
|
||||
type="primary"
|
||||
size="large"
|
||||
title="admin_console.application_details.save_changes"
|
||||
/>
|
||||
<FormProvider {...formMethods}>
|
||||
<form className={styles.form} onSubmit={onSubmit}>
|
||||
<div className={styles.fields}>
|
||||
{isAdvancedSettings ? (
|
||||
<AdvancedSettings oidcConfig={oidcConfig} />
|
||||
) : (
|
||||
<Settings oidcConfig={oidcConfig} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div className={detailsStyles.footer}>
|
||||
<div className={detailsStyles.footerMain}>
|
||||
<Button
|
||||
isLoading={isSubmitting}
|
||||
htmlType="submit"
|
||||
type="primary"
|
||||
size="large"
|
||||
title="admin_console.application_details.save_changes"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -7,7 +7,7 @@ import FormField from '@/components/FormField';
|
|||
import MultiTextInput from '@/components/MultiTextInput';
|
||||
import { createValidatorForRhf, convertRhfErrorMessage } from '@/components/MultiTextInput/utils';
|
||||
import { GuideForm } from '@/types/guide';
|
||||
import { noSpaceRegex } from '@/utilities/regex';
|
||||
import { uriValidator } from '@/utilities/validator';
|
||||
|
||||
type Props = {
|
||||
name: 'redirectUris' | 'postLogoutRedirectUris';
|
||||
|
@ -42,8 +42,8 @@ const MultiTextInputField = ({ name, title, onError }: Props) => {
|
|||
validate: createValidatorForRhf({
|
||||
required: t('errors.required_field_missing_plural', { field: title }),
|
||||
pattern: {
|
||||
regex: noSpaceRegex,
|
||||
message: t('errors.no_space_in_uri'),
|
||||
verify: uriValidator(false),
|
||||
message: t('errors.invalid_uri_format'),
|
||||
},
|
||||
}),
|
||||
}}
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
// TODO - LOG-1876: Share Regex Between Logto Core and Front-End
|
||||
export const emailRegEx = /^\S+@\S+\.\S+$/;
|
||||
export const phoneRegEx = /^[1-9]\d{10}$/;
|
||||
export const noSpaceRegex = /^\S*$/;
|
||||
|
|
16
packages/console/src/utilities/validator.ts
Normal file
16
packages/console/src/utilities/validator.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
export const uriValidator = (verifyBlank = true) => {
|
||||
return (value: string) => {
|
||||
if (!verifyBlank && value.trim().length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
new URL(value);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
};
|
|
@ -94,7 +94,7 @@ const translation = {
|
|||
unknown_server_error: 'Unknown server error occurred.',
|
||||
empty: 'No Data',
|
||||
missing_total_number: 'Unable to find Total-Number in response headers.',
|
||||
no_space_in_uri: 'Space is not allowed in URI',
|
||||
invalid_uri_format: 'Invalid URI format',
|
||||
required_field_missing: 'Please enter {{field}}',
|
||||
required_field_missing_plural: 'You have to enter at least one {{field}}',
|
||||
},
|
||||
|
@ -166,6 +166,7 @@ const translation = {
|
|||
authorization_endpoint: 'Authorization Endpoint',
|
||||
redirect_uri: 'Redirect URI',
|
||||
post_sign_out_redirect_uri: 'Post Sign Out Redirect URI',
|
||||
cors_allowed_origins: 'CORS Allowed Origins',
|
||||
add_another: 'Add another',
|
||||
id_token_expiration: 'ID Token Expiration',
|
||||
refresh_token_expiration: 'Refresh Token Expiration',
|
||||
|
|
|
@ -94,7 +94,7 @@ const translation = {
|
|||
unknown_server_error: '服务器发生未知错误。',
|
||||
empty: '没有数据',
|
||||
missing_total_number: '无法从返回的头部信息中找到 Total-Number。',
|
||||
no_space_in_uri: 'URI 中不能包含空格',
|
||||
invalid_uri_format: '无效的 URI 格式',
|
||||
required_field_missing: '请输入{{field}}',
|
||||
required_field_missing_plural: '{{field}}不能全部为空',
|
||||
},
|
||||
|
@ -164,6 +164,7 @@ const translation = {
|
|||
authorization_endpoint: 'Authorization Endpoint',
|
||||
redirect_uri: 'Redirect URI',
|
||||
post_sign_out_redirect_uri: 'Post Sign Out Redirect URI',
|
||||
cors_allowed_origins: 'CORS Allowed Origins',
|
||||
add_another: 'Add another',
|
||||
id_token_expiration: 'ID Token Expiration',
|
||||
refresh_token_expiration: 'Refresh Token Expiration',
|
||||
|
|
|
@ -29,11 +29,9 @@ export const oidcModelInstancePayloadGuard = z
|
|||
|
||||
export type OidcModelInstancePayload = z.infer<typeof oidcModelInstancePayloadGuard>;
|
||||
|
||||
const noSpaceRegex = /^\S*$/;
|
||||
|
||||
export const oidcClientMetadataGuard = z.object({
|
||||
redirectUris: z.string().regex(noSpaceRegex).array(),
|
||||
postLogoutRedirectUris: z.string().regex(noSpaceRegex).array(),
|
||||
redirectUris: z.string().url().array(),
|
||||
postLogoutRedirectUris: z.string().url().array(),
|
||||
logoUri: z.string().optional(),
|
||||
});
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue