mirror of
https://github.com/logto-io/logto.git
synced 2025-03-31 22:51:25 -05:00
feat(console): allow linking email via verification code in profile (#3275)
This commit is contained in:
parent
84adbf66df
commit
c3daeebe4d
15 changed files with 565 additions and 21 deletions
|
@ -83,6 +83,7 @@
|
|||
"react-paginate": "^8.1.3",
|
||||
"react-router-dom": "6.3.0",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"react-timer-hook": "^3.0.5",
|
||||
"recharts": "^2.1.13",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"stylelint": "^15.0.0",
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: _.unit(2);
|
||||
text-align: center;
|
||||
color: var(--color-text-primary);
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
caret-color: var(--color-primary);
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
outline: 3px solid transparent;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-primary);
|
||||
outline-color: var(--color-focused-variant);
|
||||
}
|
||||
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
font: var(--font-body-2);
|
||||
color: var(--color-error);
|
||||
margin-left: _.unit(0.5);
|
||||
margin-top: _.unit(1);
|
||||
}
|
220
packages/console/src/components/VerificationCodeInput/index.tsx
Normal file
220
packages/console/src/components/VerificationCodeInput/index.tsx
Normal file
|
@ -0,0 +1,220 @@
|
|||
import type { FormEventHandler, KeyboardEventHandler, ClipboardEventHandler } from 'react';
|
||||
import { useMemo, useRef, useCallback, useEffect } from 'react';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
export const defaultLength = 6;
|
||||
|
||||
export type Props = {
|
||||
name: string;
|
||||
className?: string;
|
||||
length?: number;
|
||||
value: string[];
|
||||
error?: string;
|
||||
onChange: (value: string[]) => void;
|
||||
};
|
||||
|
||||
const isNumeric = (char: string) => /^\d+$/.test(char);
|
||||
|
||||
const normalize = (value: string[], length: number): string[] => {
|
||||
if (value.length > length) {
|
||||
return value.slice(0, length);
|
||||
}
|
||||
|
||||
if (value.length < length) {
|
||||
// Undefined will not overwrite the original input displays, need to pass in empty string instead
|
||||
return value.concat(Array.from<string>({ length: length - value.length }).fill(''));
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
const VerificationCodeInput = ({
|
||||
name,
|
||||
className,
|
||||
value,
|
||||
length = defaultLength,
|
||||
error,
|
||||
onChange,
|
||||
}: Props) => {
|
||||
/* eslint-disable @typescript-eslint/ban-types */
|
||||
const inputReferences = useRef<Array<HTMLInputElement | null>>(
|
||||
Array.from<null>({ length }).fill(null)
|
||||
);
|
||||
/* eslint-enable @typescript-eslint/ban-types */
|
||||
|
||||
const codes = useMemo(() => normalize(value, length), [length, value]);
|
||||
|
||||
const updateValue = useCallback(
|
||||
(data: string, targetId: number) => {
|
||||
// Filter non-numeric input
|
||||
if (!isNumeric(data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chars = data.split('');
|
||||
const trimmedChars = chars.slice(0, Math.min(chars.length, codes.length - targetId));
|
||||
|
||||
const value = [
|
||||
...codes.slice(0, targetId),
|
||||
...trimmedChars,
|
||||
...codes.slice(targetId + trimmedChars.length, codes.length),
|
||||
];
|
||||
|
||||
onChange(value);
|
||||
|
||||
// Move to the next target
|
||||
const nextTarget =
|
||||
inputReferences.current[Math.min(targetId + trimmedChars.length, codes.length - 1)];
|
||||
nextTarget?.focus();
|
||||
},
|
||||
[codes, onChange]
|
||||
);
|
||||
|
||||
const onInputHandler: FormEventHandler<HTMLInputElement> = useCallback(
|
||||
(event) => {
|
||||
const { target } = event;
|
||||
|
||||
if (!(target instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { value, dataset } = target;
|
||||
|
||||
// Unrecognized target input field
|
||||
if (!dataset.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
updateValue(value, Number(dataset.id));
|
||||
},
|
||||
[updateValue]
|
||||
);
|
||||
|
||||
const onPasteHandler: ClipboardEventHandler<HTMLInputElement> = useCallback(
|
||||
(event) => {
|
||||
if (!(event.target instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
target: { dataset },
|
||||
clipboardData,
|
||||
} = event;
|
||||
|
||||
const data = clipboardData.getData('text').match(/\d/g)?.join('') ?? '';
|
||||
|
||||
// Unrecognized target input field
|
||||
if (!dataset.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
updateValue(data, Number(dataset.id));
|
||||
},
|
||||
[updateValue]
|
||||
);
|
||||
|
||||
const onKeyDownHandler: KeyboardEventHandler<HTMLInputElement> = useCallback(
|
||||
(event) => {
|
||||
const { key, target } = event;
|
||||
|
||||
if (!(target instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { value, dataset } = target;
|
||||
|
||||
if (!dataset.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetId = Number(dataset.id);
|
||||
|
||||
const nextTarget = inputReferences.current[targetId + 1];
|
||||
const previousTarget = inputReferences.current[targetId - 1];
|
||||
|
||||
switch (key) {
|
||||
case 'Backspace': {
|
||||
event.preventDefault();
|
||||
|
||||
if (value) {
|
||||
onChange(Object.assign([], codes, { [targetId]: '' }));
|
||||
break;
|
||||
}
|
||||
|
||||
if (previousTarget) {
|
||||
previousTarget.focus();
|
||||
onChange(Object.assign([], codes, { [targetId - 1]: '' }));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ArrowLeft': {
|
||||
event.preventDefault();
|
||||
previousTarget?.focus();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ArrowRight': {
|
||||
event.preventDefault();
|
||||
nextTarget?.focus();
|
||||
break;
|
||||
}
|
||||
case '+':
|
||||
case '-':
|
||||
case 'e':
|
||||
case '.':
|
||||
case 'ArrowUp':
|
||||
case 'ArrowDown': {
|
||||
event.preventDefault();
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[codes, onChange]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (value.length === 0) {
|
||||
inputReferences.current[0]?.focus();
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className={styles.wrapper}>
|
||||
{Array.from({ length }).map((_, index) => (
|
||||
<input
|
||||
ref={(element) => {
|
||||
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||
inputReferences.current[index] = element;
|
||||
}}
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={`${name}_${index}`}
|
||||
className={styles.input}
|
||||
name={`${name}_${index}`}
|
||||
data-id={index}
|
||||
value={codes[index]}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
autoComplete="off"
|
||||
onPaste={onPasteHandler}
|
||||
onInput={onInputHandler}
|
||||
onKeyDown={onKeyDownHandler}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{error && <div className={styles.errorMessage}>{error}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerificationCodeInput;
|
|
@ -47,6 +47,8 @@ import Users from '@/pages/Users';
|
|||
import Welcome from '@/pages/Welcome';
|
||||
|
||||
import ChangePasswordModal from '../Profile/containers/ChangePasswordModal';
|
||||
import LinkEmailModal from '../Profile/containers/LinkEmailModal';
|
||||
import VerificationCodeModal from '../Profile/containers/VerificationCodeModal';
|
||||
import VerifyPasswordModal from '../Profile/containers/VerifyPasswordModal';
|
||||
|
||||
const Main = () => {
|
||||
|
@ -139,6 +141,8 @@ const Main = () => {
|
|||
<Route index element={<Profile />} />
|
||||
<Route path="verify-password" element={<VerifyPasswordModal />} />
|
||||
<Route path="change-password" element={<ChangePasswordModal />} />
|
||||
<Route path="link-email" element={<LinkEmailModal />} />
|
||||
<Route path="verification-code" element={<VerificationCodeModal />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
|
|
|
@ -45,3 +45,7 @@
|
|||
margin-top: _.unit(-3);
|
||||
}
|
||||
}
|
||||
|
||||
.strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import type { AdminConsoleKey } from '@logto/phrases';
|
||||
import classNames from 'classnames';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import ReactModal from 'react-modal';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
|
@ -14,10 +14,19 @@ import * as styles from './index.module.scss';
|
|||
type Props = PropsWithChildren<{
|
||||
title: AdminConsoleKey;
|
||||
subtitle?: AdminConsoleKey;
|
||||
subtitleProps?: Record<string, string>;
|
||||
onClose: () => void;
|
||||
onGoBack?: () => void;
|
||||
}>;
|
||||
|
||||
const MainFlowLikeModal = ({ title, subtitle, children, onClose }: Props) => {
|
||||
const MainFlowLikeModal = ({
|
||||
title,
|
||||
subtitle,
|
||||
subtitleProps,
|
||||
children,
|
||||
onClose,
|
||||
onGoBack,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
@ -29,13 +38,23 @@ const MainFlowLikeModal = ({ title, subtitle, children, onClose }: Props) => {
|
|||
className={styles.backButton}
|
||||
icon={<Arrow />}
|
||||
onClick={() => {
|
||||
navigate(-1);
|
||||
if (onGoBack) {
|
||||
onGoBack();
|
||||
} else {
|
||||
navigate(-1);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('general.back')}
|
||||
</TextLink>
|
||||
<span className={styles.title}>{t(title)}</span>
|
||||
{subtitle && <span className={styles.subtitle}>{t(subtitle)}</span>}
|
||||
{subtitle && (
|
||||
<span className={styles.subtitle}>
|
||||
<Trans components={{ strong: <span className={styles.strong} /> }}>
|
||||
{t(subtitle, subtitleProps)}
|
||||
</Trans>
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -3,7 +3,7 @@ import { useState } from 'react';
|
|||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import Button from '@/components/Button';
|
||||
import Checkbox from '@/components/Checkbox';
|
||||
|
@ -28,6 +28,7 @@ const defaultValues: FormFields = {
|
|||
const ChangePasswordModal = () => {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const navigate = useNavigate();
|
||||
const { state } = useLocation();
|
||||
const {
|
||||
watch,
|
||||
reset,
|
||||
|
@ -49,9 +50,9 @@ const ChangePasswordModal = () => {
|
|||
const onSubmit = () => {
|
||||
clearErrors();
|
||||
void handleSubmit(async ({ newPassword }) => {
|
||||
await api.post(`me/password`, { json: { password: newPassword } }).json();
|
||||
await api.post(`me/password`, { json: { password: newPassword } });
|
||||
toast.success(t('settings.password_changed'));
|
||||
reset({});
|
||||
reset();
|
||||
onClose();
|
||||
})();
|
||||
};
|
||||
|
@ -63,7 +64,13 @@ const ChangePasswordModal = () => {
|
|||
};
|
||||
|
||||
return (
|
||||
<MainFlowLikeModal title="profile.password.set_password" onClose={onClose}>
|
||||
<MainFlowLikeModal
|
||||
title="profile.password.set_password"
|
||||
onClose={onClose}
|
||||
onGoBack={() => {
|
||||
navigate('../verify-password', { state });
|
||||
}}
|
||||
>
|
||||
<TextInput
|
||||
placeholder={t('profile.password.password')}
|
||||
{...register('newPassword', {
|
||||
|
|
|
@ -0,0 +1,78 @@
|
|||
import { emailRegEx } from '@logto/core-kit';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import Button from '@/components/Button';
|
||||
import TextInput from '@/components/TextInput';
|
||||
import { adminTenantEndpoint, meApi } from '@/consts';
|
||||
import { useStaticApi } from '@/hooks/use-api';
|
||||
|
||||
import MainFlowLikeModal from '../../components/MainFlowLikeModal';
|
||||
import { checkLocationState } from '../../utils';
|
||||
|
||||
type EmailForm = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
const LinkEmailModal = () => {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const navigate = useNavigate();
|
||||
const { state } = useLocation();
|
||||
const {
|
||||
register,
|
||||
reset,
|
||||
clearErrors,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<EmailForm>({
|
||||
reValidateMode: 'onBlur',
|
||||
});
|
||||
const api = useStaticApi({ prefixUrl: adminTenantEndpoint, resourceIndicator: meApi.indicator });
|
||||
|
||||
const onClose = () => {
|
||||
navigate('/profile');
|
||||
};
|
||||
|
||||
const onSubmit = () => {
|
||||
clearErrors();
|
||||
void handleSubmit(async ({ email }) => {
|
||||
await api.post(`me/verification-codes`, { json: { email } });
|
||||
reset();
|
||||
navigate('../verification-code', { state: { email, action: 'changeEmail' } });
|
||||
})();
|
||||
};
|
||||
|
||||
return (
|
||||
<MainFlowLikeModal
|
||||
title="profile.link_account.link_email"
|
||||
subtitle="profile.link_account.link_email_subtitle"
|
||||
onClose={onClose}
|
||||
>
|
||||
<TextInput
|
||||
{...register('email', {
|
||||
required: t('profile.link_account.email_required'),
|
||||
pattern: { value: emailRegEx, message: t('profile.link_account.invalid_email') },
|
||||
validate: (value) =>
|
||||
(checkLocationState(state) && state.email !== value) ||
|
||||
t('profile.link_account.identical_email_address'),
|
||||
})}
|
||||
errorMessage={errors.email?.message}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
onSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
title="general.continue"
|
||||
isLoading={isSubmitting}
|
||||
onClick={onSubmit}
|
||||
/>
|
||||
</MainFlowLikeModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default LinkEmailModal;
|
|
@ -0,0 +1,8 @@
|
|||
.message {
|
||||
font: var(--font-body-2);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.link {
|
||||
text-decoration: none;
|
||||
}
|
|
@ -0,0 +1,135 @@
|
|||
import type { RequestErrorBody } from '@logto/schemas';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
import { HTTPError } from 'ky';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTimer } from 'react-timer-hook';
|
||||
|
||||
import ArrowConnection from '@/assets/images/arrow-connection.svg';
|
||||
import TextLink from '@/components/TextLink';
|
||||
import VerificationCodeInput, { defaultLength } from '@/components/VerificationCodeInput';
|
||||
import { adminTenantEndpoint, meApi } from '@/consts';
|
||||
import { useStaticApi } from '@/hooks/use-api';
|
||||
|
||||
import MainFlowLikeModal from '../../components/MainFlowLikeModal';
|
||||
import { checkLocationState } from '../../utils';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
export const resendTimeout = 59;
|
||||
|
||||
const getTimeout = () => {
|
||||
const now = new Date();
|
||||
now.setSeconds(now.getSeconds() + resendTimeout);
|
||||
|
||||
return now;
|
||||
};
|
||||
|
||||
const VerificationCodeModal = () => {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const navigate = useNavigate();
|
||||
const { state } = useLocation();
|
||||
const [code, setCode] = useState<string[]>([]);
|
||||
const [error, setError] = useState<string>();
|
||||
const api = useStaticApi({
|
||||
prefixUrl: adminTenantEndpoint,
|
||||
resourceIndicator: meApi.indicator,
|
||||
hideErrorToast: true,
|
||||
});
|
||||
const { email, action } = checkLocationState(state)
|
||||
? state
|
||||
: { email: undefined, action: undefined };
|
||||
|
||||
const { seconds, isRunning, restart } = useTimer({
|
||||
autoStart: true,
|
||||
expiryTimestamp: getTimeout(),
|
||||
});
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
navigate('/profile');
|
||||
}, [navigate]);
|
||||
|
||||
const onSubmit = useCallback(async () => {
|
||||
const verificationCode = code.join('');
|
||||
|
||||
if (!email || !verificationCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.post(`me/verification-codes/verify`, { json: { verificationCode, email } });
|
||||
|
||||
if (action === 'changeEmail') {
|
||||
await api.patch(`me/user`, { json: { primaryEmail: email } });
|
||||
|
||||
onClose();
|
||||
}
|
||||
|
||||
if (action === 'changePassword') {
|
||||
navigate('../change-password', { state });
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof HTTPError) {
|
||||
const logtoError = await error.response.json<RequestErrorBody>();
|
||||
setError(logtoError.message);
|
||||
} else {
|
||||
setError(String(error));
|
||||
}
|
||||
}
|
||||
}, [action, api, code, email, state, navigate, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (code.length === defaultLength && code.every(Boolean)) {
|
||||
void onSubmit();
|
||||
}
|
||||
}, [code, onSubmit]);
|
||||
|
||||
return (
|
||||
<MainFlowLikeModal
|
||||
title="profile.code.enter_verification_code"
|
||||
subtitle="profile.code.enter_verification_code_subtitle"
|
||||
subtitleProps={conditional(email && { target: email })}
|
||||
onClose={onClose}
|
||||
>
|
||||
<VerificationCodeInput
|
||||
name="verificationCode"
|
||||
value={code}
|
||||
error={error}
|
||||
onChange={(value) => {
|
||||
setCode(value);
|
||||
setError(undefined);
|
||||
}}
|
||||
/>
|
||||
{isRunning ? (
|
||||
<div className={styles.message}>
|
||||
{t('profile.code.resend_countdown', { countdown: seconds })}
|
||||
</div>
|
||||
) : (
|
||||
<TextLink
|
||||
className={styles.link}
|
||||
onClick={async () => {
|
||||
setCode([]);
|
||||
setError(undefined);
|
||||
await api.post(`me/verification-codes`, { json: { email } });
|
||||
restart(getTimeout(), true);
|
||||
}}
|
||||
>
|
||||
{t('profile.code.resend')}
|
||||
</TextLink>
|
||||
)}
|
||||
{action === 'changePassword' && (
|
||||
<TextLink
|
||||
className={styles.link}
|
||||
icon={<ArrowConnection />}
|
||||
onClick={() => {
|
||||
navigate('../verify-password', { state });
|
||||
}}
|
||||
>
|
||||
{t('profile.password.verify_via_password')}
|
||||
</TextLink>
|
||||
)}
|
||||
</MainFlowLikeModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerificationCodeModal;
|
|
@ -1,6 +1,7 @@
|
|||
import { conditional } from '@silverhand/essentials';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import ArrowConnection from '@/assets/images/arrow-connection.svg';
|
||||
import Button from '@/components/Button';
|
||||
|
@ -10,6 +11,7 @@ import { adminTenantEndpoint, meApi } from '@/consts';
|
|||
import { useStaticApi } from '@/hooks/use-api';
|
||||
|
||||
import MainFlowLikeModal from '../../components/MainFlowLikeModal';
|
||||
import { checkLocationState } from '../../utils';
|
||||
|
||||
type FormFields = {
|
||||
password: string;
|
||||
|
@ -18,6 +20,7 @@ type FormFields = {
|
|||
const VerifyPasswordModal = () => {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const navigate = useNavigate();
|
||||
const { state } = useLocation();
|
||||
const {
|
||||
register,
|
||||
reset,
|
||||
|
@ -28,6 +31,7 @@ const VerifyPasswordModal = () => {
|
|||
reValidateMode: 'onBlur',
|
||||
});
|
||||
const api = useStaticApi({ prefixUrl: adminTenantEndpoint, resourceIndicator: meApi.indicator });
|
||||
const email = conditional(checkLocationState(state) && state.email);
|
||||
|
||||
const onClose = () => {
|
||||
navigate('/profile');
|
||||
|
@ -36,9 +40,9 @@ const VerifyPasswordModal = () => {
|
|||
const onSubmit = () => {
|
||||
clearErrors();
|
||||
void handleSubmit(async ({ password }) => {
|
||||
await api.post(`me/password/verify`, { json: { password } }).json();
|
||||
await api.post(`me/password/verify`, { json: { password } });
|
||||
reset({});
|
||||
navigate('../change-password');
|
||||
navigate('../change-password', { state });
|
||||
})();
|
||||
};
|
||||
|
||||
|
@ -47,6 +51,7 @@ const VerifyPasswordModal = () => {
|
|||
title="profile.password.enter_password"
|
||||
subtitle="profile.password.enter_password_subtitle"
|
||||
onClose={onClose}
|
||||
onGoBack={onClose}
|
||||
>
|
||||
<TextInput
|
||||
{...register('password', {
|
||||
|
@ -71,7 +76,17 @@ const VerifyPasswordModal = () => {
|
|||
isLoading={isSubmitting}
|
||||
onClick={onSubmit}
|
||||
/>
|
||||
<TextLink icon={<ArrowConnection />}>{t('profile.code.verify_via_code')}</TextLink>
|
||||
{email && (
|
||||
<TextLink
|
||||
icon={<ArrowConnection />}
|
||||
onClick={() => {
|
||||
void api.post('me/verification-codes', { json: { email } });
|
||||
navigate('../verification-code', { state });
|
||||
}}
|
||||
>
|
||||
{t('profile.code.verify_via_code')}
|
||||
</TextLink>
|
||||
)}
|
||||
</MainFlowLikeModal>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -35,9 +35,9 @@ const Profile = () => {
|
|||
{
|
||||
label: 'profile.link_account.email',
|
||||
value: user.email,
|
||||
actionName: 'profile.link',
|
||||
actionName: 'profile.change',
|
||||
action: () => {
|
||||
console.log('link email');
|
||||
navigate('link-email', { state: { email: user.email, action: 'changeEmail' } });
|
||||
},
|
||||
},
|
||||
]}
|
||||
|
@ -53,7 +53,9 @@ const Profile = () => {
|
|||
value: '******',
|
||||
actionName: 'profile.change',
|
||||
action: () => {
|
||||
navigate('verify-password');
|
||||
navigate('verify-password', {
|
||||
state: { email: user.email, action: 'changePassword' },
|
||||
});
|
||||
},
|
||||
},
|
||||
]}
|
||||
|
|
13
packages/console/src/pages/Profile/utils.ts
Normal file
13
packages/console/src/pages/Profile/utils.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
export type LocationState = {
|
||||
email: string;
|
||||
action: 'changePassword' | 'changeEmail';
|
||||
};
|
||||
|
||||
export const checkLocationState = (state: unknown): state is LocationState =>
|
||||
typeof state === 'object' &&
|
||||
state !== null &&
|
||||
'email' in state &&
|
||||
'action' in state &&
|
||||
typeof state.email === 'string' &&
|
||||
typeof state.action === 'string' &&
|
||||
['changePassword', 'changeEmail'].includes(state.action);
|
|
@ -1,8 +1,6 @@
|
|||
import { VerificationCodeType } from '@logto/connector-kit';
|
||||
import {
|
||||
requestVerificationCodePayloadGuard,
|
||||
verifyVerificationCodePayloadGuard,
|
||||
} from '@logto/schemas';
|
||||
import { emailRegEx } from '@logto/core-kit';
|
||||
import { object, string } from 'zod';
|
||||
|
||||
import koaGuard from '#src/middleware/koa-guard.js';
|
||||
import type { RouterInitArgs } from '#src/routes/types.js';
|
||||
|
@ -20,7 +18,7 @@ export default function verificationCodeRoutes<T extends AuthedMeRouter>(
|
|||
router.post(
|
||||
'/verification-codes',
|
||||
koaGuard({
|
||||
body: requestVerificationCodePayloadGuard,
|
||||
body: object({ email: string().regex(emailRegEx) }),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const code = await createPasscode(undefined, codeType, ctx.guard.body);
|
||||
|
@ -35,7 +33,7 @@ export default function verificationCodeRoutes<T extends AuthedMeRouter>(
|
|||
router.post(
|
||||
'/verification-codes/verify',
|
||||
koaGuard({
|
||||
body: verifyVerificationCodePayloadGuard,
|
||||
body: object({ email: string().regex(emailRegEx), verificationCode: string().min(1) }),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { verificationCode, ...identifier } = ctx.guard.body;
|
||||
|
|
2
pnpm-lock.yaml
generated
2
pnpm-lock.yaml
generated
|
@ -226,6 +226,7 @@ importers:
|
|||
react-paginate: ^8.1.3
|
||||
react-router-dom: 6.3.0
|
||||
react-syntax-highlighter: ^15.5.0
|
||||
react-timer-hook: ^3.0.5
|
||||
recharts: ^2.1.13
|
||||
remark-gfm: ^3.0.1
|
||||
stylelint: ^15.0.0
|
||||
|
@ -298,6 +299,7 @@ importers:
|
|||
react-paginate: 8.1.3_react@18.2.0
|
||||
react-router-dom: 6.3.0_biqbaboplfbrettd7655fr4n2y
|
||||
react-syntax-highlighter: 15.5.0_react@18.2.0
|
||||
react-timer-hook: 3.0.5_biqbaboplfbrettd7655fr4n2y
|
||||
recharts: 2.1.13_v2m5e27vhdewzwhryxwfaorcca
|
||||
remark-gfm: 3.0.1
|
||||
stylelint: 15.0.0
|
||||
|
|
Loading…
Add table
Reference in a new issue