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:
parent
2daf911278
commit
6e001f582c
81 changed files with 544 additions and 471 deletions
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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"
|
|
@ -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) => (
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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);
|
||||
}
|
|
@ -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')}
|
|
@ -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', {
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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')}
|
|
@ -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';
|
||||
|
|
@ -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';
|
||||
|
|
@ -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,
|
|
@ -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>
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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"
|
|
@ -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[];
|
|
@ -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;
|
|
@ -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';
|
|
@ -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={[]}
|
|
@ -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[];
|
||||
};
|
|
@ -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,
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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));
|
|
@ -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[];
|
|
@ -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';
|
|
@ -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);
|
||||
}
|
|
@ -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;
|
|
@ -0,0 +1,6 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.title {
|
||||
@include _.section-head-1;
|
||||
color: var(--color-neutral-variant-60);
|
||||
}
|
|
@ -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;
|
|
@ -0,0 +1,7 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.tabContent {
|
||||
> :not(:first-child) {
|
||||
margin-top: _.unit(3);
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
||||
|
|
@ -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: {
|
|
@ -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}>
|
|
@ -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';
|
||||
|
|
@ -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;
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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 & {
|
||||
/**
|
||||
|
|
|
@ -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));
|
Loading…
Add table
Reference in a new issue