diff --git a/packages/console/src/pages/Applications/components/ProtectedAppForm/index.module.scss b/packages/console/src/pages/Applications/components/ProtectedAppForm/index.module.scss index 7360bb797..2e0f25240 100644 --- a/packages/console/src/pages/Applications/components/ProtectedAppForm/index.module.scss +++ b/packages/console/src/pages/Applications/components/ProtectedAppForm/index.module.scss @@ -44,7 +44,6 @@ form { .domainFieldWrapper { display: flex; - align-items: center; width: 100%; .subdomain { diff --git a/packages/console/src/pages/Applications/components/ProtectedAppForm/index.tsx b/packages/console/src/pages/Applications/components/ProtectedAppForm/index.tsx index 64c7f0dfa..e61e002e8 100644 --- a/packages/console/src/pages/Applications/components/ProtectedAppForm/index.tsx +++ b/packages/console/src/pages/Applications/components/ProtectedAppForm/index.tsx @@ -1,6 +1,9 @@ -import { ApplicationType, type Application } from '@logto/schemas'; +import { isValidUrl } from '@logto/core-kit'; +import { ApplicationType, type Application, type RequestErrorBody } from '@logto/schemas'; +import { isValidSubdomain } from '@logto/shared/universal'; import { conditional } from '@silverhand/essentials'; import classNames from 'classnames'; +import { HTTPError } from 'ky'; import { useForm } from 'react-hook-form'; import { toast } from 'react-hot-toast'; import { useTranslation } from 'react-i18next'; @@ -10,7 +13,6 @@ import Button, { type Props as ButtonProps } from '@/ds-components/Button'; import FormField from '@/ds-components/FormField'; import TextInput from '@/ds-components/TextInput'; import useApi from '@/hooks/use-api'; -import { trySubmitSafe } from '@/utils/form'; import * as styles from './index.module.scss'; @@ -39,17 +41,18 @@ function ProtectedAppForm({ const { register, handleSubmit, + setError, formState: { errors, isSubmitting }, } = useForm(); - const api = useApi(); + const api = useApi({ hideErrorToast: true }); - const onSubmit = handleSubmit( - trySubmitSafe(async (data) => { - if (isSubmitting) { - return; - } + const onSubmit = handleSubmit(async (data) => { + if (isSubmitting) { + return; + } + try { const createdApp = await api .post('api/applications', { json: { @@ -62,8 +65,16 @@ function ProtectedAppForm({ .json(); toast.success(t('applications.application_created')); onCreateSuccess?.(createdApp); - }) - ); + } catch (error: unknown) { + if (error instanceof HTTPError) { + const { code, message } = await error.response.json(); + + if (code === 'application.protected_application_subdomain_exists') { + setError('subDomain', { type: 'custom', message }); + } + } + } + }); return (
@@ -88,9 +99,18 @@ function ProtectedAppForm({
+ isValidSubdomain(value) || t('protected_app.form.errors.invalid_domain_format'), + })} placeholder={t('protected_app.form.domain_field_placeholder')} - error={Boolean(errors.subDomain)} + error={ + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + errors.subDomain?.message || + (errors.subDomain?.type === 'required' && + t('protected_app.form.errors.domain_required')) + } /> {defaultDomain &&
{defaultDomain}
}
@@ -108,9 +128,16 @@ function ProtectedAppForm({ tip={conditional(!hasDetailedInstructions && t('protected_app.form.url_field_tooltip'))} > isValidUrl(value) || t('protected_app.form.errors.invalid_url'), + })} placeholder={t('protected_app.form.url_field_placeholder')} - error={Boolean(errors.origin)} + error={ + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + errors.origin?.message || + (errors.origin?.type === 'required' && t('protected_app.form.errors.url_required')) + } /> diff --git a/packages/toolkit/core-kit/src/utils/url.test.ts b/packages/toolkit/core-kit/src/utils/url.test.ts index 68afb1495..c9f839023 100644 --- a/packages/toolkit/core-kit/src/utils/url.test.ts +++ b/packages/toolkit/core-kit/src/utils/url.test.ts @@ -1,4 +1,4 @@ -import { validateRedirectUrl } from './url.js'; +import { isValidUrl, validateRedirectUrl } from './url.js'; describe('url utilities', () => { it('should allow valid redirect URIs', () => { @@ -22,4 +22,22 @@ describe('url utilities', () => { expect(validateRedirectUrl('https://logto.dev/callback', 'mobile')).toBeFalsy(); expect(validateRedirectUrl('demoApp/callback', 'mobile')).toBeFalsy(); }); + + it('should allow valid URIs', () => { + expect(isValidUrl('http://localhost:3001')).toBeTruthy(); + expect(isValidUrl('https://google.com')).toBeTruthy(); + expect(isValidUrl('https://logto.dev/callback')).toBeTruthy(); + expect(isValidUrl('https://my-company.com/callback?test=123')).toBeTruthy(); + expect(isValidUrl('https://abc.com/callback?test=123#param=hash')).toBeTruthy(); + expect(isValidUrl('io.logto://my-app/callback')).toBeTruthy(); + expect(isValidUrl('io.logto.SwiftUI-Demo://callback')).toBeTruthy(); + }); + + it('should detect invalid URIs', () => { + expect(isValidUrl('invalid_url')).toBeFalsy(); + expect(isValidUrl('abc.com')).toBeFalsy(); + expect(isValidUrl('abc.com/callback')).toBeFalsy(); + expect(isValidUrl('abc.com/callback?test=123')).toBeFalsy(); + expect(isValidUrl('abc.com/callback#test=123')).toBeFalsy(); + }); }); diff --git a/packages/toolkit/core-kit/src/utils/url.ts b/packages/toolkit/core-kit/src/utils/url.ts index 100575643..01c58f3c6 100644 --- a/packages/toolkit/core-kit/src/utils/url.ts +++ b/packages/toolkit/core-kit/src/utils/url.ts @@ -19,3 +19,11 @@ export const validateUriOrigin = (url: string) => { return false; } }; + +export const isValidUrl = (url?: string) => { + try { + return Boolean(url && new URL(url)); + } catch { + return false; + } +};