mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-18 02:26:21 +08:00
feat(core): support one time password (#9798)
This commit is contained in:
@@ -932,7 +932,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<p␊
|
||||
style="font-size:20px;line-height:28px;margin:24px 0 0;font-weight:600;font-family:Inter, Arial, Helvetica, sans-serif;color:#141414"␊
|
||||
>␊
|
||||
Sign in to AFFiNE␊
|
||||
Sign in to AFFiNE Cloud␊
|
||||
</p>␊
|
||||
</td>␊
|
||||
</tr>␊
|
||||
@@ -962,8 +962,44 @@ Generated by [AVA](https://avajs.dev).
|
||||
<p␊
|
||||
style="font-size:15px;line-height:24px;margin:24px 0 0;font-weight:400;font-family:Inter, Arial, Helvetica, sans-serif;color:#141414"␊
|
||||
>␊
|
||||
Click the button below to securely sign in. The magic link will␊
|
||||
expire in 30 minutes.␊
|
||||
You are signing in to AFFiNE. Here is your code:␊
|
||||
</p>␊
|
||||
</tr>␊
|
||||
</tbody>␊
|
||||
</table>␊
|
||||
<table␊
|
||||
align="center"␊
|
||||
width="100%"␊
|
||||
border="0"␊
|
||||
cellpadding="0"␊
|
||||
cellspacing="0"␊
|
||||
role="presentation"␊
|
||||
>␊
|
||||
<tbody style="width:100%">␊
|
||||
<tr style="width:100%">␊
|
||||
<pre␊
|
||||
style="font-size:15px;font-weight:400;line-height:24px;font-family:Inter, Arial, Helvetica, sans-serif;margin:24px 0 0;color:#141414;white-space:nowrap;border:1px solid rgba(0,0,0,.1);padding:8px 10px;border-radius:4px;background-color:#F5F5F5"␊
|
||||
>␊
|
||||
123456</pre␊
|
||||
>␊
|
||||
</tr>␊
|
||||
</tbody>␊
|
||||
</table>␊
|
||||
<table␊
|
||||
align="center"␊
|
||||
width="100%"␊
|
||||
border="0"␊
|
||||
cellpadding="0"␊
|
||||
cellspacing="0"␊
|
||||
role="presentation"␊
|
||||
>␊
|
||||
<tbody style="width:100%">␊
|
||||
<tr style="width:100%">␊
|
||||
<p␊
|
||||
style="font-size:15px;line-height:24px;margin:24px 0 0;font-weight:400;font-family:Inter, Arial, Helvetica, sans-serif;color:#141414"␊
|
||||
>␊
|
||||
Alternatively, you can sign in directly by clicking the magic␊
|
||||
link below:␊
|
||||
</p>␊
|
||||
</tr>␊
|
||||
</tbody>␊
|
||||
@@ -979,6 +1015,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<tbody style="width:100%">␊
|
||||
<tr style="width:100%">␊
|
||||
<a␊
|
||||
href="https://app.affine.pro/magic-link?token=123456&email=test@test.com"␊
|
||||
style="line-height:24px;text-decoration:none;display:inline-block;max-width:100%;mso-padding-alt:0px;font-size:15px;font-weight:600;font-family:Inter, Arial, Helvetica, sans-serif;margin:24px 0 0;color:#FFFFFF;background-color:#1E96EB;padding:8px 18px 8px 18px;border-radius:8px;border:1px solid rgba(0,0,0,.1);margin-right:4px"␊
|
||||
target="_blank"␊
|
||||
><span␊
|
||||
@@ -989,7 +1026,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
[endif]--></span␊
|
||||
><span␊
|
||||
style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:6px"␊
|
||||
>Sign in to AFFiNE</span␊
|
||||
>Sign in with Magic Link</span␊
|
||||
><span␊
|
||||
><!--[if mso␊
|
||||
]><i style="mso-font-width:450%" hidden␊
|
||||
@@ -1001,6 +1038,27 @@ Generated by [AVA](https://avajs.dev).
|
||||
</tr>␊
|
||||
</tbody>␊
|
||||
</table>␊
|
||||
<table␊
|
||||
align="center"␊
|
||||
width="100%"␊
|
||||
border="0"␊
|
||||
cellpadding="0"␊
|
||||
cellspacing="0"␊
|
||||
role="presentation"␊
|
||||
>␊
|
||||
<tbody style="width:100%">␊
|
||||
<tr style="width:100%">␊
|
||||
<p␊
|
||||
style="font-size:15px;line-height:24px;margin:24px 0 0;font-weight:400;font-family:Inter, Arial, Helvetica, sans-serif;color:#141414"␊
|
||||
>␊
|
||||
<span␊
|
||||
style="font-size:14px;font-weight:400;line-height:22px;font-family:Inter, Arial, Helvetica, sans-serif;margin:24px 0 0;color:#7A7A7A"␊
|
||||
>This code and link will expire in 30 minutes.</span␊
|
||||
>␊
|
||||
</p>␊
|
||||
</tr>␊
|
||||
</tbody>␊
|
||||
</table>␊
|
||||
</td>␊
|
||||
</tr>␊
|
||||
</tbody>␊
|
||||
@@ -1026,7 +1084,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<p␊
|
||||
style="font-size:20px;line-height:28px;margin:24px 0 0;font-weight:600;font-family:Inter, Arial, Helvetica, sans-serif;color:#141414"␊
|
||||
>␊
|
||||
Create AFFiNE Account␊
|
||||
Sign up to AFFiNE Cloud␊
|
||||
</p>␊
|
||||
</td>␊
|
||||
</tr>␊
|
||||
@@ -1056,9 +1114,44 @@ Generated by [AVA](https://avajs.dev).
|
||||
<p␊
|
||||
style="font-size:15px;line-height:24px;margin:24px 0 0;font-weight:400;font-family:Inter, Arial, Helvetica, sans-serif;color:#141414"␊
|
||||
>␊
|
||||
Click the button below to complete your account creation and␊
|
||||
sign in. This magic link will expire in␊
|
||||
<span style="font-weight:600">30 minutes</span>.␊
|
||||
You are signing up to AFFiNE. Here is your code:␊
|
||||
</p>␊
|
||||
</tr>␊
|
||||
</tbody>␊
|
||||
</table>␊
|
||||
<table␊
|
||||
align="center"␊
|
||||
width="100%"␊
|
||||
border="0"␊
|
||||
cellpadding="0"␊
|
||||
cellspacing="0"␊
|
||||
role="presentation"␊
|
||||
>␊
|
||||
<tbody style="width:100%">␊
|
||||
<tr style="width:100%">␊
|
||||
<pre␊
|
||||
style="font-size:15px;font-weight:400;line-height:24px;font-family:Inter, Arial, Helvetica, sans-serif;margin:24px 0 0;color:#141414;white-space:nowrap;border:1px solid rgba(0,0,0,.1);padding:8px 10px;border-radius:4px;background-color:#F5F5F5"␊
|
||||
>␊
|
||||
123456</pre␊
|
||||
>␊
|
||||
</tr>␊
|
||||
</tbody>␊
|
||||
</table>␊
|
||||
<table␊
|
||||
align="center"␊
|
||||
width="100%"␊
|
||||
border="0"␊
|
||||
cellpadding="0"␊
|
||||
cellspacing="0"␊
|
||||
role="presentation"␊
|
||||
>␊
|
||||
<tbody style="width:100%">␊
|
||||
<tr style="width:100%">␊
|
||||
<p␊
|
||||
style="font-size:15px;line-height:24px;margin:24px 0 0;font-weight:400;font-family:Inter, Arial, Helvetica, sans-serif;color:#141414"␊
|
||||
>␊
|
||||
Alternatively, you can sign up directly by clicking the magic␊
|
||||
link below:␊
|
||||
</p>␊
|
||||
</tr>␊
|
||||
</tbody>␊
|
||||
@@ -1074,7 +1167,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<tbody style="width:100%">␊
|
||||
<tr style="width:100%">␊
|
||||
<a␊
|
||||
href="https://app.affine.pro"␊
|
||||
href="https://app.affine.pro/magic-link?token=123456&email=test@test.com"␊
|
||||
style="line-height:24px;text-decoration:none;display:inline-block;max-width:100%;mso-padding-alt:0px;font-size:15px;font-weight:600;font-family:Inter, Arial, Helvetica, sans-serif;margin:24px 0 0;color:#FFFFFF;background-color:#1E96EB;padding:8px 18px 8px 18px;border-radius:8px;border:1px solid rgba(0,0,0,.1);margin-right:4px"␊
|
||||
target="_blank"␊
|
||||
><span␊
|
||||
@@ -1085,7 +1178,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
[endif]--></span␊
|
||||
><span␊
|
||||
style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:6px"␊
|
||||
>Create account and sign in</span␊
|
||||
>Sign up with Magic Link</span␊
|
||||
><span␊
|
||||
><!--[if mso␊
|
||||
]><i style="mso-font-width:450%" hidden␊
|
||||
@@ -1097,6 +1190,27 @@ Generated by [AVA](https://avajs.dev).
|
||||
</tr>␊
|
||||
</tbody>␊
|
||||
</table>␊
|
||||
<table␊
|
||||
align="center"␊
|
||||
width="100%"␊
|
||||
border="0"␊
|
||||
cellpadding="0"␊
|
||||
cellspacing="0"␊
|
||||
role="presentation"␊
|
||||
>␊
|
||||
<tbody style="width:100%">␊
|
||||
<tr style="width:100%">␊
|
||||
<p␊
|
||||
style="font-size:15px;line-height:24px;margin:24px 0 0;font-weight:400;font-family:Inter, Arial, Helvetica, sans-serif;color:#141414"␊
|
||||
>␊
|
||||
<span␊
|
||||
style="font-size:14px;font-weight:400;line-height:22px;font-family:Inter, Arial, Helvetica, sans-serif;margin:24px 0 0;color:#7A7A7A"␊
|
||||
>This code and link will expire in 30 minutes.</span␊
|
||||
>␊
|
||||
</p>␊
|
||||
</tr>␊
|
||||
</tbody>␊
|
||||
</table>␊
|
||||
</td>␊
|
||||
</tr>␊
|
||||
</tbody>␊
|
||||
|
||||
Binary file not shown.
@@ -140,7 +140,7 @@ test('should be able to handle unknown internal error in graphql query', async t
|
||||
t.is(err.message, 'An internal error occurred.');
|
||||
t.is(err.extensions.status, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
t.is(err.extensions.name, 'INTERNAL_SERVER_ERROR');
|
||||
t.true(t.context.logger.error.calledOnceWith('Internal server error'));
|
||||
t.true(t.context.logger.error.calledOnceWith('internal_server_error'));
|
||||
});
|
||||
|
||||
test('should be able to respond request', async t => {
|
||||
@@ -166,7 +166,7 @@ test('should be able to handle unknown internal error in http request', async t
|
||||
.expect(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
t.is(res.body.message, 'An internal error occurred.');
|
||||
t.is(res.body.name, 'INTERNAL_SERVER_ERROR');
|
||||
t.true(t.context.logger.error.calledOnceWith('Internal server error'));
|
||||
t.true(t.context.logger.error.calledOnceWith('internal_server_error'));
|
||||
});
|
||||
|
||||
// Hard to test through websocket, will call event handler directly
|
||||
@@ -196,5 +196,5 @@ test('should be able to handle unknown internal error in websocket event', async
|
||||
};
|
||||
t.is(error.message, 'An internal error occurred.');
|
||||
t.is(error.name, 'INTERNAL_SERVER_ERROR');
|
||||
t.true(t.context.logger.error.calledOnceWith('Internal server error'));
|
||||
t.true(t.context.logger.error.calledOnceWith('internal_server_error'));
|
||||
});
|
||||
|
||||
@@ -136,8 +136,12 @@ export class UserFriendlyError extends Error {
|
||||
return;
|
||||
}
|
||||
|
||||
new Logger(context).error(
|
||||
'Internal server error',
|
||||
const logger = new Logger(context);
|
||||
const fn = this.status >= 500 ? logger.error : logger.log;
|
||||
|
||||
fn.call(
|
||||
logger,
|
||||
this.name,
|
||||
this.cause ? ((this.cause as any).stack ?? this.cause) : this.stack
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
createSign,
|
||||
createVerify,
|
||||
randomBytes,
|
||||
randomInt,
|
||||
timingSafeEqual,
|
||||
} from 'node:crypto';
|
||||
|
||||
@@ -109,6 +110,20 @@ export class CryptoHelper {
|
||||
return randomBytes(length);
|
||||
}
|
||||
|
||||
randomInt(min: number, max: number) {
|
||||
return randomInt(min, max);
|
||||
}
|
||||
|
||||
otp(length = 6) {
|
||||
let otp = '';
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
otp += this.randomInt(0, 9).toString();
|
||||
}
|
||||
|
||||
return otp;
|
||||
}
|
||||
|
||||
sha256(data: string) {
|
||||
return createHash('sha256').update(data).digest();
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@ import {
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import {
|
||||
Cache,
|
||||
Config,
|
||||
CryptoHelper,
|
||||
EarlyAccessRequired,
|
||||
EmailTokenNotFound,
|
||||
InternalServerError,
|
||||
@@ -49,6 +51,8 @@ interface MagicLinkCredential {
|
||||
token: string;
|
||||
}
|
||||
|
||||
const OTP_CACHE_KEY = (otp: string) => `magic-link-otp:${otp}`;
|
||||
|
||||
@Throttle('strict')
|
||||
@Controller('/api/auth')
|
||||
export class AuthController {
|
||||
@@ -57,7 +61,9 @@ export class AuthController {
|
||||
private readonly auth: AuthService,
|
||||
private readonly models: Models,
|
||||
private readonly config: Config,
|
||||
private readonly runtime: Runtime
|
||||
private readonly runtime: Runtime,
|
||||
private readonly cache: Cache,
|
||||
private readonly crypto: CryptoHelper
|
||||
) {
|
||||
if (config.node.dev) {
|
||||
// set DNS servers in dev mode
|
||||
@@ -190,13 +196,20 @@ export class AuthController {
|
||||
}
|
||||
}
|
||||
|
||||
const ttlInSec = 30 * 60;
|
||||
const token = await this.models.verificationToken.create(
|
||||
TokenType.SignIn,
|
||||
email
|
||||
email,
|
||||
ttlInSec
|
||||
);
|
||||
|
||||
const otp = this.crypto.otp();
|
||||
// TODO(@forehalo): this is a temporary solution, we should not rely on cache to store the otp
|
||||
const cacheKey = OTP_CACHE_KEY(otp);
|
||||
await this.cache.set(cacheKey, token, { ttl: ttlInSec * 1000 });
|
||||
|
||||
const magicLink = this.url.link(callbackUrl, {
|
||||
token,
|
||||
token: otp,
|
||||
email,
|
||||
...(redirectUrl
|
||||
? {
|
||||
@@ -205,7 +218,12 @@ export class AuthController {
|
||||
: {}),
|
||||
});
|
||||
|
||||
const result = await this.auth.sendSignInEmail(email, magicLink, !user);
|
||||
const result = await this.auth.sendSignInEmail(
|
||||
email,
|
||||
magicLink,
|
||||
otp,
|
||||
!user
|
||||
);
|
||||
|
||||
if (result.rejected.length) {
|
||||
throw new InternalServerError('Failed to send sign-in email.');
|
||||
@@ -247,9 +265,16 @@ export class AuthController {
|
||||
|
||||
validators.assertValidEmail(email);
|
||||
|
||||
const cacheKey = OTP_CACHE_KEY(token);
|
||||
const cachedToken = await this.cache.get<string>(cacheKey);
|
||||
|
||||
if (!cachedToken) {
|
||||
throw new InvalidEmailToken();
|
||||
}
|
||||
|
||||
const tokenRecord = await this.models.verificationToken.verify(
|
||||
TokenType.SignIn,
|
||||
token,
|
||||
cachedToken,
|
||||
{
|
||||
credential: email,
|
||||
}
|
||||
|
||||
@@ -322,9 +322,14 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
});
|
||||
}
|
||||
|
||||
async sendSignInEmail(email: string, link: string, signUp: boolean) {
|
||||
async sendSignInEmail(
|
||||
email: string,
|
||||
link: string,
|
||||
otp: string,
|
||||
signUp: boolean
|
||||
) {
|
||||
return signUp
|
||||
? await this.mailer.sendSignUpMail(email, { url: link })
|
||||
: await this.mailer.sendSignInMail(email, { url: link });
|
||||
? await this.mailer.sendSignUpMail(email, { url: link, otp })
|
||||
: await this.mailer.sendSignInMail(email, { url: link, otp });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,21 @@ export function Text(props: PropsWithChildren) {
|
||||
return <span style={BasicTextStyle}>{props.children}</span>;
|
||||
}
|
||||
|
||||
export function SecondaryText(props: PropsWithChildren) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
...BasicTextStyle,
|
||||
color: '#7A7A7A',
|
||||
fontSize: '14px',
|
||||
lineHeight: '22px',
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Bold(props: PropsWithChildren) {
|
||||
return <span style={{ fontWeight: 600 }}>{props.children}</span>;
|
||||
}
|
||||
@@ -70,6 +85,23 @@ export const Avatar = (props: {
|
||||
);
|
||||
};
|
||||
|
||||
export const OnelineCodeBlock = (props: PropsWithChildren) => {
|
||||
return (
|
||||
<pre
|
||||
style={{
|
||||
...BasicTextStyle,
|
||||
whiteSpace: 'nowrap',
|
||||
border: '1px solid rgba(0,0,0,.1)',
|
||||
padding: '8px 10px',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: '#F5F5F5',
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</pre>
|
||||
);
|
||||
};
|
||||
|
||||
export const Name = (props: PropsWithChildren) => {
|
||||
return <Bold>{props.children}</Bold>;
|
||||
};
|
||||
|
||||
@@ -1,20 +1,41 @@
|
||||
import { Button, Content, P, Template, Title } from '../components';
|
||||
import {
|
||||
Button,
|
||||
Content,
|
||||
OnelineCodeBlock,
|
||||
P,
|
||||
SecondaryText,
|
||||
Template,
|
||||
Title,
|
||||
} from '../components';
|
||||
|
||||
export type SignInProps = {
|
||||
url: string;
|
||||
otp: string;
|
||||
};
|
||||
|
||||
export default function SignUp(props: SignInProps) {
|
||||
export default function SignIn(props: SignInProps) {
|
||||
return (
|
||||
<Template>
|
||||
<Title>Sign in to AFFiNE</Title>
|
||||
<Title>Sign in to AFFiNE Cloud</Title>
|
||||
<Content>
|
||||
<P>You are signing in to AFFiNE. Here is your code:</P>
|
||||
<OnelineCodeBlock>{props.otp}</OnelineCodeBlock>
|
||||
<P>
|
||||
Click the button below to securely sign in. The magic link will expire
|
||||
in 30 minutes.
|
||||
Alternatively, you can sign in directly by clicking the magic link
|
||||
below:
|
||||
</P>
|
||||
<Button href={props.url}>Sign in with Magic Link</Button>
|
||||
<P>
|
||||
<SecondaryText>
|
||||
This code and link will expire in 30 minutes.
|
||||
</SecondaryText>
|
||||
</P>
|
||||
<Button href={props.url}>Sign in to AFFiNE</Button>
|
||||
</Content>
|
||||
</Template>
|
||||
);
|
||||
}
|
||||
|
||||
SignIn.PreviewProps = {
|
||||
url: 'https://app.affine.pro/magic-link?token=123456&email=test@test.com',
|
||||
otp: '123456',
|
||||
};
|
||||
|
||||
@@ -1,24 +1,41 @@
|
||||
import { Bold, Button, Content, P, Template, Title } from '../components';
|
||||
import {
|
||||
Button,
|
||||
Content,
|
||||
OnelineCodeBlock,
|
||||
P,
|
||||
SecondaryText,
|
||||
Template,
|
||||
Title,
|
||||
} from '../components';
|
||||
|
||||
export type SignUpProps = {
|
||||
url: string;
|
||||
otp: string;
|
||||
};
|
||||
|
||||
export default function SignUp(props: SignUpProps) {
|
||||
return (
|
||||
<Template>
|
||||
<Title>Create AFFiNE Account</Title>
|
||||
<Title>Sign up to AFFiNE Cloud</Title>
|
||||
<Content>
|
||||
<P>You are signing up to AFFiNE. Here is your code:</P>
|
||||
<OnelineCodeBlock>{props.otp}</OnelineCodeBlock>
|
||||
<P>
|
||||
Click the button below to complete your account creation and sign in.
|
||||
This magic link will expire in <Bold>30 minutes</Bold>.
|
||||
Alternatively, you can sign up directly by clicking the magic link
|
||||
below:
|
||||
</P>
|
||||
<Button href={props.url}>Sign up with Magic Link</Button>
|
||||
<P>
|
||||
<SecondaryText>
|
||||
This code and link will expire in 30 minutes.
|
||||
</SecondaryText>
|
||||
</P>
|
||||
<Button href={props.url}>Create account and sign in</Button>
|
||||
</Content>
|
||||
</Template>
|
||||
);
|
||||
}
|
||||
|
||||
SignUp.PreviewProps = {
|
||||
url: 'https://app.affine.pro',
|
||||
url: 'https://app.affine.pro/magic-link?token=123456&email=test@test.com',
|
||||
otp: '123456',
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user