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:
parent
4fd798e1d0
commit
3034e899b9
24 changed files with 224 additions and 67 deletions
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
.sessionDuration {
|
||||
width: 135px;
|
||||
|
||||
input {
|
||||
width: 86px;
|
||||
flex: unset;
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -98,15 +98,6 @@
|
|||
gap: _.unit(3) _.unit(6);
|
||||
}
|
||||
|
||||
.sessionDuration {
|
||||
width: 135px;
|
||||
|
||||
input {
|
||||
width: 86px;
|
||||
flex: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.tip {
|
||||
ol {
|
||||
margin: 0;
|
||||
|
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
Loading…
Add table
Reference in a new issue