feat(core): support one time password (#9798)

This commit is contained in:
forehalo
2025-01-22 07:33:09 +00:00
parent bf797c7a0c
commit 5828eb53b6
16 changed files with 362 additions and 131 deletions

View File

@@ -1,25 +0,0 @@
import type { HTMLAttributes } from 'react';
import { forwardRef } from 'react';
const formatTime = (time: number): string => {
const minutes = Math.floor(time / 60);
const seconds = time % 60;
const formattedMinutes = minutes.toString().padStart(2, '0');
const formattedSeconds = seconds.toString().padStart(2, '0');
return `${formattedMinutes}:${formattedSeconds}`;
};
export const CountDownRender = forwardRef<
HTMLDivElement,
{ timeLeft: number } & HTMLAttributes<HTMLDivElement>
>(({ timeLeft, ...props }, ref) => {
return (
<div {...props} ref={ref}>
{formatTime(timeLeft)}
</div>
);
});
CountDownRender.displayName = 'CountDownRender';

View File

@@ -4,7 +4,6 @@ export * from './auth-page-container';
export * from './back-button';
export * from './change-email-page';
export * from './change-password-page';
export * from './count-down-render';
export * from './modal-header';
export * from './onboarding-page';
export * from './password-input';

View File

@@ -1,13 +1,17 @@
import { notify } from '@affine/component';
import {
AuthContent,
AuthInput,
BackButton,
CountDownRender,
ModalHeader,
} from '@affine/component/auth-components';
import { Button } from '@affine/component/ui/button';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { AuthService, CaptchaService } from '@affine/core/modules/cloud';
import {
AuthService,
BackendError,
CaptchaService,
} from '@affine/core/modules/cloud';
import type { AuthSessionStatus } from '@affine/core/modules/cloud/entities/session';
import { Unreachable } from '@affine/env/constant';
import { Trans, useI18n } from '@affine/i18n';
@@ -54,6 +58,9 @@ export const SignInWithEmailStep = ({
}, []);
const [isSending, setIsSending] = useState(false);
const [isVerifying, setIsVerifying] = useState(false);
const [otp, setOtp] = useState('');
const [otpError, setOtpError] = useState<string | undefined>();
const t = useI18n();
const authService = useService(AuthService);
@@ -120,6 +127,35 @@ export const SignInWithEmailStep = ({
changeState(prev => ({ ...prev, step: 'signIn' }));
}, [changeState]);
const onOtpChanged = useCallback((value: string) => {
setOtp(value);
setOtpError(undefined);
}, []);
const onContinue = useAsyncCallback(async () => {
if (isVerifying) return;
if (otp.length !== 6 || !/[0-9]{6}/.test(otp)) {
setOtpError(t['com.affine.auth.sign.auth.code.invalid']());
return;
}
setIsVerifying(true);
try {
await authService.signInMagicLink(email, otp);
} catch (e) {
if (e instanceof BackendError) {
notify.error({
title: e.originError.message,
});
setOtpError(t['com.affine.auth.sign.auth.code.invalid']());
}
} finally {
setIsVerifying(false);
}
}, [authService, email, isVerifying, otp, t]);
return !verifyToken && needCaptcha ? (
<>
<ModalHeader title={t['com.affine.auth.sign.in']()} />
@@ -136,34 +172,50 @@ export const SignInWithEmailStep = ({
/>
<AuthContent style={{ height: 100 }}>
<Trans
i18nKey="com.affine.auth.sign.sent.email.message.sent-tips"
i18nKey="com.affine.auth.sign.auth.code.hint"
values={{ email }}
components={{ a: <a href={`mailto:${email}`} /> }}
/>
{t['com.affine.auth.sign.sent.email.message.sent-tips.sign-in']()}
</AuthContent>
<div className={style.resendWrapper}>
{resendCountDown <= 0 ? (
<Button
disabled={isSending}
variant="plain"
size="large"
onClick={sendEmail}
>
{t['com.affine.auth.sign.auth.code.resend.hint']()}
</Button>
) : (
<div className={style.sentRow}>
<div className={style.sentMessage}>
{t['com.affine.auth.sent']()}
</div>
<CountDownRender
className={style.resendCountdown}
timeLeft={resendCountDown}
<AuthInput
placeholder={t['com.affine.auth.sign.auth.code']()}
onChange={onOtpChanged}
error={!!otpError}
errorHint={otpError}
onEnter={onContinue}
type="text"
required={true}
maxLength={6}
/>
<Button
style={{ width: '100%' }}
data-testid="continue-code-button"
size="extraLarge"
block={true}
onClick={onContinue}
disabled={!!otpError || isVerifying}
loading={isVerifying}
>
{t['com.affine.auth.sign.auth.code.continue']()}
</Button>
<div className={style.resendButtonWrapper}>
<Button
disabled={resendCountDown > 0}
variant="plain"
onClick={sendEmail}
>
{resendCountDown <= 0 ? (
t['com.affine.auth.sign.auth.code.resend']()
) : (
<Trans
i18nKey="com.affine.auth.sign.auth.code.resend.hint"
values={{ second: resendCountDown }}
/>
</div>
)}
)}
</Button>
</div>
<div className={style.authMessage} style={{ marginTop: 20 }}>

View File

@@ -15,6 +15,7 @@ export const authMessage = style({
globalStyle(`${authMessage} a`, {
color: cssVar('linkColor'),
});
globalStyle(`${authMessage} .link`, {
cursor: 'pointer',
color: cssVar('linkColor'),
@@ -41,31 +42,8 @@ export const linkButton = style({
userSelect: 'none',
});
export const resendWrapper = style({
height: 77,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
marginTop: 30,
});
export const sentRow = style({
display: 'flex',
justifyContent: 'center',
gap: '8px',
lineHeight: '22px',
fontSize: cssVar('fontSm'),
});
export const sentMessage = style({
color: cssVar('textPrimaryColor'),
fontWeight: 600,
});
export const resendCountdown = style({
width: 45,
textAlign: 'center',
export const resendButtonWrapper = style({
marginTop: 20,
});
export const addSelfhostedButton = style({

View File

@@ -1061,9 +1061,15 @@ export function useAFFiNEI18N(): {
*/
["com.affine.auth.send.verify.email.hint"](): string;
/**
* `Sent`
* `Resend code`
*/
["com.affine.auth.sent"](): string;
["com.affine.auth.sign.auth.code.resend"](): string;
/**
* `Resend in {{second}}s`
*/
["com.affine.auth.sign.auth.code.resend.hint"](options: {
readonly second: string;
}): string;
/**
* `The verification link failed to be sent, please try again later.`
*/
@@ -1161,10 +1167,6 @@ export function useAFFiNEI18N(): {
* `If you haven't received the email, please check your spam folder.`
*/
["com.affine.auth.sign.auth.code.message"](): string;
/**
* `Resend link`
*/
["com.affine.auth.sign.auth.code.resend.hint"](): string;
/**
* `Sign in with magic link`
*/
@@ -1217,14 +1219,6 @@ export function useAFFiNEI18N(): {
* ` You can click the link to create an account automatically.`
*/
["com.affine.auth.sign.sent.email.message.end"](): string;
/**
* `You can click the link to sign in automatically.`
*/
["com.affine.auth.sign.sent.email.message.sent-tips.sign-in"](): string;
/**
* `You can click the link to create an account automatically.`
*/
["com.affine.auth.sign.sent.email.message.sent-tips.sign-up"](): string;
/**
* `Sign up`
*/
@@ -6660,7 +6654,7 @@ export const TypedTrans: {
["3"]: JSX.Element;
}>>;
/**
* `An email with a magic link has been sent to <a>{{email}}</a>.`
* `To continue signing in, please enter the code that was sent to <a>{{email}}</a>.`
*/
["com.affine.auth.sign.sent.email.message.sent-tips"]: ComponentType<TypedTransProps<{
readonly email: string;

View File

@@ -256,7 +256,12 @@
"com.affine.auth.send.reset.password.link": "Send reset link",
"com.affine.auth.send.set.password.link": "Send set link",
"com.affine.auth.send.verify.email.hint": "Send verification link",
"com.affine.auth.sent": "Sent",
"com.affine.auth.sign.auth.code": "Verification code",
"com.affine.auth.sign.auth.code.hint": "To continue signing in, please enter the code that was sent to <a>{{email}}</a>.",
"com.affine.auth.sign.auth.code.invalid": "Invalid verification code",
"com.affine.auth.sign.auth.code.continue": "Continue with code",
"com.affine.auth.sign.auth.code.resend": "Resend code",
"com.affine.auth.sign.auth.code.resend.hint": "Resend in {{second}}s",
"com.affine.auth.sent.change.email.fail": "The verification link failed to be sent, please try again later.",
"com.affine.auth.sent.change.email.hint": "Verification link has been sent.",
"com.affine.auth.sent.change.password.hint": "Reset password link has been sent.",
@@ -280,7 +285,6 @@
"com.affine.auth.sign-out.confirm-modal.title": "Sign out?",
"com.affine.auth.sign.auth.code.message": "If you haven't received the email, please check your spam folder.",
"com.affine.auth.sign.auth.code.message.password": "Or <1>sign in with password</1> instead.",
"com.affine.auth.sign.auth.code.resend.hint": "Resend link",
"com.affine.auth.sign.auth.code.send-email.sign-in": "Sign in with magic link",
"com.affine.auth.sign.condition": "Terms of conditions",
"com.affine.auth.sign.email.continue": "Continue with email",
@@ -295,10 +299,6 @@
"com.affine.auth.sign.add-selfhosted.description": "The Self-Hosted instance is not hosted or deployed by AFFiNE. Your data will be stored on these instances. <1>Learn more about Self-Host details.</1>",
"com.affine.auth.sign.message": "By clicking “Continue with Google/Email” above, you acknowledge that you agree to AFFiNE's <1>Terms of Conditions</1> and <3>Privacy Policy</3>.",
"com.affine.auth.sign.policy": "Privacy policy",
"com.affine.auth.sign.sent.email.message.end": " You can click the link to create an account automatically.",
"com.affine.auth.sign.sent.email.message.sent-tips": "An email with a magic link has been sent to <a>{{email}}</a>.",
"com.affine.auth.sign.sent.email.message.sent-tips.sign-in": "You can click the link to sign in automatically.",
"com.affine.auth.sign.sent.email.message.sent-tips.sign-up": "You can click the link to create an account automatically.",
"com.affine.auth.sign.up": "Sign up",
"com.affine.auth.sign.up.sent.email.subtitle": "Create your account",
"com.affine.auth.sign.up.success.subtitle": "The app will automatically open or redirect to the web version. If you encounter any issues, you can also click the button below to manually open the AFFiNE app.",