0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-17 22:31:28 -05:00

feat(console): add localhost notice (#5412)

This commit is contained in:
wangsijie 2024-02-20 16:56:21 +08:00 committed by GitHub
parent 4fd798e1d0
commit 3034e899b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 224 additions and 67 deletions

View file

@ -124,4 +124,9 @@
font: var(--font-body-2);
color: var(--color-error);
margin-top: _.unit(1);
a {
color: var(--color-error);
text-decoration: underline;
}
}

View file

@ -19,7 +19,7 @@ import IconButton from '@/ds-components/IconButton';
import * as styles from './index.module.scss';
type Props = Omit<HTMLProps<HTMLInputElement>, 'size'> & {
error?: string | boolean;
error?: string | boolean | ReactElement;
icon?: ReactElement;
/**
* An element to be rendered on the right side of the input.
@ -116,7 +116,7 @@ function TextInput(
),
})}
</div>
{Boolean(error) && typeof error === 'string' && (
{Boolean(error) && typeof error !== 'boolean' && (
<div className={styles.errorMessage}>{error}</div>
)}
</div>

View file

@ -0,0 +1,8 @@
.sessionDuration {
width: 135px;
input {
width: 86px;
flex: unset;
}
}

View file

@ -0,0 +1,78 @@
import { type Application, type SnakeCaseOidcConfig } from '@logto/schemas';
import { type ChangeEvent } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import useSWRImmutable from 'swr/immutable';
import FormCard from '@/components/FormCard';
import { openIdProviderConfigPath } from '@/consts/oidc';
import FormField from '@/ds-components/FormField';
import NumericInput from '@/ds-components/TextInput/NumericInput';
import { type RequestError } from '@/hooks/use-api';
import { type ApplicationForm } from '../../utils';
import * as styles from './SessionForm.module.scss';
type Props = {
data: Application;
};
const maxSessionDuration = 365; // 1 year
function SessionForm({ data }: Props) {
const { data: oidcConfig } = useSWRImmutable<SnakeCaseOidcConfig, RequestError>(
openIdProviderConfigPath
);
const {
control,
formState: { errors },
} = useFormContext<ApplicationForm>();
if (!data.protectedAppMetadata || !oidcConfig) {
return null;
}
return (
<FormCard title="application_details.session">
<FormField title="application_details.session_duration">
<Controller
name="protectedAppMetadata.sessionDuration"
control={control}
rules={{
min: 1,
}}
render={({ field: { onChange, value, name } }) => (
<NumericInput
className={styles.sessionDuration}
name={name}
placeholder="14"
value={String(value)}
min={1}
max={maxSessionDuration}
error={Boolean(errors.protectedAppMetadata?.sessionDuration)}
onChange={({ target: { value } }: ChangeEvent<HTMLInputElement>) => {
onChange(value && Number(value));
}}
onValueUp={() => {
onChange(value + 1);
}}
onValueDown={() => {
onChange(value - 1);
}}
onBlur={() => {
if (value < 1) {
onChange(1);
} else if (value > maxSessionDuration) {
onChange(maxSessionDuration);
}
}}
/>
)}
/>
</FormField>
</FormCard>
);
}
export default SessionForm;

View file

@ -98,15 +98,6 @@
gap: _.unit(3) _.unit(6);
}
.sessionDuration {
width: 135px;
input {
width: 86px;
flex: unset;
}
}
.tip {
ol {
margin: 0;

View file

@ -1,4 +1,4 @@
import { isValidRegEx, validateUriOrigin } from '@logto/core-kit';
import { isLocalhost, isValidRegEx, validateUriOrigin } from '@logto/core-kit';
import {
DomainStatus,
type Application,
@ -7,8 +7,8 @@ import {
} from '@logto/schemas';
import { cond } from '@silverhand/essentials';
import classNames from 'classnames';
import { type ChangeEvent, useState, useEffect } from 'react';
import { Controller, useFieldArray, useFormContext } from 'react-hook-form';
import { useState, useEffect } from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { Trans, useTranslation } from 'react-i18next';
import useSWR from 'swr';
import useSWRImmutable from 'swr/immutable';
@ -25,7 +25,6 @@ import FormField from '@/ds-components/FormField';
import InlineNotification from '@/ds-components/InlineNotification';
import Spacer from '@/ds-components/Spacer';
import TextInput from '@/ds-components/TextInput';
import NumericInput from '@/ds-components/TextInput/NumericInput';
import TextLink from '@/ds-components/TextLink';
import useApi, { type RequestError } from '@/hooks/use-api';
import useDocumentationUrl from '@/hooks/use-documentation-url';
@ -35,6 +34,7 @@ import CustomDomain from '@/pages/TenantSettings/TenantDomainSettings/CustomDoma
import EndpointsAndCredentials from '../EndpointsAndCredentials';
import { type ApplicationForm } from '../utils';
import SessionForm from './components/SessionForm';
import * as styles from './index.module.scss';
type Props = {
@ -42,7 +42,6 @@ type Props = {
};
const routes = Object.freeze(['/register', '/sign-in', '/sign-in-callback', '/sign-out']);
const maxSessionDuration = 365; // 1 year
function ProtectedAppSettings({ data }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
@ -136,14 +135,36 @@ function ProtectedAppSettings({ data }: Props) {
<TextInput
{...register('protectedAppMetadata.origin', {
required: true,
validate: (value) =>
validateUriOrigin(value) || t('protected_app.form.errors.invalid_url'),
validate: (value) => {
if (!validateUriOrigin(value)) {
return t('protected_app.form.errors.invalid_url');
}
if (isLocalhost(value)) {
return t('protected_app.form.errors.localhost');
}
return true;
},
})}
error={
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
errors.protectedAppMetadata?.origin?.message ||
(errors.protectedAppMetadata?.origin?.type === 'required' &&
t('protected_app.form.errors.url_required'))
errors.protectedAppMetadata?.origin?.message ===
t('protected_app.form.errors.localhost') ? (
<Trans
components={{
a: (
<TextLink to="https://docs.logto.io/docs/recipes/protected-app/#local-development" />
),
}}
>
{t('protected_app.form.errors.localhost')}
</Trans>
) : (
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
errors.protectedAppMetadata?.origin?.message ||
(errors.protectedAppMetadata?.origin?.type === 'required' &&
t('protected_app.form.errors.url_required'))
)
}
placeholder={t('protected_app.form.url_field_placeholder')}
/>
@ -267,44 +288,7 @@ function ProtectedAppSettings({ data }: Props) {
</FormField>
</FormCard>
<EndpointsAndCredentials app={data} oidcConfig={oidcConfig} />
<FormCard title="application_details.session">
<FormField title="application_details.session_duration">
<Controller
name="protectedAppMetadata.sessionDuration"
control={control}
rules={{
min: 1,
}}
render={({ field: { onChange, value, name } }) => (
<NumericInput
className={styles.sessionDuration}
name={name}
placeholder="14"
value={String(value)}
min={1}
max={maxSessionDuration}
error={Boolean(errors.protectedAppMetadata?.sessionDuration)}
onChange={({ target: { value } }: ChangeEvent<HTMLInputElement>) => {
onChange(value && Number(value));
}}
onValueUp={() => {
onChange(value + 1);
}}
onValueDown={() => {
onChange(value - 1);
}}
onBlur={() => {
if (value < 1) {
onChange(1);
} else if (value > maxSessionDuration) {
onChange(maxSessionDuration);
}
}}
/>
)}
/>
</FormField>
</FormCard>
<SessionForm data={data} />
</>
);
}

View file

@ -1,4 +1,4 @@
import { validateUriOrigin } from '@logto/core-kit';
import { isLocalhost, validateUriOrigin } from '@logto/core-kit';
import { ApplicationType, type Application, type RequestErrorBody } from '@logto/schemas';
import { isValidSubdomain } from '@logto/shared/universal';
import { condString, conditional } from '@silverhand/essentials';
@ -18,6 +18,7 @@ import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import Button, { type Props as ButtonProps } from '@/ds-components/Button';
import FormField from '@/ds-components/FormField';
import TextInput from '@/ds-components/TextInput';
import TextLink from '@/ds-components/TextLink';
import useApi from '@/hooks/use-api';
import useApplicationsUsage from '@/hooks/use-applications-usage';
import useTenantPathname from '@/hooks/use-tenant-pathname';
@ -123,14 +124,36 @@ function ProtectedAppForm({
inputContainerClassName={styles.input}
{...register('origin', {
required: true,
validate: (value) =>
validateUriOrigin(value) || t('protected_app.form.errors.invalid_url'),
validate: (value) => {
if (!validateUriOrigin(value)) {
return t('protected_app.form.errors.invalid_url');
}
if (isLocalhost(value)) {
return t('protected_app.form.errors.localhost');
}
return true;
},
})}
placeholder={t('protected_app.form.url_field_placeholder')}
error={
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
errors.origin?.message ||
(errors.origin?.type === 'required' && t('protected_app.form.errors.url_required'))
// Error message can only be string, manually add link to the message
errors.origin?.message === t('protected_app.form.errors.localhost') ? (
<Trans
components={{
a: (
<TextLink to="https://docs.logto.io/docs/recipes/protected-app/#local-development" />
),
}}
>
{t('protected_app.form.errors.localhost')}
</Trans>
) : (
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
errors.origin?.message ||
(errors.origin?.type === 'required' && t('protected_app.form.errors.url_required'))
)
}
/>
</FormField>

View file

@ -57,6 +57,9 @@ const protected_app = {
/** UNTRANSLATED */
invalid_url:
"Invalid Origin URL format: Use http:// or https://. Note: '/pathname' is not currently supported.",
/** UNTRANSLATED */
localhost:
'Please expose your local server to the internet first. Learn more about <a>local development</a>.',
},
},
/** UNTRANSLATED */

View file

@ -34,6 +34,8 @@ const protected_app = {
url_required: 'Origin URL is required.',
invalid_url:
"Invalid Origin URL format: Use http:// or https://. Note: '/pathname' is not currently supported.",
localhost:
'Please expose your local server to the internet first. Learn more about <a>local development</a>.',
},
},
success_message:

View file

@ -57,6 +57,9 @@ const protected_app = {
/** UNTRANSLATED */
invalid_url:
"Invalid Origin URL format: Use http:// or https://. Note: '/pathname' is not currently supported.",
/** UNTRANSLATED */
localhost:
'Please expose your local server to the internet first. Learn more about <a>local development</a>.',
},
},
/** UNTRANSLATED */

View file

@ -57,6 +57,9 @@ const protected_app = {
/** UNTRANSLATED */
invalid_url:
"Invalid Origin URL format: Use http:// or https://. Note: '/pathname' is not currently supported.",
/** UNTRANSLATED */
localhost:
'Please expose your local server to the internet first. Learn more about <a>local development</a>.',
},
},
/** UNTRANSLATED */

View file

@ -57,6 +57,9 @@ const protected_app = {
/** UNTRANSLATED */
invalid_url:
"Invalid Origin URL format: Use http:// or https://. Note: '/pathname' is not currently supported.",
/** UNTRANSLATED */
localhost:
'Please expose your local server to the internet first. Learn more about <a>local development</a>.',
},
},
/** UNTRANSLATED */

View file

@ -57,6 +57,9 @@ const protected_app = {
/** UNTRANSLATED */
invalid_url:
"Invalid Origin URL format: Use http:// or https://. Note: '/pathname' is not currently supported.",
/** UNTRANSLATED */
localhost:
'Please expose your local server to the internet first. Learn more about <a>local development</a>.',
},
},
/** UNTRANSLATED */

View file

@ -57,6 +57,9 @@ const protected_app = {
/** UNTRANSLATED */
invalid_url:
"Invalid Origin URL format: Use http:// or https://. Note: '/pathname' is not currently supported.",
/** UNTRANSLATED */
localhost:
'Please expose your local server to the internet first. Learn more about <a>local development</a>.',
},
},
/** UNTRANSLATED */

View file

@ -57,6 +57,9 @@ const protected_app = {
/** UNTRANSLATED */
invalid_url:
"Invalid Origin URL format: Use http:// or https://. Note: '/pathname' is not currently supported.",
/** UNTRANSLATED */
localhost:
'Please expose your local server to the internet first. Learn more about <a>local development</a>.',
},
},
/** UNTRANSLATED */

View file

@ -57,6 +57,9 @@ const protected_app = {
/** UNTRANSLATED */
invalid_url:
"Invalid Origin URL format: Use http:// or https://. Note: '/pathname' is not currently supported.",
/** UNTRANSLATED */
localhost:
'Please expose your local server to the internet first. Learn more about <a>local development</a>.',
},
},
/** UNTRANSLATED */

View file

@ -57,6 +57,9 @@ const protected_app = {
/** UNTRANSLATED */
invalid_url:
"Invalid Origin URL format: Use http:// or https://. Note: '/pathname' is not currently supported.",
/** UNTRANSLATED */
localhost:
'Please expose your local server to the internet first. Learn more about <a>local development</a>.',
},
},
/** UNTRANSLATED */

View file

@ -57,6 +57,9 @@ const protected_app = {
/** UNTRANSLATED */
invalid_url:
"Invalid Origin URL format: Use http:// or https://. Note: '/pathname' is not currently supported.",
/** UNTRANSLATED */
localhost:
'Please expose your local server to the internet first. Learn more about <a>local development</a>.',
},
},
/** UNTRANSLATED */

View file

@ -57,6 +57,9 @@ const protected_app = {
/** UNTRANSLATED */
invalid_url:
"Invalid Origin URL format: Use http:// or https://. Note: '/pathname' is not currently supported.",
/** UNTRANSLATED */
localhost:
'Please expose your local server to the internet first. Learn more about <a>local development</a>.',
},
},
/** UNTRANSLATED */

View file

@ -57,6 +57,9 @@ const protected_app = {
/** UNTRANSLATED */
invalid_url:
"Invalid Origin URL format: Use http:// or https://. Note: '/pathname' is not currently supported.",
/** UNTRANSLATED */
localhost:
'Please expose your local server to the internet first. Learn more about <a>local development</a>.',
},
},
/** UNTRANSLATED */

View file

@ -57,6 +57,9 @@ const protected_app = {
/** UNTRANSLATED */
invalid_url:
"Invalid Origin URL format: Use http:// or https://. Note: '/pathname' is not currently supported.",
/** UNTRANSLATED */
localhost:
'Please expose your local server to the internet first. Learn more about <a>local development</a>.',
},
},
/** UNTRANSLATED */

View file

@ -57,6 +57,9 @@ const protected_app = {
/** UNTRANSLATED */
invalid_url:
"Invalid Origin URL format: Use http:// or https://. Note: '/pathname' is not currently supported.",
/** UNTRANSLATED */
localhost:
'Please expose your local server to the internet first. Learn more about <a>local development</a>.',
},
},
/** UNTRANSLATED */

View file

@ -1,4 +1,4 @@
import { isValidUrl, validateRedirectUrl } from './url.js';
import { isLocalhost, isValidUrl, validateRedirectUrl } from './url.js';
describe('url utilities', () => {
it('should allow valid redirect URIs', () => {
@ -41,3 +41,18 @@ describe('url utilities', () => {
expect(isValidUrl('abc.com/callback#test=123')).toBeFalsy();
});
});
describe('isLocalhost()', () => {
it('should return true for localhost', () => {
expect(isLocalhost('http://localhost')).toBeTruthy();
expect(isLocalhost('http://localhost:3001')).toBeTruthy();
expect(isLocalhost('https://localhost:3001')).toBeTruthy();
expect(isLocalhost('http://localhost:3001/callback')).toBeTruthy();
});
it('should return false for non-localhost', () => {
expect(isLocalhost('https://localhost.dev/callback')).toBeFalsy();
expect(isLocalhost('https://my-company.com/callback?test=123')).toBeFalsy();
expect(isLocalhost('https://abc.com/callback?test=123#param=hash')).toBeFalsy();
});
});

View file

@ -27,3 +27,12 @@ export const isValidUrl = (url?: string) => {
return false;
}
};
/**
* Check if the given URL is localhost
*/
export const isLocalhost = (url: string) => {
const parsedUrl = new URL(url);
return ['localhost', '127.0.0.1', '::1'].includes(parsedUrl.hostname);
};