Skip to content

Commit 384b91b

Browse files
authored
feat(console): setup forgot password methods (#7617)
1 parent 2c959bc commit 384b91b

File tree

10 files changed

+333
-4
lines changed

10 files changed

+333
-4
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { ForgotPasswordMethod } from '@logto/schemas';
2+
import { useTranslation } from 'react-i18next';
3+
4+
import Draggable from '@/assets/icons/draggable.svg?react';
5+
import Minus from '@/assets/icons/minus.svg?react';
6+
import IconButton from '@/ds-components/IconButton';
7+
8+
import styles from './index.module.scss';
9+
import { forgotPasswordMethodPhrase } from './utils';
10+
11+
type Props = {
12+
readonly method: ForgotPasswordMethod;
13+
readonly onRemove: () => void;
14+
};
15+
16+
function VerificationMethodItem({ method, onRemove }: Props) {
17+
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
18+
19+
return (
20+
<div className={styles.methodItem}>
21+
<div className={styles.methodContent}>
22+
<Draggable className={styles.draggableIcon} />
23+
<span className={styles.methodLabel}>{t(forgotPasswordMethodPhrase[method])}</span>
24+
</div>
25+
<IconButton onClick={onRemove}>
26+
<Minus />
27+
</IconButton>
28+
</div>
29+
);
30+
}
31+
32+
export default VerificationMethodItem;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
@use '@/scss/underscore' as _;
2+
3+
.draggleItemContainer {
4+
transform: translate(0, 0);
5+
}
6+
7+
.methodItem {
8+
display: flex;
9+
align-items: center;
10+
margin: _.unit(2) 0;
11+
gap: _.unit(2);
12+
}
13+
14+
.methodContent {
15+
display: flex;
16+
align-items: center;
17+
height: 44px;
18+
width: 100%;
19+
padding: _.unit(3) _.unit(2);
20+
background-color: var(--color-layer-2);
21+
border-radius: 8px;
22+
cursor: move;
23+
color: var(--color-text);
24+
}
25+
26+
.methodLabel {
27+
font: var(--font-label-2);
28+
color: var(--color-text);
29+
}
30+
31+
.draggableIcon {
32+
color: var(--color-text-secondary);
33+
margin-right: _.unit(1);
34+
}
35+
36+
.plusIcon {
37+
color: var(--color-text-secondary);
38+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { ForgotPasswordMethod } from '@logto/schemas';
2+
import { Controller, useFormContext } from 'react-hook-form';
3+
import { useTranslation } from 'react-i18next';
4+
5+
import Plus from '@/assets/icons/plus.svg?react';
6+
import ActionMenu from '@/ds-components/ActionMenu';
7+
import { DragDropProvider, DraggableItem } from '@/ds-components/DragDrop';
8+
import { DropdownItem } from '@/ds-components/Dropdown';
9+
import FormField from '@/ds-components/FormField';
10+
11+
import type { SignInExperienceForm } from '../../../../types';
12+
import FormFieldDescription from '../../../components/FormFieldDescription';
13+
14+
import VerificationMethodItem from './VerificationMethodItem';
15+
import styles from './index.module.scss';
16+
import { forgotPasswordMethodPhrase } from './utils';
17+
18+
function ForgotPasswordMethodEditBox() {
19+
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
20+
const { control } = useFormContext<SignInExperienceForm>();
21+
22+
return (
23+
<FormField title="sign_in_exp.sign_up_and_sign_in.sign_in.forgot_password_verification_method">
24+
<FormFieldDescription>
25+
{t('sign_in_exp.sign_up_and_sign_in.sign_in.forgot_password_description')}
26+
</FormFieldDescription>
27+
<Controller
28+
control={control}
29+
defaultValue={[]}
30+
name="forgotPasswordMethods"
31+
render={({ field: { value, onChange } }) => {
32+
const methods = value ?? [];
33+
const availableMethods = Object.values(ForgotPasswordMethod).filter(
34+
(method) => !methods.includes(method)
35+
);
36+
37+
const handleAddMethod = (method: ForgotPasswordMethod) => {
38+
onChange([...methods, method]);
39+
};
40+
41+
const handleRemoveMethod = (method: ForgotPasswordMethod) => {
42+
onChange(
43+
methods.filter((currentMethod: ForgotPasswordMethod) => currentMethod !== method)
44+
);
45+
};
46+
47+
const handleSwapMethods = (dragIndex: number, hoverIndex: number) => {
48+
const dragItem = methods[dragIndex];
49+
const hoverItem = methods[hoverIndex];
50+
51+
if (!dragItem || !hoverItem) {
52+
return;
53+
}
54+
55+
onChange(
56+
methods.map((method: ForgotPasswordMethod, index: number) => {
57+
if (index === dragIndex) {
58+
return hoverItem;
59+
}
60+
if (index === hoverIndex) {
61+
return dragItem;
62+
}
63+
return method;
64+
})
65+
);
66+
};
67+
68+
return (
69+
<div>
70+
<DragDropProvider>
71+
{methods.map((method: ForgotPasswordMethod, index: number) => (
72+
<DraggableItem
73+
key={method}
74+
id={method}
75+
sortIndex={index}
76+
moveItem={handleSwapMethods}
77+
className={styles.draggleItemContainer}
78+
>
79+
<VerificationMethodItem
80+
method={method}
81+
onRemove={() => {
82+
handleRemoveMethod(method);
83+
}}
84+
/>
85+
</DraggableItem>
86+
))}
87+
</DragDropProvider>
88+
{availableMethods.length > 0 && (
89+
<ActionMenu
90+
isDropdownFullWidth
91+
buttonProps={{
92+
type: 'default',
93+
size: 'medium',
94+
title: 'sign_in_exp.sign_up_and_sign_in.sign_in.add_verification_method',
95+
icon: <Plus className={styles.plusIcon} />,
96+
}}
97+
dropdownHorizontalAlign="start"
98+
>
99+
{availableMethods.map((method) => (
100+
<DropdownItem
101+
key={method}
102+
onClick={() => {
103+
handleAddMethod(method);
104+
}}
105+
>
106+
{t(forgotPasswordMethodPhrase[method])}
107+
</DropdownItem>
108+
))}
109+
</ActionMenu>
110+
)}
111+
</div>
112+
);
113+
}}
114+
/>
115+
</FormField>
116+
);
117+
}
118+
119+
export default ForgotPasswordMethodEditBox;
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type { AdminConsoleKey } from '@logto/phrases';
2+
import { ForgotPasswordMethod } from '@logto/schemas';
3+
4+
type ForgotPasswordMethodPhrase = {
5+
[key in ForgotPasswordMethod]: AdminConsoleKey;
6+
};
7+
8+
export const forgotPasswordMethodPhrase = Object.freeze({
9+
[ForgotPasswordMethod.EmailVerificationCode]:
10+
'sign_in_exp.sign_up_and_sign_in.sign_in.email_verification_code',
11+
[ForgotPasswordMethod.PhoneVerificationCode]:
12+
'sign_in_exp.sign_up_and_sign_in.sign_in.phone_verification_code',
13+
}) satisfies ForgotPasswordMethodPhrase;

packages/console/src/pages/SignInExperience/PageContent/SignUpAndSignIn/SignInForm/index.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
import type { SignInExperience } from '@logto/schemas';
2+
import { ForgotPasswordMethod, ConnectorType } from '@logto/schemas';
3+
import { useEffect } from 'react';
4+
import { useFormContext } from 'react-hook-form';
25
import { useTranslation } from 'react-i18next';
36

7+
import { isDevFeaturesEnabled } from '@/consts/env';
48
import Card from '@/ds-components/Card';
59
import FormField from '@/ds-components/FormField';
10+
import useEnabledConnectorTypes from '@/hooks/use-enabled-connector-types';
611

12+
import type { SignInExperienceForm } from '../../../types';
713
import FormFieldDescription from '../../components/FormFieldDescription';
814
import FormSectionTitle from '../../components/FormSectionTitle';
915

16+
import ForgotPasswordMethodEditBox from './ForgotPasswordMethodEditBox';
1017
import SignInMethodEditBox from './SignInMethodEditBox';
1118

1219
type Props = {
@@ -15,6 +22,36 @@ type Props = {
1522

1623
function SignInForm({ signInExperience }: Props) {
1724
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
25+
const { watch, setValue } = useFormContext<SignInExperienceForm>();
26+
const { isConnectorTypeEnabled } = useEnabledConnectorTypes();
27+
28+
const signInMethods = watch('signIn.methods');
29+
const forgotPasswordMethods = watch('forgotPasswordMethods');
30+
const hasPasswordMethod = signInMethods.some((method) => method.password);
31+
32+
useEffect(() => {
33+
if (!isDevFeaturesEnabled) {
34+
return;
35+
}
36+
37+
// If there is no password method, we should clear the forgot password methods.
38+
if (!hasPasswordMethod) {
39+
setValue('forgotPasswordMethods', []);
40+
} else if (!forgotPasswordMethods) {
41+
// If this is null, we should initialize it based on current connector setup
42+
// if has email connector, then add email verification code method, also for sms connector
43+
const initialMethods = [
44+
...(isConnectorTypeEnabled(ConnectorType.Email)
45+
? [ForgotPasswordMethod.EmailVerificationCode]
46+
: []),
47+
...(isConnectorTypeEnabled(ConnectorType.Sms)
48+
? [ForgotPasswordMethod.PhoneVerificationCode]
49+
: []),
50+
];
51+
52+
setValue('forgotPasswordMethods', initialMethods);
53+
}
54+
}, [hasPasswordMethod, setValue, isConnectorTypeEnabled, forgotPasswordMethods]);
1855

1956
return (
2057
<Card>
@@ -25,6 +62,7 @@ function SignInForm({ signInExperience }: Props) {
2562
</FormFieldDescription>
2663
<SignInMethodEditBox signInExperience={signInExperience} />
2764
</FormField>
65+
{isDevFeaturesEnabled && hasPasswordMethod && <ForgotPasswordMethodEditBox />}
2866
</Card>
2967
);
3068
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { ForgotPasswordMethod } from '@logto/schemas';
2+
import { useTranslation } from 'react-i18next';
3+
4+
import DiffSegment from './DiffSegment';
5+
import styles from './index.module.scss';
6+
7+
type Props = {
8+
readonly before: ForgotPasswordMethod[] | undefined;
9+
readonly after: ForgotPasswordMethod[] | undefined;
10+
readonly isAfter?: boolean;
11+
};
12+
13+
function ForgotPasswordMethodsDiffSection({ before, after, isAfter = false }: Props) {
14+
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
15+
const sortedBeforeMethods = (before ?? []).slice().sort();
16+
const sortedAfterMethods = (after ?? []).slice().sort();
17+
18+
const displayMethods = isAfter ? sortedAfterMethods : sortedBeforeMethods;
19+
20+
const hasChanged = (method: ForgotPasswordMethod) =>
21+
!((before ?? []).includes(method) && (after ?? []).includes(method));
22+
23+
const getMethodLabel = (method: ForgotPasswordMethod) => {
24+
switch (method) {
25+
case ForgotPasswordMethod.EmailVerificationCode: {
26+
return t('sign_in_exp.sign_up_and_sign_in.sign_in.email_verification_code');
27+
}
28+
case ForgotPasswordMethod.PhoneVerificationCode: {
29+
return t('sign_in_exp.sign_up_and_sign_in.sign_in.phone_verification_code');
30+
}
31+
}
32+
};
33+
34+
if (displayMethods.length === 0) {
35+
return null;
36+
}
37+
38+
return (
39+
<div>
40+
<div className={styles.title}>
41+
{t('sign_in_exp.sign_up_and_sign_in.sign_in.forgot_password_verification_method')}
42+
</div>
43+
<ul className={styles.list}>
44+
{displayMethods.map((method) => (
45+
<li key={method}>
46+
<DiffSegment hasChanged={hasChanged(method)} isAfter={isAfter}>
47+
{getMethodLabel(method)}
48+
</DiffSegment>
49+
</li>
50+
))}
51+
</ul>
52+
</div>
53+
);
54+
}
55+
56+
export default ForgotPasswordMethodsDiffSection;

packages/console/src/pages/SignInExperience/PageContent/SignUpAndSignInChangePreview/SignUpAndSignInDiffSection/index.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import { isDevFeaturesEnabled } from '@/consts/env';
12
import { type SignInExperiencePageManagedData } from '@/pages/SignInExperience/types';
23

4+
import ForgotPasswordMethodsDiffSection from './ForgotPasswordMethodsDiffSection';
35
import SignInDiffSection from './SignInDiffSection';
46
import SignUpDiffSection from './SignUpDiffSection';
57
import SocialTargetsDiffSection from './SocialTargetsDiffSection';
@@ -24,6 +26,13 @@ function SignUpAndSignInDiffSection({ before, after, isAfter = false }: Props) {
2426
after={after.socialSignInConnectorTargets}
2527
isAfter={isAfter}
2628
/>
29+
{isDevFeaturesEnabled && (
30+
<ForgotPasswordMethodsDiffSection
31+
before={before.forgotPasswordMethods ?? undefined}
32+
after={after.forgotPasswordMethods ?? undefined}
33+
isAfter={isAfter}
34+
/>
35+
)}
2736
</>
2837
);
2938
}

0 commit comments

Comments
 (0)