mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
fix: sign in issues (#4047)
Co-authored-by: Alex Yang <himself65@outlook.com>
This commit is contained in:
@@ -1,23 +1,39 @@
|
||||
import {
|
||||
AuthContent,
|
||||
BackButton,
|
||||
CountDownRender,
|
||||
ModalHeader,
|
||||
ResendButton,
|
||||
} from '@affine/component/auth-components';
|
||||
import { Trans } from '@affine/i18n';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { type FC, useCallback } from 'react';
|
||||
import { Button } from '@toeverything/components/button';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { signInCloud } from '../../../utils/cloud-utils';
|
||||
import { buildCallbackUrl } from './callback-url';
|
||||
import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status';
|
||||
import type { AuthPanelProps } from './index';
|
||||
import * as style from './style.css';
|
||||
import { useAuth } from './use-auth';
|
||||
|
||||
export const AfterSignInSendEmail: FC<AuthPanelProps> = ({
|
||||
export const AfterSignInSendEmail = ({
|
||||
setAuthState,
|
||||
email,
|
||||
}) => {
|
||||
onSignedIn,
|
||||
}: AuthPanelProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const loginStatus = useCurrentLoginStatus();
|
||||
|
||||
const { resendCountDown, allowSendEmail, signIn } = useAuth({
|
||||
onNoAccess: useCallback(() => {
|
||||
setAuthState('noAccess');
|
||||
}, [setAuthState]),
|
||||
});
|
||||
if (loginStatus === 'authenticated') {
|
||||
onSignedIn?.();
|
||||
}
|
||||
|
||||
const onResendClick = useCallback(async () => {
|
||||
await signIn(email);
|
||||
}, [email, signIn]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -31,15 +47,23 @@ export const AfterSignInSendEmail: FC<AuthPanelProps> = ({
|
||||
{t['com.affine.auth.sign.sent.email.message.end']()}
|
||||
</AuthContent>
|
||||
|
||||
<ResendButton
|
||||
onClick={useCallback(() => {
|
||||
signInCloud('email', {
|
||||
email,
|
||||
callbackUrl: buildCallbackUrl('/auth/signIn'),
|
||||
redirect: true,
|
||||
}).catch(console.error);
|
||||
}, [email])}
|
||||
<div className={style.resendWrapper}>
|
||||
{allowSendEmail ? (
|
||||
<Button type="plain" size="large" onClick={onResendClick}>
|
||||
{t['com.affine.auth.sign.auth.code.resend.hint']()}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<span className="resend-code-hint">
|
||||
{t['com.affine.auth.sign.auth.code.on.resend.hint']()}
|
||||
</span>
|
||||
<CountDownRender
|
||||
className={style.resendCountdown}
|
||||
timeLeft={resendCountDown}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={style.authMessage} style={{ marginTop: 20 }}>
|
||||
{/*prettier-ignore*/}
|
||||
|
||||
@@ -1,22 +1,39 @@
|
||||
import {
|
||||
AuthContent,
|
||||
BackButton,
|
||||
CountDownRender,
|
||||
ModalHeader,
|
||||
ResendButton,
|
||||
} from '@affine/component/auth-components';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { Button } from '@toeverything/components/button';
|
||||
import { type FC, useCallback } from 'react';
|
||||
|
||||
import { signInCloud } from '../../../utils/cloud-utils';
|
||||
import { buildCallbackUrl } from './callback-url';
|
||||
import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status';
|
||||
import type { AuthPanelProps } from './index';
|
||||
import * as style from './style.css';
|
||||
import { useAuth } from './use-auth';
|
||||
|
||||
export const AfterSignUpSendEmail: FC<AuthPanelProps> = ({
|
||||
setAuthState,
|
||||
email,
|
||||
onSignedIn,
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const loginStatus = useCurrentLoginStatus();
|
||||
|
||||
const { resendCountDown, allowSendEmail, signUp } = useAuth({
|
||||
onNoAccess: useCallback(() => {
|
||||
setAuthState('noAccess');
|
||||
}, [setAuthState]),
|
||||
});
|
||||
|
||||
if (loginStatus === 'authenticated') {
|
||||
onSignedIn?.();
|
||||
}
|
||||
|
||||
const onResendClick = useCallback(async () => {
|
||||
await signUp(email);
|
||||
}, [email, signUp]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -30,15 +47,23 @@ export const AfterSignUpSendEmail: FC<AuthPanelProps> = ({
|
||||
{t['com.affine.auth.sign.sent.email.message.end']()}
|
||||
</AuthContent>
|
||||
|
||||
<ResendButton
|
||||
onClick={useCallback(() => {
|
||||
signInCloud('email', {
|
||||
email: email,
|
||||
callbackUrl: buildCallbackUrl('/auth/signUp'),
|
||||
redirect: true,
|
||||
}).catch(console.error);
|
||||
}, [email])}
|
||||
<div className={style.resendWrapper}>
|
||||
{allowSendEmail ? (
|
||||
<Button type="plain" size="large" onClick={onResendClick}>
|
||||
{t['com.affine.auth.sign.auth.code.resend.hint']()}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<span className="resend-code-hint">
|
||||
{t['com.affine.auth.sign.auth.code.on.resend.hint']()}
|
||||
</span>
|
||||
<CountDownRender
|
||||
className={style.resendCountdown}
|
||||
timeLeft={resendCountDown}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={style.authMessage} style={{ marginTop: 20 }}>
|
||||
{t['com.affine.auth.sign.auth.code.message']()}
|
||||
|
||||
@@ -3,17 +3,12 @@ import {
|
||||
type AuthModalProps as AuthModalBaseProps,
|
||||
} from '@affine/component/auth-components';
|
||||
import { refreshRootMetadataAtom } from '@affine/workspace/atom';
|
||||
import { atom, useAtom, useSetAtom } from 'jotai';
|
||||
import {
|
||||
type FC,
|
||||
startTransition,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { type FC, startTransition, useCallback, useMemo } from 'react';
|
||||
|
||||
import { AfterSignInSendEmail } from './after-sign-in-send-email';
|
||||
import { AfterSignUpSendEmail } from './after-sign-up-send-email';
|
||||
import { NoAccess } from './no-access';
|
||||
import { SendEmail } from './send-email';
|
||||
import { SignIn } from './sign-in';
|
||||
import { SignInWithPassword } from './sign-in-with-password';
|
||||
@@ -25,7 +20,8 @@ export type AuthProps = {
|
||||
| 'afterSignInSendEmail'
|
||||
// throw away
|
||||
| 'signInWithPassword'
|
||||
| 'sendEmail';
|
||||
| 'sendEmail'
|
||||
| 'noAccess';
|
||||
setAuthState: (state: AuthProps['state']) => void;
|
||||
setAuthEmail: (state: AuthProps['email']) => void;
|
||||
setEmailType: (state: AuthProps['emailType']) => void;
|
||||
@@ -41,8 +37,6 @@ export type AuthPanelProps = {
|
||||
setEmailType: AuthProps['setEmailType'];
|
||||
emailType: AuthProps['emailType'];
|
||||
onSignedIn?: () => void;
|
||||
authStore: AuthStoreAtom;
|
||||
setAuthStore: (data: Partial<AuthStoreAtom>) => void;
|
||||
};
|
||||
|
||||
const config: {
|
||||
@@ -53,17 +47,9 @@ const config: {
|
||||
afterSignInSendEmail: AfterSignInSendEmail,
|
||||
signInWithPassword: SignInWithPassword,
|
||||
sendEmail: SendEmail,
|
||||
noAccess: NoAccess,
|
||||
};
|
||||
|
||||
type AuthStoreAtom = {
|
||||
hasSentEmail: boolean;
|
||||
resendCountDown: number;
|
||||
};
|
||||
export const authStoreAtom = atom<AuthStoreAtom>({
|
||||
hasSentEmail: false,
|
||||
resendCountDown: 60,
|
||||
});
|
||||
|
||||
export const AuthModal: FC<AuthModalBaseProps & AuthProps> = ({
|
||||
open,
|
||||
state,
|
||||
@@ -74,18 +60,6 @@ export const AuthModal: FC<AuthModalBaseProps & AuthProps> = ({
|
||||
setEmailType,
|
||||
emailType,
|
||||
}) => {
|
||||
const [, setAuthStore] = useAtom(authStoreAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setAuthStore({
|
||||
hasSentEmail: false,
|
||||
resendCountDown: 60,
|
||||
});
|
||||
setAuthEmail('');
|
||||
}
|
||||
}, [open, setAuthEmail, setAuthStore]);
|
||||
|
||||
const refreshMetadata = useSetAtom(refreshRootMetadataAtom);
|
||||
|
||||
const onSignedIn = useCallback(() => {
|
||||
@@ -119,39 +93,18 @@ export const AuthPanel: FC<AuthProps> = ({
|
||||
emailType,
|
||||
onSignedIn,
|
||||
}) => {
|
||||
const [authStore, setAuthStore] = useAtom(authStoreAtom);
|
||||
|
||||
const CurrentPanel = useMemo(() => {
|
||||
return config[state];
|
||||
}, [state]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setAuthStore({
|
||||
hasSentEmail: false,
|
||||
resendCountDown: 60,
|
||||
});
|
||||
};
|
||||
}, [setAuthEmail, setAuthStore]);
|
||||
|
||||
return (
|
||||
<CurrentPanel
|
||||
email={email}
|
||||
setAuthState={setAuthState}
|
||||
setAuthEmail={setAuthEmail}
|
||||
setEmailType={setEmailType}
|
||||
authStore={authStore}
|
||||
emailType={emailType}
|
||||
onSignedIn={onSignedIn}
|
||||
setAuthStore={useCallback(
|
||||
(data: Partial<AuthStoreAtom>) => {
|
||||
setAuthStore(prev => ({
|
||||
...prev,
|
||||
...data,
|
||||
}));
|
||||
},
|
||||
[setAuthStore]
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
53
apps/core/src/components/affine/auth/no-access.tsx
Normal file
53
apps/core/src/components/affine/auth/no-access.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
AuthContent,
|
||||
BackButton,
|
||||
ModalHeader,
|
||||
} from '@affine/component/auth-components';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { NewIcon } from '@blocksuite/icons';
|
||||
import { type FC, useCallback } from 'react';
|
||||
|
||||
import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status';
|
||||
import type { AuthPanelProps } from './index';
|
||||
import * as style from './style.css';
|
||||
|
||||
export const NoAccess: FC<AuthPanelProps> = ({ setAuthState, onSignedIn }) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const loginStatus = useCurrentLoginStatus();
|
||||
|
||||
if (loginStatus === 'authenticated') {
|
||||
onSignedIn?.();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={t['AFFiNE Cloud']()}
|
||||
subTitle={t['Early Access Stage']()}
|
||||
/>
|
||||
<AuthContent style={{ height: 162 }}>
|
||||
{t['com.affine.auth.sign.no.access.hint']()}
|
||||
<a href="https://community.affine.pro/c/insider-general/">
|
||||
{t['com.affine.auth.sign.no.access.link']()}
|
||||
</a>
|
||||
</AuthContent>
|
||||
|
||||
<div className={style.accessMessage}>
|
||||
<NewIcon
|
||||
style={{
|
||||
fontSize: 16,
|
||||
marginRight: 4,
|
||||
color: 'var(--affine-icon-color)',
|
||||
}}
|
||||
/>
|
||||
{t['com.affine.auth.sign.no.access.wait']()}
|
||||
</div>
|
||||
|
||||
<BackButton
|
||||
onClick={useCallback(() => {
|
||||
setAuthState('signIn');
|
||||
}, [setAuthState])}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -16,7 +16,7 @@ import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { useMutation } from '@affine/workspace/affine/gql';
|
||||
import { Button } from '@toeverything/components/button';
|
||||
import { useSetAtom } from 'jotai/react';
|
||||
import { type FC, useCallback } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import type { AuthPanelProps } from './index';
|
||||
|
||||
@@ -118,14 +118,13 @@ const useSendEmail = (emailType: AuthPanelProps['emailType']) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const SendEmail: FC<AuthPanelProps> = ({
|
||||
export const SendEmail = ({
|
||||
setAuthState,
|
||||
setAuthStore,
|
||||
email,
|
||||
authStore: { hasSentEmail },
|
||||
emailType,
|
||||
}) => {
|
||||
}: AuthPanelProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [hasSentEmail, setHasSentEmail] = useState(false);
|
||||
const pushNotification = useSetAtom(pushNotificationAtom);
|
||||
|
||||
const title = useEmailTitle(emailType);
|
||||
@@ -143,8 +142,8 @@ export const SendEmail: FC<AuthPanelProps> = ({
|
||||
key: Date.now().toString(),
|
||||
type: 'success',
|
||||
});
|
||||
setAuthStore({ hasSentEmail: true });
|
||||
}, [email, hint, pushNotification, sendEmail, setAuthStore]);
|
||||
setHasSentEmail(true);
|
||||
}, [email, hint, pushNotification, sendEmail]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,62 +1,52 @@
|
||||
import { AuthInput, ModalHeader } from '@affine/component/auth-components';
|
||||
import { pushNotificationAtom } from '@affine/component/notification-center';
|
||||
import type { Notification } from '@affine/component/notification-center/index.jotai';
|
||||
import { isDesktop } from '@affine/env/constant';
|
||||
import {
|
||||
AuthInput,
|
||||
CountDownRender,
|
||||
ModalHeader,
|
||||
} from '@affine/component/auth-components';
|
||||
import { getUserQuery } from '@affine/graphql';
|
||||
import { Trans } from '@affine/i18n';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { useMutation } from '@affine/workspace/affine/gql';
|
||||
import { ArrowDownBigIcon, GoogleDuotoneIcon } from '@blocksuite/icons';
|
||||
import { Button } from '@toeverything/components/button';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { type SignInResponse } from 'next-auth/react';
|
||||
import { type FC, useState } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { signInCloud } from '../../../utils/cloud-utils';
|
||||
import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status';
|
||||
import { emailRegex } from '../../../utils/email-regex';
|
||||
import { buildCallbackUrl } from './callback-url';
|
||||
import type { AuthPanelProps } from './index';
|
||||
import * as style from './style.css';
|
||||
import { useAuth } from './use-auth';
|
||||
|
||||
function validateEmail(email: string) {
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
const INTERNAL_BETA_URL = `https://community.affine.pro/c/insider-general/`;
|
||||
|
||||
function handleSendEmailError(
|
||||
res: SignInResponse | undefined,
|
||||
pushNotification: (notification: Notification) => void
|
||||
) {
|
||||
if (res?.error) {
|
||||
pushNotification({
|
||||
title: 'Send email error',
|
||||
message: 'Please back to home and try again',
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
if (res?.status === 403 && res?.url === INTERNAL_BETA_URL) {
|
||||
pushNotification({
|
||||
title: 'Sign up error',
|
||||
message: `You don't have early access permission\nVisit ${INTERNAL_BETA_URL} for more information`,
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const SignIn: FC<AuthPanelProps> = ({
|
||||
setAuthState,
|
||||
setAuthEmail,
|
||||
email,
|
||||
onSignedIn,
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const loginStatus = useCurrentLoginStatus();
|
||||
|
||||
const { resendCountDown, allowSendEmail, signIn, signUp, signInWithGoogle } =
|
||||
useAuth({
|
||||
onNoAccess: useCallback(() => {
|
||||
setAuthState('noAccess');
|
||||
}, [setAuthState]),
|
||||
});
|
||||
|
||||
const { trigger: verifyUser, isMutating } = useMutation({
|
||||
mutation: getUserQuery,
|
||||
});
|
||||
const [isValidEmail, setIsValidEmail] = useState(true);
|
||||
const pushNotification = useSetAtom(pushNotificationAtom);
|
||||
|
||||
if (loginStatus === 'authenticated') {
|
||||
onSignedIn?.();
|
||||
}
|
||||
|
||||
const onContinue = useCallback(async () => {
|
||||
if (!validateEmail(email)) {
|
||||
setIsValidEmail(false);
|
||||
@@ -68,26 +58,16 @@ export const SignIn: FC<AuthPanelProps> = ({
|
||||
|
||||
setAuthEmail(email);
|
||||
if (user) {
|
||||
signInCloud('email', {
|
||||
email: email,
|
||||
callbackUrl: buildCallbackUrl('/auth/signIn'),
|
||||
redirect: false,
|
||||
})
|
||||
.then(res => handleSendEmailError(res, pushNotification))
|
||||
.catch(console.error);
|
||||
setAuthState('afterSignInSendEmail');
|
||||
} else {
|
||||
signInCloud('email', {
|
||||
email: email,
|
||||
callbackUrl: buildCallbackUrl('/auth/signUp'),
|
||||
redirect: false,
|
||||
})
|
||||
.then(res => handleSendEmailError(res, pushNotification))
|
||||
.catch(console.error);
|
||||
|
||||
await signIn(email);
|
||||
} else {
|
||||
setAuthState('afterSignUpSendEmail');
|
||||
|
||||
await signUp(email);
|
||||
}
|
||||
}, [email, setAuthEmail, setAuthState, verifyUser, pushNotification]);
|
||||
}, [email, setAuthEmail, setAuthState, signIn, signUp, verifyUser]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalHeader
|
||||
@@ -103,18 +83,9 @@ export const SignIn: FC<AuthPanelProps> = ({
|
||||
marginTop: 30,
|
||||
}}
|
||||
icon={<GoogleDuotoneIcon />}
|
||||
onClick={useCallback(() => {
|
||||
if (isDesktop) {
|
||||
open(
|
||||
`/desktop-signin?provider=google&callback_url=${buildCallbackUrl(
|
||||
'/open-app/oauth-jwt'
|
||||
)}`,
|
||||
'_target'
|
||||
);
|
||||
} else {
|
||||
signInCloud('google').catch(console.error);
|
||||
}
|
||||
}, [])}
|
||||
onClick={useCallback(async () => {
|
||||
await signInWithGoogle();
|
||||
}, [signInWithGoogle])}
|
||||
>
|
||||
{t['Continue with Google']()}
|
||||
</Button>
|
||||
@@ -142,7 +113,9 @@ export const SignIn: FC<AuthPanelProps> = ({
|
||||
data-testid="continue-login-button"
|
||||
block
|
||||
loading={isMutating}
|
||||
disabled={!allowSendEmail}
|
||||
icon={
|
||||
allowSendEmail || isMutating ? (
|
||||
<ArrowDownBigIcon
|
||||
width={20}
|
||||
height={20}
|
||||
@@ -151,6 +124,12 @@ export const SignIn: FC<AuthPanelProps> = ({
|
||||
color: 'var(--affine-blue)',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<CountDownRender
|
||||
className={style.resendCountdownInButton}
|
||||
timeLeft={resendCountDown}
|
||||
/>
|
||||
)
|
||||
}
|
||||
iconPosition="end"
|
||||
onClick={onContinue}
|
||||
|
||||
@@ -26,3 +26,32 @@ export const forgetPasswordButton = style({
|
||||
bottom: 0,
|
||||
display: 'none',
|
||||
});
|
||||
|
||||
export const resendWrapper = style({
|
||||
height: 32,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginTop: 30,
|
||||
});
|
||||
|
||||
export const resendCountdown = style({ width: 45, textAlign: 'center' });
|
||||
export const resendCountdownInButton = style({
|
||||
width: 40,
|
||||
textAlign: 'center',
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
marginLeft: 16,
|
||||
color: 'var(--affine-blue)',
|
||||
fontWeight: 400,
|
||||
});
|
||||
|
||||
export const accessMessage = style({
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
fontWeight: 500,
|
||||
marginTop: 65,
|
||||
marginBottom: 40,
|
||||
});
|
||||
|
||||
136
apps/core/src/components/affine/auth/use-auth.ts
Normal file
136
apps/core/src/components/affine/auth/use-auth.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { pushNotificationAtom } from '@affine/component/notification-center';
|
||||
import type { Notification } from '@affine/component/notification-center/index.jotai';
|
||||
import { isDesktop } from '@affine/env/constant';
|
||||
import { atom, useAtom, useSetAtom } from 'jotai';
|
||||
import { type SignInResponse } from 'next-auth/react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { signInCloud } from '../../../utils/cloud-utils';
|
||||
import { buildCallbackUrl } from './callback-url';
|
||||
|
||||
const COUNT_DOWN_TIME = 60;
|
||||
const INTERNAL_BETA_URL = `https://community.affine.pro/c/insider-general/`;
|
||||
|
||||
function handleSendEmailError(
|
||||
res: SignInResponse | undefined | void,
|
||||
pushNotification: (notification: Notification) => void
|
||||
) {
|
||||
if (res?.error) {
|
||||
pushNotification({
|
||||
title: 'Send email error',
|
||||
message: 'Please back to home and try again',
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
// if (res?.status === 403 && res?.url === INTERNAL_BETA_URL) {
|
||||
// pushNotification({
|
||||
// title: 'Sign up error',
|
||||
// message: `You don't have early access permission\nVisit ${INTERNAL_BETA_URL} for more information`,
|
||||
// type: 'error',
|
||||
// });
|
||||
// }
|
||||
}
|
||||
|
||||
type AuthStoreAtom = {
|
||||
allowSendEmail: boolean;
|
||||
resendCountDown: number;
|
||||
};
|
||||
|
||||
export const authStoreAtom = atom<AuthStoreAtom>({
|
||||
allowSendEmail: true,
|
||||
resendCountDown: COUNT_DOWN_TIME,
|
||||
});
|
||||
|
||||
const countDownAtom = atom(
|
||||
null, // it's a convention to pass `null` for the first argument
|
||||
(get, set) => {
|
||||
const clearId = window.setInterval(() => {
|
||||
const countDown = get(authStoreAtom).resendCountDown;
|
||||
if (countDown === 0) {
|
||||
set(authStoreAtom, {
|
||||
allowSendEmail: true,
|
||||
resendCountDown: COUNT_DOWN_TIME,
|
||||
});
|
||||
window.clearInterval(clearId);
|
||||
return;
|
||||
}
|
||||
set(authStoreAtom, {
|
||||
resendCountDown: countDown - 1,
|
||||
allowSendEmail: false,
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
);
|
||||
|
||||
export const useAuth = ({ onNoAccess }: { onNoAccess: () => void }) => {
|
||||
const pushNotification = useSetAtom(pushNotificationAtom);
|
||||
const [authStore, setAuthStore] = useAtom(authStoreAtom);
|
||||
const startResendCountDown = useSetAtom(countDownAtom);
|
||||
|
||||
const signIn = useCallback(
|
||||
async (email: string) => {
|
||||
setAuthStore(() => ({
|
||||
allowSendEmail: false,
|
||||
resendCountDown: COUNT_DOWN_TIME,
|
||||
}));
|
||||
startResendCountDown();
|
||||
|
||||
const res = await signInCloud('email', {
|
||||
email: email,
|
||||
callbackUrl: buildCallbackUrl('signIn'),
|
||||
redirect: false,
|
||||
}).catch(console.error);
|
||||
|
||||
handleSendEmailError(res, pushNotification);
|
||||
|
||||
if (res?.status === 403 && res?.url === INTERNAL_BETA_URL) {
|
||||
onNoAccess();
|
||||
}
|
||||
},
|
||||
[onNoAccess, pushNotification, setAuthStore, startResendCountDown]
|
||||
);
|
||||
|
||||
const signUp = useCallback(
|
||||
async (email: string) => {
|
||||
setAuthStore({
|
||||
allowSendEmail: false,
|
||||
resendCountDown: COUNT_DOWN_TIME,
|
||||
});
|
||||
startResendCountDown();
|
||||
|
||||
const res = await signInCloud('email', {
|
||||
email: email,
|
||||
callbackUrl: buildCallbackUrl('signUp'),
|
||||
redirect: false,
|
||||
}).catch(console.error);
|
||||
|
||||
handleSendEmailError(res, pushNotification);
|
||||
|
||||
if (res?.status === 403 && res?.url === INTERNAL_BETA_URL) {
|
||||
onNoAccess();
|
||||
}
|
||||
},
|
||||
[onNoAccess, pushNotification, setAuthStore, startResendCountDown]
|
||||
);
|
||||
|
||||
const signInWithGoogle = useCallback(() => {
|
||||
if (isDesktop) {
|
||||
open(
|
||||
`/desktop-signin?provider=google&callback_url=${buildCallbackUrl(
|
||||
'/open-app/oauth-jwt'
|
||||
)}`,
|
||||
'_target'
|
||||
);
|
||||
} else {
|
||||
signInCloud('google').catch(console.error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
allowSendEmail: authStore.allowSendEmail,
|
||||
resendCountDown: authStore.resendCountDown,
|
||||
signUp,
|
||||
signIn,
|
||||
signInWithGoogle,
|
||||
};
|
||||
};
|
||||
@@ -10,7 +10,7 @@ import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { useMutation, useQuery } from '@affine/workspace/affine/gql';
|
||||
import { ArrowRightSmallIcon, CameraIcon, DoneIcon } from '@blocksuite/icons';
|
||||
import { Button, IconButton } from '@toeverything/components/button';
|
||||
import { useAtom } from 'jotai';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { type FC, Suspense, useCallback, useState } from 'react';
|
||||
|
||||
import { authAtom } from '../../../../atoms';
|
||||
@@ -137,7 +137,7 @@ const StoragePanel = () => {
|
||||
export const AccountSetting: FC = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
const user = useCurrentUser();
|
||||
const [, setAuthModal] = useAtom(authAtom);
|
||||
const setAuthModal = useSetAtom(authAtom);
|
||||
|
||||
const onChangeEmail = useCallback(() => {
|
||||
setAuthModal({
|
||||
@@ -147,14 +147,15 @@ export const AccountSetting: FC = () => {
|
||||
emailType: 'changeEmail',
|
||||
});
|
||||
}, [setAuthModal, user.email]);
|
||||
const onChangePassword = useCallback(() => {
|
||||
|
||||
const onPasswordButtonClick = useCallback(() => {
|
||||
setAuthModal({
|
||||
openModal: true,
|
||||
state: 'sendEmail',
|
||||
email: user.email,
|
||||
emailType: 'changePassword',
|
||||
emailType: user.hasPassword ? 'changePassword' : 'setPassword',
|
||||
});
|
||||
}, [setAuthModal, user.email]);
|
||||
}, [setAuthModal, user.email, user.hasPassword]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -173,7 +174,7 @@ export const AccountSetting: FC = () => {
|
||||
name={t['com.affine.settings.password']()}
|
||||
desc={t['com.affine.settings.password.message']()}
|
||||
>
|
||||
<Button onClick={onChangePassword}>
|
||||
<Button onClick={onPasswordButtonClick}>
|
||||
{user.hasPassword
|
||||
? t['com.affine.settings.password.action.change']()
|
||||
: t['com.affine.settings.password.action.set']()}
|
||||
|
||||
@@ -8,10 +8,9 @@ export const settingContent = style({
|
||||
});
|
||||
|
||||
globalStyle(`${settingContent} .wrapper`, {
|
||||
width: '60%',
|
||||
padding: '0 15px',
|
||||
height: '100%',
|
||||
minWidth: '560px',
|
||||
maxWidth: '560px',
|
||||
margin: '0 auto',
|
||||
overflowY: 'auto',
|
||||
});
|
||||
|
||||
@@ -85,43 +85,65 @@ export class MailService {
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async sendSignInEmail(url: string, options: Options) {
|
||||
const html = emailTemplate({
|
||||
title: 'Sign in to AFFiNE',
|
||||
content:
|
||||
'Click the button below to securely sign in. The magic link will expire in 30 minutes.',
|
||||
buttonContent: 'Sign in to AFFiNE',
|
||||
buttonUrl: url,
|
||||
});
|
||||
return this.sendMail({
|
||||
html,
|
||||
subject: 'Sign in to AFFiNE',
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
async sendChangePasswordEmail(to: string, url: string) {
|
||||
const html = `
|
||||
<h1>Change password</h1>
|
||||
<p>Click button to open change password page</p>
|
||||
<a href="${url}">${url}</a>
|
||||
`;
|
||||
const html = emailTemplate({
|
||||
title: 'Modify your AFFiNE password',
|
||||
content:
|
||||
'Click the button below to reset your password. The magic link will expire in 30 minutes.',
|
||||
buttonContent: 'Set new password',
|
||||
buttonUrl: url,
|
||||
});
|
||||
return this.sendMail({
|
||||
from: this.config.auth.email.sender,
|
||||
to,
|
||||
subject: `Change password`,
|
||||
subject: `Modify your AFFiNE password`,
|
||||
html,
|
||||
});
|
||||
}
|
||||
|
||||
async sendSetPasswordEmail(to: string, url: string) {
|
||||
const html = `
|
||||
<h1>Set password</h1>
|
||||
<p>Click button to open set password page</p>
|
||||
<a href="${url}">${url}</a>
|
||||
`;
|
||||
const html = emailTemplate({
|
||||
title: 'Set your AFFiNE password',
|
||||
content:
|
||||
'Click the button below to set your password. The magic link will expire in 30 minutes.',
|
||||
buttonContent: 'Set your password',
|
||||
buttonUrl: url,
|
||||
});
|
||||
return this.sendMail({
|
||||
from: this.config.auth.email.sender,
|
||||
to,
|
||||
subject: `Change password`,
|
||||
subject: `Set your AFFiNE password`,
|
||||
html,
|
||||
});
|
||||
}
|
||||
async sendChangeEmail(to: string, url: string) {
|
||||
const html = `
|
||||
<h1>Change Email</h1>
|
||||
<p>Click button to open change email page</p>
|
||||
<a href="${url}">${url}</a>
|
||||
`;
|
||||
const html = emailTemplate({
|
||||
title: 'Verify your current email for AFFiNE',
|
||||
content:
|
||||
'You recently requested to change the email address associated with your AFFiNE account. To complete this process, please click on the verification link below. This magic link will expire in 30 minutes.',
|
||||
buttonContent: 'Verify and set up a new email address',
|
||||
buttonUrl: url,
|
||||
});
|
||||
return this.sendMail({
|
||||
from: this.config.auth.email.sender,
|
||||
to,
|
||||
subject: `Change password`,
|
||||
subject: `Verify your current email for AFFiNE`,
|
||||
html,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -88,25 +88,24 @@ export const NextAuthOptionsProvider: FactoryProvider<NextAuthOptions> = {
|
||||
from: config.auth.email.sender,
|
||||
async sendVerificationRequest(params: SendVerificationRequestParams) {
|
||||
const { identifier, url, provider } = params;
|
||||
const { host, searchParams, origin } = new URL(url);
|
||||
const { searchParams, origin } = new URL(url);
|
||||
const callbackUrl = searchParams.get('callbackUrl') || '';
|
||||
if (!callbackUrl) {
|
||||
throw new Error('callbackUrl is not set');
|
||||
}
|
||||
// hack: check if link is opened via desktop
|
||||
|
||||
const schema = getSchemaFromCallbackUrl(origin, callbackUrl);
|
||||
const wrappedUrl = wrapUrlWithOpenApp(origin, url, schema);
|
||||
|
||||
const result = await mailer.sendMail({
|
||||
// hack: check if link is opened via desktop
|
||||
const result = await mailer.sendSignInEmail(wrappedUrl, {
|
||||
to: identifier,
|
||||
from: provider.from,
|
||||
subject: `Sign in to ${host}`,
|
||||
text: text({ url: wrappedUrl, host }),
|
||||
html: html({ url: wrappedUrl, host }),
|
||||
});
|
||||
logger.log(
|
||||
`send verification email success: ${result.accepted.join(', ')}`
|
||||
);
|
||||
|
||||
const failed = result.rejected
|
||||
.concat(result.pending)
|
||||
.filter(Boolean);
|
||||
@@ -298,211 +297,3 @@ export const NextAuthOptionsProvider: FactoryProvider<NextAuthOptions> = {
|
||||
},
|
||||
inject: [Config, PrismaService, MailService],
|
||||
};
|
||||
|
||||
/**
|
||||
* Email HTML body
|
||||
* Insert invisible space into domains from being turned into a hyperlink by email
|
||||
* clients like Outlook and Apple mail, as this is confusing because it seems
|
||||
* like they are supposed to click on it to sign in.
|
||||
*
|
||||
* @note We don't add the email address to avoid needing to escape it, if you do, remember to sanitize it!
|
||||
*/
|
||||
function html(params: { url: string; host: string }) {
|
||||
const { url } = params;
|
||||
|
||||
return `
|
||||
<body style="background: #f6f7fb;overflow:hidden">
|
||||
<table
|
||||
width="100%"
|
||||
border="0"
|
||||
cellpadding="24px"
|
||||
style="
|
||||
background: #fff;
|
||||
max-width: 450px;
|
||||
margin: 32px auto 0 auto;
|
||||
border-radius: 16px 16px 0 0;
|
||||
box-shadow: 0px 0px 20px 0px rgba(66, 65, 73, 0.04);
|
||||
"
|
||||
>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://affine.pro" target="_blank">
|
||||
<img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/affine-logo.png"
|
||||
alt="AFFiNE log"
|
||||
height="32px"
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style="
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 28px;
|
||||
font-family: inter, Arial, Helvetica, sans-serif;
|
||||
color: #444;
|
||||
padding-top: 0;
|
||||
"
|
||||
>
|
||||
Verify your new email for AFFiNE
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style="
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
font-family: inter, Arial, Helvetica, sans-serif;
|
||||
color: #444;
|
||||
padding-top: 0;
|
||||
"
|
||||
>
|
||||
You recently requested to change the email address associated with your
|
||||
AFFiNe account. To complete this process, please click on the
|
||||
verification link below.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="margin-left: 24px; padding-top: 0; padding-bottom: 64px">
|
||||
<table border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td style="border-radius: 8px" bgcolor="#1E96EB">
|
||||
<a
|
||||
href="${url}"
|
||||
target="_blank"
|
||||
style="
|
||||
font-size: 15px;
|
||||
font-family: inter, Arial, Helvetica, sans-serif;
|
||||
font-weight: 600;
|
||||
line-height: 24px;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 18px;
|
||||
border: 1px solid #1e96eb;
|
||||
display: inline-block;
|
||||
font-weight: bold;
|
||||
"
|
||||
>Verify your new email address</a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table
|
||||
width="100%"
|
||||
border="0"
|
||||
style="
|
||||
background: #fafafa;
|
||||
max-width: 450px;
|
||||
margin: 0 auto 32px auto;
|
||||
border-radius: 0 0 16px 16px;
|
||||
box-shadow: 0px 0px 20px 0px rgba(66, 65, 73, 0.04);
|
||||
padding: 20px;
|
||||
"
|
||||
>
|
||||
<tr align="center">
|
||||
<td>
|
||||
<table cellpadding="0">
|
||||
<tr>
|
||||
<td style="padding: 0 10px">
|
||||
<a href="https://github.com/toeverything/AFFiNE" target="_blank"
|
||||
><img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/Github.png"
|
||||
alt="AFFiNE github link"
|
||||
height="16px"
|
||||
/></a>
|
||||
</td>
|
||||
<td style="padding: 0 10px">
|
||||
<a href="https://twitter.com/AffineOfficial" target="_blank">
|
||||
<img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/Twitter.png"
|
||||
alt="AFFiNE twitter link"
|
||||
height="16px"
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
<td style="padding: 0 10px">
|
||||
<a href="https://discord.gg/Arn7TqJBvG" target="_blank"
|
||||
><img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/Discord.png"
|
||||
alt="AFFiNE discord link"
|
||||
height="16px"
|
||||
/></a>
|
||||
</td>
|
||||
<td style="padding: 0 10px">
|
||||
<a href="https://www.youtube.com/@affinepro" target="_blank"
|
||||
><img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/Youtube.png"
|
||||
alt="AFFiNE youtube link"
|
||||
height="16px"
|
||||
/></a>
|
||||
</td>
|
||||
<td style="padding: 0 10px">
|
||||
<a href="https://t.me/affineworkos" target="_blank"
|
||||
><img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/Telegram.png"
|
||||
alt="AFFiNE telegram link"
|
||||
height="16px"
|
||||
/></a>
|
||||
</td>
|
||||
<td style="padding: 0 10px">
|
||||
<a href="https://www.reddit.com/r/Affine/" target="_blank"
|
||||
><img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/Reddit.png"
|
||||
alt="AFFiNE reddit link"
|
||||
height="16px"
|
||||
/></a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr align="center">
|
||||
<td
|
||||
style="
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
font-family: inter, Arial, Helvetica, sans-serif;
|
||||
color: #8e8d91;
|
||||
padding-top: 8px;
|
||||
"
|
||||
>
|
||||
One hyper-fused platform for wildly creative minds
|
||||
</td>
|
||||
</tr>
|
||||
<tr align="center">
|
||||
<td
|
||||
style="
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
font-family: inter, Arial, Helvetica, sans-serif;
|
||||
color: #8e8d91;
|
||||
padding-top: 8px;
|
||||
"
|
||||
>
|
||||
Copyright<img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/copyright.png"
|
||||
alt="copyright"
|
||||
height="14px"
|
||||
style="vertical-align: middle; margin: 0 4px"
|
||||
/>2023 Toeverything
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
/** Email Text body (fallback for email clients that don't render HTML, e.g. feature phones) */
|
||||
function text({ url, host }: { url: string; host: string }) {
|
||||
return `Sign in to ${host}\n${url}\n\n`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { forwardRef, type HTMLAttributes } 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 }) => {
|
||||
return <div {...props}>{formatTime(timeLeft)}</div>;
|
||||
});
|
||||
|
||||
CountDownRender.displayName = 'CountDownRender';
|
||||
@@ -4,10 +4,10 @@ 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';
|
||||
export * from './modal-header';
|
||||
export * from './password-input';
|
||||
export * from './resend-button';
|
||||
export * from './set-password-page';
|
||||
export * from './sign-in-page-container';
|
||||
export * from './sign-in-success-page';
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { Button } from '@toeverything/components/button';
|
||||
import { type FC, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { resendButtonWrapper } from './share.css';
|
||||
|
||||
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}`;
|
||||
};
|
||||
const CountDown: FC<{
|
||||
seconds: number;
|
||||
onEnd?: () => void;
|
||||
}> = ({ seconds, onEnd }) => {
|
||||
const [timeLeft, setTimeLeft] = useState(seconds);
|
||||
|
||||
useEffect(() => {
|
||||
if (timeLeft === 0) {
|
||||
onEnd?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
setTimeLeft(timeLeft - 1);
|
||||
|
||||
if (timeLeft - 1 === 0) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [onEnd, timeLeft]);
|
||||
|
||||
return (
|
||||
<div style={{ width: 45, textAlign: 'center' }}>{formatTime(timeLeft)}</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ResendButton: FC<{
|
||||
onClick: () => void;
|
||||
countDownSeconds?: number;
|
||||
}> = ({ onClick, countDownSeconds = 60 }) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [canResend, setCanResend] = useState(false);
|
||||
|
||||
const onButtonClick = useCallback(() => {
|
||||
onClick();
|
||||
setCanResend(false);
|
||||
}, [onClick]);
|
||||
|
||||
const onCountDownEnd = useCallback(() => {
|
||||
setCanResend(true);
|
||||
}, [setCanResend]);
|
||||
|
||||
return (
|
||||
<div className={resendButtonWrapper}>
|
||||
{canResend ? (
|
||||
<Button type="plain" size="large" onClick={onButtonClick}>
|
||||
{t['com.affine.auth.sign.auth.code.resend.hint']()}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<span className="resend-code-hint">
|
||||
{t['com.affine.auth.sign.auth.code.on.resend.hint']()}
|
||||
</span>
|
||||
<CountDown seconds={countDownSeconds} onEnd={onCountDownEnd} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -47,7 +47,6 @@ export const SetPasswordPage: FC<{
|
||||
)
|
||||
}
|
||||
>
|
||||
<h1>This is set page</h1>
|
||||
{hasSetUp ? (
|
||||
<Button type="primary" size="large" onClick={onOpenAffine}>
|
||||
{t['com.affine.auth.open.affine']()}
|
||||
|
||||
@@ -433,6 +433,10 @@
|
||||
"com.affine.auth.sign.sent.email.message.end": " You can click the link to create an account automatically.",
|
||||
"com.affine.auth.sign.up.success.title": "Your account has been created and you’re now signed in!",
|
||||
"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.",
|
||||
"Early Access Stage": "Early Access Stage",
|
||||
"com.affine.auth.sign.no.access.hint": "AFFiNE Cloud is in early access. Check out this link to learn more about the benefits of becoming an AFFiNE Cloud Early Supporter: ",
|
||||
"com.affine.auth.sign.no.access.link": "AFFiNE Cloud Early Access",
|
||||
"com.affine.auth.sign.no.access.wait": "Wait for public release",
|
||||
"com.affine.auth.page.sent.email.title": "Welcome to AFFiNE Cloud, you are almost there!",
|
||||
"com.affine.auth.page.sent.email.subtitle": "Please set a password of 8-20 characters with both letters and numbers to continue signing up with ",
|
||||
"com.affine.auth.later": "Later",
|
||||
|
||||
Reference in New Issue
Block a user