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

refactor(console): reorg sie page codes (#5292)

This commit is contained in:
Xiao Yijun 2024-01-24 17:00:19 +08:00 committed by GitHub
parent 2daf911278
commit 6e001f582c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
81 changed files with 544 additions and 471 deletions

View file

@ -45,7 +45,8 @@ import RolePermissions from '@/pages/RoleDetails/RolePermissions';
import RoleSettings from '@/pages/RoleDetails/RoleSettings';
import RoleUsers from '@/pages/RoleDetails/RoleUsers';
import Roles from '@/pages/Roles';
import SignInExperience, { SignInExperienceTab } from '@/pages/SignInExperience';
import SignInExperience from '@/pages/SignInExperience';
import { SignInExperienceTab } from '@/pages/SignInExperience/types';
import TenantSettings from '@/pages/TenantSettings';
import BillingHistory from '@/pages/TenantSettings/BillingHistory';
import Subscription from '@/pages/TenantSettings/Subscription';

View file

@ -3,7 +3,7 @@ import type { ReactNode } from 'react';
import * as styles from './index.module.scss';
type Props = {
export type Props = {
isActive: boolean;
className?: string;
children: ReactNode;

View file

@ -0,0 +1,9 @@
@use '@/scss/underscore' as _;
.darkModeTip {
display: flex;
align-items: baseline;
font: var(--font-body-2);
color: var(--color-text-secondary);
margin-top: _.unit(1);
}

View file

@ -14,10 +14,11 @@ import ImageUploaderField from '@/ds-components/Uploader/ImageUploaderField';
import useUserAssetsService from '@/hooks/use-user-assets-service';
import { uriValidator } from '@/utils/validator';
import type { SignInExperienceForm } from '../../types';
import * as styles from '../index.module.scss';
import type { SignInExperienceForm } from '../../../types';
import FormSectionTitle from '../../components/FormSectionTitle';
import LogoAndFaviconUploader from './components/LogoAndFaviconUploader';
import LogoAndFaviconUploader from './LogoAndFaviconUploader';
import * as styles from './index.module.scss';
function BrandingForm() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
@ -55,7 +56,7 @@ function BrandingForm() {
return (
<Card>
<div className={styles.title}>{t('sign_in_exp.branding.title')}</div>
<FormSectionTitle title="branding.title" />
<FormField title="sign_in_exp.color.primary_color">
<Controller
name="color.primaryColor"

View file

@ -7,10 +7,10 @@ import FormField from '@/ds-components/FormField';
import TextLink from '@/ds-components/TextLink';
import useDocumentationUrl from '@/hooks/use-documentation-url';
import type { SignInExperienceForm } from '../../types';
import * as tabsStyles from '../index.module.scss';
import type { SignInExperienceForm } from '../../../types';
import FormSectionTitle from '../../components/FormSectionTitle';
import * as brandingStyles from './CustomCssForm.module.scss';
import * as brandingStyles from './index.module.scss';
function CustomCssForm() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
@ -19,7 +19,7 @@ function CustomCssForm() {
return (
<Card>
<div className={tabsStyles.title}>{t('sign_in_exp.custom_css.title')}</div>
<FormSectionTitle title="custom_css.title" />
<FormField
title="sign_in_exp.custom_css.css_code_editor_title"
tip={(closeTipHandler) => (

View file

@ -1,7 +1,6 @@
import PageMeta from '@/components/PageMeta';
import TabWrapper from '@/ds-components/TabWrapper';
import * as styles from '../index.module.scss';
import SignInExperienceTabWrapper from '../components/SignInExperienceTabWrapper';
import BrandingForm from './BrandingForm';
import CustomCssForm from './CustomCssForm';
@ -12,11 +11,11 @@ type Props = {
function Branding({ isActive }: Props) {
return (
<TabWrapper isActive={isActive} className={styles.tabContent}>
<SignInExperienceTabWrapper isActive={isActive}>
{isActive && <PageMeta titleKey={['sign_in_exp.tabs.branding', 'sign_in_exp.page_title']} />}
<BrandingForm />
<CustomCssForm />
</TabWrapper>
</SignInExperienceTabWrapper>
);
}

View file

@ -27,19 +27,13 @@ import { Tooltip } from '@/ds-components/Tip';
import useApi, { RequestError } from '@/hooks/use-api';
import useSwrFetcher from '@/hooks/use-swr-fetcher';
import useUiLanguages from '@/hooks/use-ui-languages';
import {
hiddenLocalePhraseGroups,
hiddenLocalePhrases,
} from '@/pages/SignInExperience/utils/constants';
import {
createEmptyUiTranslation,
flattenTranslation,
} from '@/pages/SignInExperience/utils/language';
import type { CustomPhraseResponse } from '@/types/custom-phrase';
import { trySubmitSafe } from '@/utils/form';
import * as styles from './LanguageDetails.module.scss';
import { hiddenLocalePhraseGroups, hiddenLocalePhrases } from './constants';
import { LanguageEditorContext } from './use-language-editor-context';
import { createEmptyUiTranslation, flattenTranslation } from './utils';
const emptyUiTranslation = createEmptyUiTranslation();

View file

@ -0,0 +1,16 @@
@use '@/scss/underscore' as _;
.title {
@include _.section-head-1;
color: var(--color-neutral-variant-60);
}
.manageLanguageButton {
margin-top: _.unit(1);
}
.defaultLanguageDescription {
padding-top: _.unit(2);
font: var(--font-body-2);
color: var(--color-text-secondary);
}

View file

@ -12,10 +12,11 @@ import Switch from '@/ds-components/Switch';
import type { RequestError } from '@/hooks/use-api';
import useUiLanguages from '@/hooks/use-ui-languages';
import type { SignInExperienceForm } from '../../types';
import * as styles from '../index.module.scss';
import type { SignInExperienceForm } from '../../../types';
import FormSectionTitle from '../../components/FormSectionTitle';
import ManageLanguageButton from './components/ManageLanguage/ManageLanguageButton';
import ManageLanguageButton from './ManageLanguage/ManageLanguageButton';
import * as styles from './index.module.scss';
type Props = {
isManageLanguageVisible?: boolean;
@ -47,7 +48,7 @@ function LanguagesForm({ isManageLanguageVisible = false }: Props) {
return (
<Card>
<div className={styles.title}>{t('sign_in_exp.content.languages.title')}</div>
<FormSectionTitle title="content.languages.title" />
<FormField title="sign_in_exp.content.languages.enable_auto_detect">
<Switch
{...register('languageInfo.autoDetect')}

View file

@ -7,7 +7,7 @@ import TextInput from '@/ds-components/TextInput';
import { uriValidator } from '@/utils/validator';
import type { SignInExperienceForm } from '../../types';
import * as styles from '../index.module.scss';
import FormSectionTitle from '../components/FormSectionTitle';
function TermsForm() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
@ -18,7 +18,7 @@ function TermsForm() {
return (
<Card>
<div className={styles.title}>{t('sign_in_exp.content.terms_of_use.title')}</div>
<FormSectionTitle title="content.terms_of_use.title" />
<FormField title="sign_in_exp.content.terms_of_use.terms_of_use">
<TextInput
{...register('termsOfUseUrl', {

View file

@ -1,7 +1,6 @@
import PageMeta from '@/components/PageMeta';
import TabWrapper from '@/ds-components/TabWrapper';
import * as styles from '../index.module.scss';
import SignInExperienceTabWrapper from '../components/SignInExperienceTabWrapper';
import LanguagesForm from './LanguagesForm';
import TermsForm from './TermsForm';
@ -12,11 +11,11 @@ type Props = {
function Content({ isActive }: Props) {
return (
<TabWrapper isActive={isActive} className={styles.tabContent}>
<SignInExperienceTabWrapper isActive={isActive}>
{isActive && <PageMeta titleKey={['sign_in_exp.tabs.content', 'sign_in_exp.page_title']} />}
<TermsForm />
<LanguagesForm isManageLanguageVisible />
</TabWrapper>
</SignInExperienceTabWrapper>
);
}

View file

@ -9,13 +9,14 @@ import Card from '@/ds-components/Card';
import Checkbox from '@/ds-components/Checkbox/Checkbox';
import FormField from '@/ds-components/FormField';
import RadioGroup, { Radio } from '@/ds-components/RadioGroup';
import TabWrapper from '@/ds-components/TabWrapper';
import NumericInput from '@/ds-components/TextInput/NumericInput';
import TextLink from '@/ds-components/TextLink';
import Textarea from '@/ds-components/Textarea';
import { type SignInExperienceForm } from '../../types';
import * as commonStyles from '../index.module.scss';
import FormFieldDescription from '../components/FormFieldDescription';
import FormSectionTitle from '../components/FormSectionTitle';
import SignInExperienceTabWrapper from '../components/SignInExperienceTabWrapper';
import * as styles from './index.module.scss';
@ -69,14 +70,14 @@ function PasswordPolicy({ isActive }: Props) {
});
return (
<TabWrapper isActive={isActive} className={commonStyles.tabContent}>
<SignInExperienceTabWrapper isActive={isActive}>
{isActive && (
<PageMeta titleKey={['sign_in_exp.tabs.password_policy', 'sign_in_exp.page_title']} />
)}
<Card>
<div className={commonStyles.title}>{t('password_requirements')}</div>
<FormSectionTitle title="password_policy.password_requirements" />
<FormField title="sign_in_exp.password_policy.minimum_length">
<div className={commonStyles.formFieldDescription}>
<FormFieldDescription>
<Trans
components={{
a: (
@ -89,7 +90,7 @@ function PasswordPolicy({ isActive }: Props) {
>
{t('minimum_length_description')}
</Trans>
</div>
</FormFieldDescription>
<Controller
name="passwordPolicy.length.min"
control={control}
@ -128,11 +129,11 @@ function PasswordPolicy({ isActive }: Props) {
/>
</FormField>
<FormField title="sign_in_exp.password_policy.minimum_required_char_types">
<div className={commonStyles.formFieldDescription}>
<FormFieldDescription>
{t('minimum_required_char_types_description', {
symbols: PasswordPolicyChecker.symbols,
})}
</div>
</FormFieldDescription>
<Controller
name="passwordPolicy.characterTypes.min"
control={control}
@ -154,7 +155,7 @@ function PasswordPolicy({ isActive }: Props) {
</FormField>
</Card>
<Card>
<div className={commonStyles.title}>{t('password_rejection')}</div>
<FormSectionTitle title="password_policy.password_rejection" />
<FormField title="sign_in_exp.password_policy.compromised_passwords">
<PasswordOption
name="passwordPolicy.rejects.pwned"
@ -191,7 +192,7 @@ function PasswordPolicy({ isActive }: Props) {
)}
</FormField>
</Card>
</TabWrapper>
</SignInExperienceTabWrapper>
);
}

View file

@ -0,0 +1,11 @@
@use '@/scss/underscore' as _;
.setUpHint {
font: var(--font-body-2);
color: var(--color-text-secondary);
margin-top: _.unit(2);
.setup {
margin: 0 _.unit(1);
}
}

View file

@ -6,8 +6,10 @@ import FormField from '@/ds-components/FormField';
import Switch from '@/ds-components/Switch';
import TextLink from '@/ds-components/TextLink';
import type { SignInExperienceForm } from '../../types';
import * as styles from '../index.module.scss';
import type { SignInExperienceForm } from '../../../types';
import FormSectionTitle from '../../components/FormSectionTitle';
import * as styles from './index.module.scss';
function AdvancedOptions() {
const { t } = useTranslation(undefined, {
@ -17,7 +19,7 @@ function AdvancedOptions() {
return (
<Card>
<div className={styles.title}>{t('title')}</div>
<FormSectionTitle title="sign_up_and_sign_in.advanced_options.title" />
<FormField title="sign_in_exp.sign_up_and_sign_in.advanced_options.enable_single_sign_on">
<Switch
{...register('singleSignOnEnabled')}

View file

@ -7,7 +7,8 @@ import Plus from '@/assets/icons/plus.svg';
import ActionMenu from '@/ds-components/ActionMenu';
import type { Props as ButtonProps } from '@/ds-components/Button';
import { DropdownItem } from '@/ds-components/Dropdown';
import { signInIdentifierPhrase } from '@/pages/SignInExperience/constants';
import { signInIdentifierPhrase } from '../../../constants';
import * as styles from './index.module.scss';

View file

@ -10,10 +10,10 @@ import SwitchArrowIcon from '@/assets/icons/switch-arrow.svg';
import Checkbox from '@/ds-components/Checkbox';
import IconButton from '@/ds-components/IconButton';
import { Tooltip } from '@/ds-components/Tip';
import { signInIdentifierPhrase } from '@/pages/SignInExperience/constants';
import type { SignInMethod } from '@/pages/SignInExperience/types';
import ConnectorSetupWarning from '../ConnectorSetupWarning';
import { signInIdentifierPhrase } from '../../../constants';
import ConnectorSetupWarning from '../../components/ConnectorSetupWarning';
import * as styles from './index.module.scss';

View file

@ -5,15 +5,11 @@ import { useTranslation } from 'react-i18next';
import { DragDropProvider, DraggableItem } from '@/ds-components/DragDrop';
import useEnabledConnectorTypes from '@/hooks/use-enabled-connector-types';
import {
identifierRequiredConnectorMapping,
signInIdentifiers,
signUpIdentifiersMapping,
} from '@/pages/SignInExperience/constants';
import type { SignInExperienceForm } from '@/pages/SignInExperience/types';
import { getSignUpRequiredConnectorTypes } from '@/pages/SignInExperience/utils/identifier';
import { createSignInMethod } from '../../utils';
import type { SignInExperienceForm } from '../../../../types';
import { signInIdentifiers, signUpIdentifiersMapping } from '../../../constants';
import { identifierRequiredConnectorMapping } from '../../constants';
import { getSignUpRequiredConnectorTypes, createSignInMethod } from '../../utils';
import AddButton from './AddButton';
import SignInMethodItem from './SignInMethodItem';
@ -45,10 +41,6 @@ function SignInMethodEditBox() {
const { isConnectorTypeEnabled } = useEnabledConnectorTypes();
if (!signUp) {
return null;
}
const {
identifier: signUpIdentifier,
password: isSignUpPasswordRequired,

View file

@ -3,20 +3,21 @@ import { useTranslation } from 'react-i18next';
import Card from '@/ds-components/Card';
import FormField from '@/ds-components/FormField';
import * as styles from '../index.module.scss';
import FormFieldDescription from '../../components/FormFieldDescription';
import FormSectionTitle from '../../components/FormSectionTitle';
import SignInMethodEditBox from './components/SignInMethodEditBox';
import SignInMethodEditBox from './SignInMethodEditBox';
function SignInForm() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (
<Card>
<div className={styles.title}>{t('sign_in_exp.sign_up_and_sign_in.sign_in.title')}</div>
<FormSectionTitle title="sign_up_and_sign_in.sign_in.title" />
<FormField title="sign_in_exp.sign_up_and_sign_in.sign_in.sign_in_identifier_and_auth">
<div className={styles.formFieldDescription}>
<FormFieldDescription>
{t('sign_in_exp.sign_up_and_sign_in.sign_in.description')}
</div>
</FormFieldDescription>
<SignInMethodEditBox />
</FormField>
</Card>

View file

@ -0,0 +1,17 @@
@use '@/scss/underscore' as _;
.title {
@include _.section-head-1;
color: var(--color-neutral-variant-60);
}
.socialOnlyDescription {
margin-left: _.unit(1);
color: var(--color-text-secondary);
}
.selections {
> :not(:first-child) {
margin-top: _.unit(3);
}
}

View file

@ -8,25 +8,25 @@ import FormField from '@/ds-components/FormField';
import Select from '@/ds-components/Select';
import useEnabledConnectorTypes from '@/hooks/use-enabled-connector-types';
import { SignUpIdentifier } from '../../../types';
import type { SignInExperienceForm } from '../../../types';
import FormFieldDescription from '../../components/FormFieldDescription';
import FormSectionTitle from '../../components/FormSectionTitle';
import {
signUpIdentifierPhrase,
signUpIdentifiers,
signUpIdentifiersMapping,
} from '../../constants';
import type { SignInExperienceForm } from '../../types';
import { SignUpIdentifier } from '../../types';
import ConnectorSetupWarning from '../components/ConnectorSetupWarning';
import {
getSignUpRequiredConnectorTypes,
isVerificationRequiredSignUpIdentifiers,
} from '../../utils/identifier';
import * as styles from '../index.module.scss';
import ConnectorSetupWarning from './components/ConnectorSetupWarning';
import {
createSignInMethod,
getSignInMethodPasswordCheckState,
getSignInMethodVerificationCodeCheckState,
} from './utils';
} from '../utils';
import * as styles from './index.module.scss';
function SignUpForm() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
@ -42,10 +42,6 @@ function SignUpForm() {
const signUp = watch('signUp');
if (!signUp) {
return null;
}
const { identifier: signUpIdentifier } = signUp;
const isUsernamePasswordSignUp = signUpIdentifier === SignUpIdentifier.Username;
@ -113,11 +109,11 @@ function SignUpForm() {
return (
<Card>
<div className={styles.title}>{t('sign_in_exp.sign_up_and_sign_in.sign_up.title')}</div>
<FormSectionTitle title="sign_up_and_sign_in.sign_up.title" />
<FormField title="sign_in_exp.sign_up_and_sign_in.sign_up.sign_up_identifier">
<div className={styles.formFieldDescription}>
<FormFieldDescription>
{t('sign_in_exp.sign_up_and_sign_in.sign_up.identifier_description')}
</div>
</FormFieldDescription>
<Controller
name="signUp.identifier"
control={control}
@ -164,9 +160,9 @@ function SignUpForm() {
</FormField>
{signUpIdentifier !== SignUpIdentifier.None && (
<FormField title="sign_in_exp.sign_up_and_sign_in.sign_up.sign_up_authentication">
<div className={styles.formFieldDescription}>
<FormFieldDescription>
{t('sign_in_exp.sign_up_and_sign_in.sign_up.authentication_description')}
</div>
</FormFieldDescription>
<div className={styles.selections}>
<Controller
name="signUp.password"

View file

@ -10,7 +10,7 @@ import { DropdownItem } from '@/ds-components/Dropdown';
import ConnectorPlatformIcon from '@/icons/ConnectorPlatformIcon';
import type { ConnectorGroup } from '@/types/connector';
import * as styles from './AddButton.module.scss';
import * as styles from './index.module.scss';
type Props = {
options: ConnectorGroup[];

View file

@ -6,7 +6,7 @@ import IconButton from '@/ds-components/IconButton';
import ConnectorPlatformIcon from '@/icons/ConnectorPlatformIcon';
import type { ConnectorGroup } from '@/types/connector';
import * as styles from './SelectedConnectorItem.module.scss';
import * as styles from './index.module.scss';
type Props = {
data: ConnectorGroup;

View file

@ -6,7 +6,7 @@ import TextLink from '@/ds-components/TextLink';
import useConnectorGroups from '@/hooks/use-connector-groups';
import type { ConnectorGroup } from '@/types/connector';
import ConnectorSetupWarning from '../ConnectorSetupWarning';
import ConnectorSetupWarning from '../../components/ConnectorSetupWarning';
import AddButton from './AddButton';
import SelectedConnectorItem from './SelectedConnectorItem';

View file

@ -4,10 +4,11 @@ import { useTranslation } from 'react-i18next';
import Card from '@/ds-components/Card';
import FormField from '@/ds-components/FormField';
import type { SignInExperienceForm } from '../../types';
import * as styles from '../index.module.scss';
import type { SignInExperienceForm } from '../../../types';
import FormFieldDescription from '../../components/FormFieldDescription';
import FormSectionTitle from '../../components/FormSectionTitle';
import SocialConnectorEditBox from './components/SocialConnectorEditBox';
import SocialConnectorEditBox from './SocialConnectorEditBox';
function SocialSignInForm() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
@ -15,14 +16,11 @@ function SocialSignInForm() {
return (
<Card>
<div className={styles.title}>
{t('sign_in_exp.sign_up_and_sign_in.social_sign_in.title')}
</div>
<FormSectionTitle title="sign_up_and_sign_in.social_sign_in.title" />
<FormField title="sign_in_exp.sign_up_and_sign_in.social_sign_in.social_sign_in">
<div className={styles.formFieldDescription}>
<FormFieldDescription>
{t('sign_in_exp.sign_up_and_sign_in.social_sign_in.description')}
</div>
</FormFieldDescription>
<Controller
control={control}
defaultValue={[]}

View file

@ -1,13 +1,23 @@
import { type AdminConsoleKey } from '@logto/phrases';
import { ConnectorType } from '@logto/schemas';
import { Trans, useTranslation } from 'react-i18next';
import InlineNotification from '@/ds-components/InlineNotification';
import TextLink from '@/ds-components/TextLink';
import useEnabledConnectorTypes from '@/hooks/use-enabled-connector-types';
import { noConnectorWarningPhrase } from '@/pages/SignInExperience/constants';
import * as styles from './index.module.scss';
type NoConnectorWarningPhrase = {
[key in ConnectorType]: AdminConsoleKey;
};
const noConnectorWarningPhrase = Object.freeze({
[ConnectorType.Email]: 'sign_in_exp.setup_warning.no_connector_email',
[ConnectorType.Sms]: 'sign_in_exp.setup_warning.no_connector_sms',
[ConnectorType.Social]: 'sign_in_exp.setup_warning.no_connector_social',
}) satisfies NoConnectorWarningPhrase;
type Props = {
requiredConnectors: ConnectorType[];
};

View file

@ -0,0 +1,9 @@
import { ConnectorType } from '@logto/connector-kit';
import { SignInIdentifier } from '@logto/schemas';
export const identifierRequiredConnectorMapping: {
[key in SignInIdentifier]?: ConnectorType;
} = {
[SignInIdentifier.Email]: ConnectorType.Email,
[SignInIdentifier.Phone]: ConnectorType.Sms,
};

View file

@ -1,7 +1,6 @@
import PageMeta from '@/components/PageMeta';
import TabWrapper from '@/ds-components/TabWrapper';
import * as styles from '../index.module.scss';
import SignInExperienceTabWrapper from '../components/SignInExperienceTabWrapper';
import AdvancedOptions from './AdvancedOptions';
import SignInForm from './SignInForm';
@ -14,7 +13,7 @@ type Props = {
function SignUpAndSignIn({ isActive }: Props) {
return (
<TabWrapper isActive={isActive} className={styles.tabContent}>
<SignInExperienceTabWrapper isActive={isActive}>
{isActive && (
<PageMeta titleKey={['sign_in_exp.tabs.sign_up_and_sign_in', 'sign_in_exp.page_title']} />
)}
@ -22,7 +21,7 @@ function SignUpAndSignIn({ isActive }: Props) {
<SignInForm />
<SocialSignInForm />
<AdvancedOptions />
</TabWrapper>
</SignInExperienceTabWrapper>
);
}

View file

@ -1,7 +1,10 @@
import { SignInIdentifier } from '@logto/schemas';
import { type ConnectorType, SignInIdentifier } from '@logto/schemas';
import type { SignUpForm } from '@/pages/SignInExperience/types';
import { SignUpIdentifier } from '@/pages/SignInExperience/types';
import type { SignUpForm } from '../../types';
import { SignUpIdentifier } from '../../types';
import { signUpIdentifiersMapping } from '../constants';
import { identifierRequiredConnectorMapping } from './constants';
export const getSignInMethodPasswordCheckState = (
signInIdentifier: SignInIdentifier,
@ -41,3 +44,19 @@ export const createSignInMethod = (identifier: SignInIdentifier) => ({
verificationCode: identifier !== SignInIdentifier.Username,
isPasswordPrimary: true,
});
export const isVerificationRequiredSignUpIdentifiers = (signUpIdentifier: SignUpIdentifier) => {
const identifiers = signUpIdentifiersMapping[signUpIdentifier];
return (
identifiers.includes(SignInIdentifier.Email) || identifiers.includes(SignInIdentifier.Phone)
);
};
export const getSignUpRequiredConnectorTypes = (
signUpIdentifier: SignUpIdentifier
): ConnectorType[] =>
signUpIdentifiersMapping[signUpIdentifier]
.map((identifier) => identifierRequiredConnectorMapping[identifier])
// eslint-disable-next-line unicorn/prefer-native-coercion-functions
.filter((connectorType): connectorType is ConnectorType => Boolean(connectorType));

View file

@ -4,12 +4,13 @@ import { detailedDiff } from 'deep-object-diff';
import { useTranslation } from 'react-i18next';
import DynamicT from '@/ds-components/DynamicT';
import { signInIdentifierPhrase } from '@/pages/SignInExperience/constants';
import type { SignInMethod, SignInMethodsObject } from '@/pages/SignInExperience/types';
import type { SignInMethod, SignInMethodsObject } from '../../../types';
import { signInIdentifierPhrase } from '../../constants';
import { convertToSignInMethodsObject } from '../../utils/form';
import DiffSegment from './DiffSegment';
import * as styles from './index.module.scss';
import { convertToSignInMethodsObject } from './utils';
type Props = {
before: SignInMethod[];

View file

@ -4,9 +4,10 @@ import { diff } from 'deep-object-diff';
import { useTranslation } from 'react-i18next';
import DynamicT from '@/ds-components/DynamicT';
import { signUpIdentifierPhrase } from '@/pages/SignInExperience/constants';
import type { SignUpForm } from '@/pages/SignInExperience/types';
import { signUpFormDataParser } from '@/pages/SignInExperience/utils/parser';
import type { SignUpForm } from '../../../types';
import { signUpIdentifierPhrase } from '../../constants';
import { signUpFormDataParser } from '../../utils/parser';
import DiffSegment from './DiffSegment';
import * as styles from './index.module.scss';

View file

@ -0,0 +1,7 @@
@use '@/scss/underscore' as _;
.formFieldDescription {
font: var(--font-body-2);
color: var(--color-text-secondary);
margin: _.unit(1) 0 _.unit(2);
}

View file

@ -0,0 +1,13 @@
import { type ReactNode } from 'react';
import * as styles from './index.module.scss';
type Props = {
children: ReactNode;
};
function FormFieldDescription({ children }: Props) {
return <div className={styles.formFieldDescription}>{children}</div>;
}
export default FormFieldDescription;

View file

@ -0,0 +1,6 @@
@use '@/scss/underscore' as _;
.title {
@include _.section-head-1;
color: var(--color-neutral-variant-60);
}

View file

@ -0,0 +1,19 @@
import { type TFuncKey } from 'i18next';
import DynamicT from '@/ds-components/DynamicT';
import * as styles from './index.module.scss';
type Props = {
title: TFuncKey<'translation', 'admin_console.sign_in_exp'>;
};
function FormSectionTitle({ title }: Props) {
return (
<div className={styles.title}>
<DynamicT forKey={`sign_in_exp.${title}`} />
</div>
);
}
export default FormSectionTitle;

View file

@ -0,0 +1,7 @@
@use '@/scss/underscore' as _;
.tabContent {
> :not(:first-child) {
margin-top: _.unit(3);
}
}

View file

@ -0,0 +1,15 @@
import TabWrapper, { type Props as TabWrapperProps } from '@/ds-components/TabWrapper';
import * as styles from './index.module.scss';
type Props = Omit<TabWrapperProps, 'className'>;
function SignInExperienceTabWrapper({ children, ...reset }: Props) {
return (
<TabWrapper {...reset} className={styles.tabContent}>
{children}
</TabWrapper>
);
}
export default SignInExperienceTabWrapper;

View file

@ -1,7 +1,7 @@
import type { AdminConsoleKey } from '@logto/phrases';
import { ConnectorType, SignInIdentifier } from '@logto/schemas';
import { SignInIdentifier } from '@logto/schemas';
import { SignUpIdentifier } from './types';
import { SignUpIdentifier } from '../types';
export const signUpIdentifiers = Object.values(SignUpIdentifier);
@ -15,13 +15,6 @@ export const signUpIdentifiersMapping: { [key in SignUpIdentifier]: SignInIdenti
[SignUpIdentifier.None]: [],
};
export const identifierRequiredConnectorMapping: {
[key in SignInIdentifier]?: ConnectorType;
} = {
[SignInIdentifier.Email]: ConnectorType.Email,
[SignInIdentifier.Phone]: ConnectorType.Sms,
};
type SignInIdentifierPhrase = {
[key in SignInIdentifier]: AdminConsoleKey;
};
@ -43,13 +36,3 @@ export const signUpIdentifierPhrase = Object.freeze({
[SignUpIdentifier.EmailOrSms]: 'sign_in_exp.sign_up_and_sign_in.identifiers_email_or_sms',
[SignUpIdentifier.None]: 'sign_in_exp.sign_up_and_sign_in.identifiers_none',
}) satisfies SignUpIdentifierPhrase;
type NoConnectorWarningPhrase = {
[key in ConnectorType]: AdminConsoleKey;
};
export const noConnectorWarningPhrase = Object.freeze({
[ConnectorType.Email]: 'sign_in_exp.setup_warning.no_connector_email',
[ConnectorType.Sms]: 'sign_in_exp.setup_warning.no_connector_sms',
[ConnectorType.Social]: 'sign_in_exp.setup_warning.no_connector_social',
}) satisfies NoConnectorWarningPhrase;

View file

@ -0,0 +1,36 @@
@use '@/scss/underscore' as _;
.tabs {
margin: _.unit(4) 0;
}
.content {
flex: 1;
display: flex;
flex-direction: column;
.contentTop {
display: flex;
flex: 1;
margin-bottom: _.unit(6);
&.withSubmitActionBar {
margin-bottom: _.unit(3);
}
> * {
flex: 1;
min-width: 510px;
}
.form {
margin-right: _.unit(3);
}
.preview {
position: sticky;
top: _.unit(4);
align-self: flex-start;
}
}
}

View file

@ -0,0 +1,171 @@
import { type SignInExperience } from '@logto/schemas';
import classNames from 'classnames';
import { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import SubmitFormChangesActionBar from '@/components/SubmitFormChangesActionBar';
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
import ConfirmModal from '@/ds-components/ConfirmModal';
import TabNav, { TabNavItem } from '@/ds-components/TabNav';
import useApi from '@/hooks/use-api';
import useConfigs from '@/hooks/use-configs';
import useTenantPathname from '@/hooks/use-tenant-pathname';
import { trySubmitSafe } from '@/utils/form';
import Preview from '../components/Preview';
import usePreviewConfigs from '../hooks/use-preview-configs';
import { SignInExperienceTab } from '../types';
import { type SignInExperienceForm } from '../types';
import Branding from './Branding';
import Content from './Content';
import PasswordPolicy from './PasswordPolicy';
import SignUpAndSignIn from './SignUpAndSignIn';
import SignUpAndSignInChangePreview from './SignUpAndSignInChangePreview';
import * as styles from './index.module.scss';
import {
getBrandingErrorCount,
getSignUpAndSignInErrorCount,
getContentErrorCount,
hasSignUpAndSignInConfigChanged,
} from './utils/form';
import { sieFormDataParser } from './utils/parser';
const PageTab = TabNavItem<`../${SignInExperienceTab}`>;
type Props = {
data: SignInExperience;
onSignInExperienceUpdated: (data: SignInExperience) => void;
};
function PageContent({ data, onSignInExperienceUpdated }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { tab } = useParams();
const [isSaving, setIsSaving] = useState(false);
const { updateConfigs } = useConfigs();
const { getPathname } = useTenantPathname();
const [dataToCompare, setDataToCompare] = useState<SignInExperience>();
const methods = useForm<SignInExperienceForm>({
defaultValues: sieFormDataParser.fromSignInExperience(data),
});
const {
reset,
handleSubmit,
getValues,
watch,
formState: { isDirty, errors },
} = methods;
const api = useApi();
const formData = watch();
const previewConfigs = usePreviewConfigs(formData, isDirty, data);
const saveData = async () => {
setIsSaving(true);
try {
const updatedData = await api
.patch('api/sign-in-exp', {
json: sieFormDataParser.toUpdateSignInExperienceData(getValues()),
})
.json<SignInExperience>();
reset(sieFormDataParser.fromSignInExperience(updatedData));
onSignInExperienceUpdated(updatedData);
setDataToCompare(undefined);
await updateConfigs({ signInExperienceCustomized: true });
toast.success(t('general.saved'));
} finally {
setIsSaving(false);
}
};
const onSubmit = handleSubmit(
trySubmitSafe(async (formData: SignInExperienceForm) => {
if (isSaving) {
return;
}
const formatted = sieFormDataParser.toSignInExperience(formData);
// Sign-in methods changed, need to show confirm modal first.
if (!hasSignUpAndSignInConfigChanged(data, formatted)) {
setDataToCompare(formatted);
return;
}
await saveData();
})
);
return (
<>
<TabNav className={styles.tabs}>
<PageTab href="../branding" errorCount={getBrandingErrorCount(errors)}>
{t('sign_in_exp.tabs.branding')}
</PageTab>
<PageTab
href="../sign-up-and-sign-in"
errorCount={getSignUpAndSignInErrorCount(errors, formData)}
>
{t('sign_in_exp.tabs.sign_up_and_sign_in')}
</PageTab>
<PageTab href="../content" errorCount={getContentErrorCount(errors)}>
{t('sign_in_exp.tabs.content')}
</PageTab>
<PageTab href="../password-policy">{t('sign_in_exp.tabs.password_policy')}</PageTab>
</TabNav>
<div className={styles.content}>
<div className={classNames(styles.contentTop, isDirty && styles.withSubmitActionBar)}>
<FormProvider {...methods}>
<form className={styles.form}>
<Branding isActive={tab === SignInExperienceTab.Branding} />
<SignUpAndSignIn isActive={tab === SignInExperienceTab.SignUpAndSignIn} />
<Content isActive={tab === SignInExperienceTab.Content} />
<PasswordPolicy isActive={tab === SignInExperienceTab.PasswordPolicy} />
</form>
</FormProvider>
{formData.id && (
<Preview
isLivePreviewDisabled={isDirty}
signInExperience={previewConfigs}
className={styles.preview}
/>
)}
</div>
<SubmitFormChangesActionBar
isOpen={isDirty}
isSubmitting={isSaving}
onDiscard={reset}
onSubmit={onSubmit}
/>
</div>
<ConfirmModal
isOpen={Boolean(dataToCompare)}
isLoading={isSaving}
onCancel={() => {
setDataToCompare(undefined);
}}
onConfirm={async () => {
await saveData();
}}
>
{dataToCompare && <SignUpAndSignInChangePreview before={data} after={dataToCompare} />}
</ConfirmModal>
<UnsavedChangesAlertModal
hasUnsavedChanges={isDirty}
parentPath={getPathname('/sign-in-experience')}
/>
</>
);
}
export default PageContent;

View file

@ -1,13 +1,29 @@
import type { SignInExperience } from '@logto/schemas';
import type { SignInExperience, SignUp } from '@logto/schemas';
import { diff } from 'deep-object-diff';
import type { DeepRequired, FieldErrorsImpl } from 'react-hook-form';
import {
hasSignInMethodsChanged,
hasSignUpSettingsChanged,
hasSocialTargetsChanged,
} from '../components/SignUpAndSignInChangePreview/SignUpAndSignInDiffSection/utils';
import { SignUpIdentifier } from '../types';
import type { SignInExperienceForm } from '../types';
import type { SignInExperienceForm, SignInMethod, SignInMethodsObject } from '../../types';
import { SignUpIdentifier } from '../../types';
export const convertToSignInMethodsObject = (signInMethods: SignInMethod[]): SignInMethodsObject =>
signInMethods.reduce<SignInMethodsObject>(
(methodsObject, { identifier, password, verificationCode }) => ({
...methodsObject,
[identifier]: { password, verificationCode },
}),
// eslint-disable-next-line @typescript-eslint/prefer-reduce-type-parameter, no-restricted-syntax
{} as SignInMethodsObject
);
const hasSignUpSettingsChanged = (before: SignUp, after: SignUp) =>
Object.keys(diff(before, after)).length > 0;
const hasSignInMethodsChanged = (before: SignInMethod[], after: SignInMethod[]) =>
Object.keys(diff(convertToSignInMethodsObject(before), convertToSignInMethodsObject(after)))
.length > 0;
const hasSocialTargetsChanged = (before: string[], after: string[]) =>
Object.keys(diff(before.slice().sort(), after.slice().sort())).length > 0;
export const hasSignUpAndSignInConfigChanged = (
before: SignInExperience,
@ -38,15 +54,12 @@ export const getSignUpAndSignInErrorCount = (
errors: FieldErrorsImpl<DeepRequired<SignInExperienceForm>>,
formData: SignInExperienceForm
) => {
const signUpIdentifier = formData.signUp?.identifier;
const signUpIdentifier = formData.signUp.identifier;
/**
* Note: we treat the `emailOrSms` sign-up identifier as 2 errors when it's invalid.
*/
const signUpIdentifierRelatedErrorCount = signUpIdentifier
? signUpIdentifier === SignUpIdentifier.EmailOrSms
? 2
: 1
: 0;
const signUpIdentifierRelatedErrorCount =
signUpIdentifier === SignUpIdentifier.EmailOrSms ? 2 : 1;
const { signUp, signIn } = errors;

View file

@ -1,15 +1,29 @@
import { passwordPolicyGuard } from '@logto/core-kit';
import { SignInMode, type SignInExperience, type SignUp, SignInIdentifier } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import {
SignInMode,
type SignInExperience,
type SignUp,
type SignInIdentifier,
} from '@logto/schemas';
import { conditional, isSameArray } from '@silverhand/essentials';
import { signUpIdentifiersMapping } from '../constants';
import {
type UpdateSignInExperienceData,
type SignInExperienceForm,
type SignUpForm,
} from '../types';
type SignUpIdentifier,
} from '../../types';
import { signUpIdentifiersMapping } from '../constants';
import { mapIdentifiersToSignUpIdentifier } from './identifier';
const mapIdentifiersToSignUpIdentifier = (identifiers: SignInIdentifier[]): SignUpIdentifier => {
for (const [signUpIdentifier, mappedIdentifiers] of Object.entries(signUpIdentifiersMapping)) {
if (isSameArray(identifiers, mappedIdentifiers)) {
// eslint-disable-next-line no-restricted-syntax
return signUpIdentifier as SignUpIdentifier;
}
}
throw new Error('Invalid identifiers in the sign up settings.');
};
export const signUpFormDataParser = {
fromSignUp: (data: SignUp): SignUpForm => {
@ -72,13 +86,7 @@ export const sieFormDataParser = {
logoUrl: conditional(branding.logoUrl?.length && branding.logoUrl),
darkLogoUrl: conditional(branding.darkLogoUrl?.length && branding.darkLogoUrl),
},
signUp: signUp
? signUpFormDataParser.toSignUp(signUp)
: {
identifiers: [SignInIdentifier.Username],
password: true,
verify: false,
},
signUp: signUpFormDataParser.toSignUp(signUp),
signInMode: createAccountEnabled ? SignInMode.SignInAndRegister : SignInMode.SignIn,
customCss: customCss?.length ? customCss : null,
passwordPolicy: {

View file

@ -3,7 +3,8 @@ import classNames from 'classnames';
import CardTitle from '@/ds-components/CardTitle';
import Spacer from '@/ds-components/Spacer';
import * as pageStyles from '../../index.module.scss';
import * as pageContentStyles from '../PageContent/index.module.scss';
import * as pageStyles from '../index.module.scss';
import * as styles from './index.module.scss';
@ -15,10 +16,10 @@ function Skeleton() {
subtitle="sign_in_exp.description"
className={pageStyles.cardTitle}
/>
<div className={classNames(pageStyles.tabs, styles.tabBar)} />
<div className={classNames(pageStyles.content, styles.content)}>
<div className={pageStyles.contentTop}>
<div className={pageStyles.form}>
<div className={classNames(pageContentStyles.tabs, styles.tabBar)} />
<div className={classNames(pageContentStyles.content, styles.content)}>
<div className={pageContentStyles.contentTop}>
<div className={pageContentStyles.form}>
<div className={styles.card}>
<div className={styles.title} />
<div className={styles.field} />
@ -30,7 +31,7 @@ function Skeleton() {
<div className={styles.field} />
</div>
</div>
<div className={pageStyles.preview}>
<div className={pageContentStyles.preview}>
<div className={styles.preview}>
<div className={styles.header}>
<div className={styles.info}>

View file

@ -17,13 +17,13 @@ import useUserPreferences from '@/hooks/use-user-preferences';
import * as modalStyles from '@/scss/modal.module.scss';
import { trySubmitSafe } from '@/utils/form';
import usePreviewConfigs from '../../hooks/use-preview-configs';
import BrandingForm from '../../tabs/Branding/BrandingForm';
import LanguagesForm from '../../tabs/Content/LanguagesForm';
import TermsForm from '../../tabs/Content/TermsForm';
import type { SignInExperienceForm } from '../../types';
import { sieFormDataParser } from '../../utils/parser';
import Preview from '../Preview';
import BrandingForm from '../PageContent/Branding/BrandingForm';
import LanguagesForm from '../PageContent/Content/LanguagesForm';
import TermsForm from '../PageContent/Content/TermsForm';
import { sieFormDataParser } from '../PageContent/utils/parser';
import Preview from '../components/Preview';
import usePreviewConfigs from '../hooks/use-preview-configs';
import type { SignInExperienceForm } from '../types';
import * as styles from './GuideModal.module.scss';

View file

@ -1,24 +0,0 @@
import type { SignUp } from '@logto/schemas';
import { diff } from 'deep-object-diff';
import type { SignInMethod, SignInMethodsObject } from '@/pages/SignInExperience/types';
export const hasSignUpSettingsChanged = (before: SignUp, after: SignUp) =>
Object.keys(diff(before, after)).length > 0;
export const convertToSignInMethodsObject = (signInMethods: SignInMethod[]): SignInMethodsObject =>
signInMethods.reduce<SignInMethodsObject>(
(methodsObject, { identifier, password, verificationCode }) => ({
...methodsObject,
[identifier]: { password, verificationCode },
}),
// eslint-disable-next-line @typescript-eslint/prefer-reduce-type-parameter, no-restricted-syntax
{} as SignInMethodsObject
);
export const hasSignInMethodsChanged = (before: SignInMethod[], after: SignInMethod[]) =>
Object.keys(diff(convertToSignInMethodsObject(before), convertToSignInMethodsObject(after)))
.length > 0;
export const hasSocialTargetsChanged = (before: string[], after: string[]) =>
Object.keys(diff(before.slice().sort(), after.slice().sort())).length > 0;

View file

@ -3,8 +3,8 @@ import { useEffect, useState, useMemo } from 'react';
import useDebounce from '@/hooks/use-debounce';
import { sieFormDataParser } from '../PageContent/utils/parser';
import type { SignInExperienceForm } from '../types';
import { sieFormDataParser } from '../utils/parser';
const usePreviewConfigs = (
formData: SignInExperienceForm,

View file

@ -1,56 +1,20 @@
import { withAppInsights } from '@logto/app-insights/react';
import type { SignInExperience as SignInExperienceType } from '@logto/schemas';
import classNames from 'classnames';
import type { ReactNode } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import useSWR from 'swr';
import RequestDataError from '@/components/RequestDataError';
import SubmitFormChangesActionBar from '@/components/SubmitFormChangesActionBar';
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
import { isCloud } from '@/consts/env';
import CardTitle from '@/ds-components/CardTitle';
import ConfirmModal from '@/ds-components/ConfirmModal';
import TabNav, { TabNavItem } from '@/ds-components/TabNav';
import type { RequestError } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import useConfigs from '@/hooks/use-configs';
import useTenantPathname from '@/hooks/use-tenant-pathname';
import useUiLanguages from '@/hooks/use-ui-languages';
import useUserAssetsService from '@/hooks/use-user-assets-service';
import { trySubmitSafe } from '@/utils/form';
import Preview from './components/Preview';
import SignUpAndSignInChangePreview from './components/SignUpAndSignInChangePreview';
import Skeleton from './components/Skeleton';
import Welcome from './components/Welcome';
import usePreviewConfigs from './hooks/use-preview-configs';
import PageContent from './PageContent';
import Skeleton from './Skeleton';
import Welcome from './Welcome';
import * as styles from './index.module.scss';
import Branding from './tabs/Branding';
import Content from './tabs/Content';
import PasswordPolicy from './tabs/PasswordPolicy';
import SignUpAndSignIn from './tabs/SignUpAndSignIn';
import type { SignInExperienceForm } from './types';
import {
hasSignUpAndSignInConfigChanged,
getBrandingErrorCount,
getContentErrorCount,
getSignUpAndSignInErrorCount,
} from './utils/form';
import { sieFormDataParser } from './utils/parser';
export enum SignInExperienceTab {
Branding = 'branding',
SignUpAndSignIn = 'sign-up-and-sign-in',
Content = 'content',
PasswordPolicy = 'password-policy',
}
const PageTab = TabNavItem<`../${SignInExperienceTab}`>;
type PageWrapperProps = {
children: ReactNode;
@ -70,26 +34,25 @@ function PageWrapper({ children }: PageWrapperProps) {
}
function SignInExperience() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { tab } = useParams();
const { data, error, mutate } = useSWR<SignInExperienceType, RequestError>('api/sign-in-exp');
const isLoadingSignInExperience = !data && !error;
const {
data,
error,
isLoading: isLoadingSignInExperience,
mutate,
} = useSWR<SignInExperienceType, RequestError>('api/sign-in-exp');
const { isLoading: isUserAssetsServiceLoading } = useUserAssetsService();
const [isSaving, setIsSaving] = useState(false);
const {
configs,
error: configsError,
updateConfigs,
isLoading: isLoadingConfig,
mutate: mutateConfigs,
} = useConfigs();
const { getPathname } = useTenantPathname();
const shouldDisplayWelcome = !isCloud && !configs?.signInExperienceCustomized;
const { error: languageError, isLoading: isLoadingLanguages } = useUiLanguages();
const [dataToCompare, setDataToCompare] = useState<SignInExperienceType>();
const requestError = error ?? configsError ?? languageError;
@ -99,74 +62,6 @@ function SignInExperience() {
isLoadingLanguages ||
isUserAssetsServiceLoading;
const methods = useForm<SignInExperienceForm>();
const {
reset,
handleSubmit,
getValues,
watch,
formState: { isDirty, errors },
} = methods;
const api = useApi();
const formData = watch();
const previewConfigs = usePreviewConfigs(formData, isDirty, data);
const defaultFormData = useMemo(() => {
if (!data) {
return;
}
return sieFormDataParser.fromSignInExperience(data);
}, [data]);
useEffect(() => {
if (isDirty) {
return;
}
if (defaultFormData) {
reset(defaultFormData);
}
}, [reset, defaultFormData, isDirty]);
const saveData = async () => {
setIsSaving(true);
try {
const updatedData = await api
.patch('api/sign-in-exp', {
json: sieFormDataParser.toUpdateSignInExperienceData(getValues()),
})
.json<SignInExperienceType>();
reset(sieFormDataParser.fromSignInExperience(updatedData));
void mutate(updatedData);
setDataToCompare(undefined);
await updateConfigs({ signInExperienceCustomized: true });
toast.success(t('general.saved'));
} finally {
setIsSaving(false);
}
};
const onSubmit = handleSubmit(
trySubmitSafe(async (formData: SignInExperienceForm) => {
if (!data || isSaving) {
return;
}
const formatted = sieFormDataParser.toSignInExperience(formData);
// Sign-in methods changed, need to show confirm modal first.
if (!hasSignUpAndSignInConfigChanged(data, formatted)) {
setDataToCompare(formatted);
return;
}
await saveData();
})
);
if (isLoading) {
return <Skeleton />;
}
@ -201,66 +96,7 @@ function SignInExperience() {
return (
<PageWrapper>
<TabNav className={styles.tabs}>
<PageTab href="../branding" errorCount={getBrandingErrorCount(errors)}>
{t('sign_in_exp.tabs.branding')}
</PageTab>
<PageTab
href="../sign-up-and-sign-in"
errorCount={getSignUpAndSignInErrorCount(errors, formData)}
>
{t('sign_in_exp.tabs.sign_up_and_sign_in')}
</PageTab>
<PageTab href="../content" errorCount={getContentErrorCount(errors)}>
{t('sign_in_exp.tabs.content')}
</PageTab>
<PageTab href="../password-policy">{t('sign_in_exp.tabs.password_policy')}</PageTab>
</TabNav>
{data && defaultFormData && (
<div className={styles.content}>
<div className={classNames(styles.contentTop, isDirty && styles.withSubmitActionBar)}>
<FormProvider {...methods}>
<form className={styles.form}>
<Branding isActive={tab === SignInExperienceTab.Branding} />
<SignUpAndSignIn isActive={tab === SignInExperienceTab.SignUpAndSignIn} />
<Content isActive={tab === SignInExperienceTab.Content} />
<PasswordPolicy isActive={tab === SignInExperienceTab.PasswordPolicy} />
</form>
</FormProvider>
{formData.id && (
<Preview
isLivePreviewDisabled={isDirty}
signInExperience={previewConfigs}
className={styles.preview}
/>
)}
</div>
<SubmitFormChangesActionBar
isOpen={isDirty}
isSubmitting={isSaving}
onDiscard={reset}
onSubmit={onSubmit}
/>
</div>
)}
{data && (
<ConfirmModal
isOpen={Boolean(dataToCompare)}
isLoading={isSaving}
onCancel={() => {
setDataToCompare(undefined);
}}
onConfirm={async () => {
await saveData();
}}
>
{dataToCompare && <SignUpAndSignInChangePreview before={data} after={dataToCompare} />}
</ConfirmModal>
)}
<UnsavedChangesAlertModal
hasUnsavedChanges={isDirty}
parentPath={getPathname('/sign-in-experience')}
/>
{data && <PageContent data={data} onSignInExperienceUpdated={mutate} />}
</PageWrapper>
);
}

View file

@ -1,69 +0,0 @@
@use '@/scss/underscore' as _;
.tabContent {
> :not(:first-child) {
margin-top: _.unit(3);
}
}
.title {
@include _.section-head-1;
color: var(--color-neutral-variant-60);
}
.formFieldDescription {
font: var(--font-body-2);
color: var(--color-text-secondary);
margin: _.unit(1) 0 _.unit(2);
}
.socialOnlyDescription {
margin-left: _.unit(1);
color: var(--color-text-secondary);
}
.selections {
> :not(:first-child) {
margin-top: _.unit(3);
}
}
.primaryTag {
color: var(--color-text-secondary);
}
.method {
margin-top: _.unit(3);
}
.primarySocial {
margin-top: _.unit(2);
}
.darkModeTip {
display: flex;
align-items: baseline;
font: var(--font-body-2);
color: var(--color-text-secondary);
margin-top: _.unit(1);
}
.manageLanguageButton {
margin-top: _.unit(1);
}
.defaultLanguageDescription {
padding-top: _.unit(2);
font: var(--font-body-2);
color: var(--color-text-secondary);
}
.setUpHint {
font: var(--font-body-2);
color: var(--color-text-secondary);
margin-top: _.unit(2);
.setup {
margin: 0 _.unit(1);
}
}

View file

@ -1,5 +1,12 @@
import { type PasswordPolicy } from '@logto/core-kit';
import type { SignInExperience, SignInIdentifier, SignUp } from '@logto/schemas';
import { type SignUp, type SignInExperience, type SignInIdentifier } from '@logto/schemas';
export enum SignInExperienceTab {
Branding = 'branding',
SignUpAndSignIn = 'sign-up-and-sign-in',
Content = 'content',
PasswordPolicy = 'password-policy',
}
export enum SignUpIdentifier {
Email = 'email',
@ -18,7 +25,7 @@ export type SignInExperienceForm = Omit<
'signUp' | 'customCss' | 'passwordPolicy'
> & {
customCss?: string; // Code editor components can not properly handle null value, manually transform null to undefined instead.
signUp?: SignUpForm;
signUp: SignUpForm;
/** The parsed password policy object. All properties are required. */
passwordPolicy: PasswordPolicy & {
/**

View file

@ -1,34 +0,0 @@
import type { ConnectorType } from '@logto/schemas';
import { SignInIdentifier } from '@logto/schemas';
import { isSameArray } from '@silverhand/essentials';
import { identifierRequiredConnectorMapping, signUpIdentifiersMapping } from '../constants';
import type { SignUpIdentifier } from '../types';
export const isVerificationRequiredSignUpIdentifiers = (signUpIdentifier: SignUpIdentifier) => {
const identifiers = signUpIdentifiersMapping[signUpIdentifier];
return (
identifiers.includes(SignInIdentifier.Email) || identifiers.includes(SignInIdentifier.Phone)
);
};
export const mapIdentifiersToSignUpIdentifier = (
identifiers: SignInIdentifier[]
): SignUpIdentifier => {
for (const [signUpIdentifier, mappedIdentifiers] of Object.entries(signUpIdentifiersMapping)) {
if (isSameArray(identifiers, mappedIdentifiers)) {
// eslint-disable-next-line no-restricted-syntax
return signUpIdentifier as SignUpIdentifier;
}
}
throw new Error('Invalid identifiers in the sign up settings.');
};
export const getSignUpRequiredConnectorTypes = (
signUpIdentifier: SignUpIdentifier
): ConnectorType[] =>
signUpIdentifiersMapping[signUpIdentifier]
.map((identifier) => identifierRequiredConnectorMapping[identifier])
// eslint-disable-next-line unicorn/prefer-native-coercion-functions
.filter((connectorType): connectorType is ConnectorType => Boolean(connectorType));