refactor(auth): authenticate user in main window (#8032)

This commit is contained in:
forehalo
2024-09-03 09:03:46 +00:00
parent e33aa35f7e
commit 52c9da67f0
31 changed files with 561 additions and 509 deletions
@@ -9,7 +9,7 @@ import { Button } from '@affine/component/ui/button';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { AuthService } from '@affine/core/modules/cloud';
import { Trans, useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { useService } from '@toeverything/infra';
import { useCallback, useEffect, useState } from 'react';
import type { AuthPanelProps } from './index';
@@ -17,10 +17,9 @@ import * as style from './style.css';
import { Captcha, useCaptcha } from './use-captcha';
export const AfterSignInSendEmail = ({
setAuthState,
setAuthData: setAuth,
email,
onSignedIn,
}: AuthPanelProps) => {
}: AuthPanelProps<'afterSignInSendEmail'>) => {
const [resendCountDown, setResendCountDown] = useState(60);
useEffect(() => {
@@ -37,22 +36,9 @@ export const AfterSignInSendEmail = ({
const t = useI18n();
const authService = useService(AuthService);
useEffect(() => {
const timer = setInterval(() => {
authService.session.revalidate();
}, 3000);
return () => {
clearInterval(timer);
};
}, [authService]);
const loginStatus = useLiveData(authService.session.status$);
const [verifyToken, challenge] = useCaptcha();
if (loginStatus === 'authenticated') {
onSignedIn?.();
}
const onResendClick = useAsyncCallback(async () => {
setIsSending(true);
try {
@@ -70,12 +56,12 @@ export const AfterSignInSendEmail = ({
}, [authService, challenge, email, verifyToken]);
const onSignInWithPasswordClick = useCallback(() => {
setAuthState('signInWithPassword');
}, [setAuthState]);
setAuth({ state: 'signInWithPassword' });
}, [setAuth]);
const onBackBottomClick = useCallback(() => {
setAuthState('signIn');
}, [setAuthState]);
setAuth({ state: 'signIn' });
}, [setAuth]);
return (
<>
@@ -8,7 +8,7 @@ import {
import { Button } from '@affine/component/ui/button';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { Trans, useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { useService } from '@toeverything/infra';
import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react';
@@ -17,11 +17,9 @@ import type { AuthPanelProps } from './index';
import * as style from './style.css';
import { Captcha, useCaptcha } from './use-captcha';
export const AfterSignUpSendEmail: FC<AuthPanelProps> = ({
setAuthState,
email,
onSignedIn,
}) => {
export const AfterSignUpSendEmail: FC<
AuthPanelProps<'afterSignUpSendEmail'>
> = ({ setAuthData, email }) => {
const [resendCountDown, setResendCountDown] = useState(60);
useEffect(() => {
@@ -37,19 +35,6 @@ export const AfterSignUpSendEmail: FC<AuthPanelProps> = ({
const [isSending, setIsSending] = useState(false);
const t = useI18n();
const authService = useService(AuthService);
const loginStatus = useLiveData(authService.session.status$);
useEffect(() => {
const timeout = setInterval(() => {
// revalidate session to get the latest status
authService.session.revalidate();
}, 3000);
return () => {
clearInterval(timeout);
};
}, [authService]);
if (loginStatus === 'authenticated') {
onSignedIn?.();
}
const [verifyToken, challenge] = useCaptcha();
@@ -117,8 +102,8 @@ export const AfterSignUpSendEmail: FC<AuthPanelProps> = ({
<BackButton
onClick={useCallback(() => {
setAuthState('signIn');
}, [setAuthState])}
setAuthData({ state: 'signIn' });
}, [setAuthData])}
/>
</>
);
@@ -1,7 +1,14 @@
import type { AuthModalProps as AuthModalBaseProps } from '@affine/component/auth-components';
import { notify } from '@affine/component';
import { AuthModal as AuthModalBase } from '@affine/component/auth-components';
import { authAtom, type AuthAtomData } from '@affine/core/atoms';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { AuthService } from '@affine/core/modules/cloud';
import { apis, events } from '@affine/electron-api';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { useAtom } from 'jotai/react';
import type { FC } from 'react';
import { useCallback, useMemo } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { AfterSignInSendEmail } from './after-sign-in-send-email';
import { AfterSignUpSendEmail } from './after-sign-up-send-email';
@@ -9,33 +16,25 @@ import { SendEmail } from './send-email';
import { SignIn } from './sign-in';
import { SignInWithPassword } from './sign-in-with-password';
export type AuthProps = {
state:
| 'signIn'
| 'afterSignUpSendEmail'
| 'afterSignInSendEmail'
// throw away
| 'signInWithPassword'
| 'sendEmail';
setAuthState: (state: AuthProps['state']) => void;
setAuthEmail: (state: AuthProps['email']) => void;
setEmailType: (state: AuthProps['emailType']) => void;
email: string;
emailType: 'setPassword' | 'changePassword' | 'changeEmail' | 'verifyEmail';
onSignedIn?: () => void;
};
type AuthAtomType<T extends AuthAtomData['state']> = Extract<
AuthAtomData,
{ state: T }
>;
export type AuthPanelProps = {
email: string;
setAuthState: AuthProps['setAuthState'];
setAuthEmail: AuthProps['setAuthEmail'];
setEmailType: AuthProps['setEmailType'];
emailType: AuthProps['emailType'];
onSignedIn?: () => void;
};
// return field in B that is not in A
type Difference<
A extends Record<string, any>,
B extends Record<string, any>,
> = Pick<B, Exclude<keyof B, keyof A>>;
export type AuthPanelProps<State extends AuthAtomData['state']> = {
setAuthData: <T extends AuthAtomData['state']>(
updates: { state: T } & Difference<AuthAtomType<State>, AuthAtomType<T>>
) => void;
} & Extract<AuthAtomData, { state: State }>;
const config: {
[k in AuthProps['state']]: FC<AuthPanelProps>;
[k in AuthAtomData['state']]: FC<AuthPanelProps<k>>;
} = {
signIn: SignIn,
afterSignUpSendEmail: AfterSignUpSendEmail,
@@ -44,58 +43,100 @@ const config: {
sendEmail: SendEmail,
};
export const AuthModal: FC<AuthModalBaseProps & AuthProps> = ({
open,
state,
setOpen,
email,
setAuthEmail,
setAuthState,
setEmailType,
emailType,
}) => {
const onSignedIn = useCallback(() => {
setAuthState('signIn');
setAuthEmail('');
setOpen(false);
}, [setAuthState, setAuthEmail, setOpen]);
export function AuthModal() {
const t = useI18n();
const [authAtomValue, setAuthAtom] = useAtom(authAtom);
const authService = useService(AuthService);
const setOpen = useCallback(
(open: boolean) => {
setAuthAtom(prev => ({ ...prev, openModal: open }));
},
[setAuthAtom]
);
const signIn = useAsyncCallback(
async ({
method,
payload,
}: {
method: 'magic-link' | 'oauth';
payload: any;
}) => {
if (!(await apis?.ui.isActiveTab())) {
return;
}
try {
switch (method) {
case 'magic-link': {
const { email, token } = payload;
await authService.signInMagicLink(email, token);
break;
}
case 'oauth': {
const { code, state } = payload;
await authService.signInOauth(code, state);
break;
}
}
authService.session.revalidate();
} catch (e) {
notify.error({
title: t['com.affine.auth.toast.title.failed'](),
message: (e as any).message,
});
}
},
[authService, t]
);
useEffect(() => {
return events?.ui.onAuthenticationRequest(signIn);
}, [signIn]);
return (
<AuthModalBase open={open} setOpen={setOpen}>
<AuthPanel
state={state}
email={email}
setAuthEmail={setAuthEmail}
setAuthState={setAuthState}
setEmailType={setEmailType}
emailType={emailType}
onSignedIn={onSignedIn}
/>
<AuthModalBase open={authAtomValue.openModal} setOpen={setOpen}>
<AuthPanel />
</AuthModalBase>
);
};
}
export const AuthPanel: FC<AuthProps> = ({
state,
email,
setAuthEmail,
setAuthState,
setEmailType,
emailType,
onSignedIn,
}) => {
const CurrentPanel = useMemo(() => {
return config[state];
}, [state]);
export function AuthPanel() {
const t = useI18n();
const [authAtomValue, setAuthAtom] = useAtom(authAtom);
const authService = useService(AuthService);
const loginStatus = useLiveData(authService.session.status$);
const previousLoginStatus = useRef(loginStatus);
return (
<CurrentPanel
email={email}
setAuthState={setAuthState}
setAuthEmail={setAuthEmail}
setEmailType={setEmailType}
emailType={emailType}
onSignedIn={onSignedIn}
/>
const setAuthData = useCallback(
(updates: Partial<AuthAtomData>) => {
// @ts-expect-error checked in impls
setAuthAtom(prev => ({
...prev,
...updates,
}));
},
[setAuthAtom]
);
};
useEffect(() => {
if (
loginStatus === 'authenticated' &&
previousLoginStatus.current === 'unauthenticated'
) {
setAuthAtom({
openModal: false,
state: 'signIn',
});
notify.success({
title: t['com.affine.auth.toast.title.signed-in'](),
message: t['com.affine.auth.toast.message.signed-in'](),
});
}
previousLoginStatus.current = loginStatus;
}, [loginStatus, setAuthAtom, t]);
const CurrentPanel = config[authAtomValue.state];
// @ts-expect-error checked in impls
return <CurrentPanel {...authAtomValue} setAuthData={setAuthData} />;
}
@@ -2,6 +2,8 @@ import { notify, Skeleton } from '@affine/component';
import { Button } from '@affine/component/ui/button';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { track } from '@affine/core/mixpanel';
import { popupWindow } from '@affine/core/utils';
import { apis } from '@affine/electron-api';
import { OAuthProviderType } from '@affine/graphql';
import { GithubIcon, GoogleDuotoneIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
@@ -59,7 +61,12 @@ function OAuthProvider({ provider }: { provider: OAuthProviderType }) {
const onClick = useAsyncCallback(async () => {
try {
setIsConnecting(true);
await authService.signInOauth(provider);
const url = await authService.oauthPreflight(provider);
if (environment.isDesktop) {
await apis?.ui.openExternal(url);
} else {
popupWindow(url);
}
} catch (err) {
console.error(err);
notify.error({ title: 'Failed to sign in, please try again.' });
@@ -21,7 +21,7 @@ import { useMutation } from '../../../hooks/use-mutation';
import { ServerConfigService } from '../../../modules/cloud';
import type { AuthPanelProps } from './index';
const useEmailTitle = (emailType: AuthPanelProps['emailType']) => {
const useEmailTitle = (emailType: AuthPanelProps<'sendEmail'>['emailType']) => {
const t = useI18n();
switch (emailType) {
@@ -36,7 +36,9 @@ const useEmailTitle = (emailType: AuthPanelProps['emailType']) => {
}
};
const useNotificationHint = (emailType: AuthPanelProps['emailType']) => {
const useNotificationHint = (
emailType: AuthPanelProps<'sendEmail'>['emailType']
) => {
const t = useI18n();
switch (emailType) {
@@ -49,7 +51,9 @@ const useNotificationHint = (emailType: AuthPanelProps['emailType']) => {
return t['com.affine.auth.sent.verify.email.hint']();
}
};
const useButtonContent = (emailType: AuthPanelProps['emailType']) => {
const useButtonContent = (
emailType: AuthPanelProps<'sendEmail'>['emailType']
) => {
const t = useI18n();
switch (emailType) {
@@ -63,7 +67,7 @@ const useButtonContent = (emailType: AuthPanelProps['emailType']) => {
}
};
const useSendEmail = (emailType: AuthPanelProps['emailType']) => {
const useSendEmail = (emailType: AuthPanelProps<'sendEmail'>['emailType']) => {
const {
trigger: sendChangePasswordEmail,
isMutating: isChangePasswordMutating,
@@ -134,10 +138,10 @@ const useSendEmail = (emailType: AuthPanelProps['emailType']) => {
};
export const SendEmail = ({
setAuthState,
setAuthData,
email,
emailType,
}: AuthPanelProps) => {
}: AuthPanelProps<'sendEmail'>) => {
const t = useI18n();
const serverConfig = useService(ServerConfigService).serverConfig;
@@ -160,8 +164,8 @@ export const SendEmail = ({
}, [email, hint, sendEmail]);
const onBack = useCallback(() => {
setAuthState('signIn');
}, [setAuthState]);
setAuthData({ state: 'signIn' });
}, [setAuthData]);
if (!passwordLimits) {
// TODO(@eyhn): loading & error UI
@@ -16,11 +16,9 @@ import type { AuthPanelProps } from './index';
import * as styles from './style.css';
import { useCaptcha } from './use-captcha';
export const SignInWithPassword: FC<AuthPanelProps> = ({
setAuthState,
setEmailType,
export const SignInWithPassword: FC<AuthPanelProps<'signInWithPassword'>> = ({
setAuthData,
email,
onSignedIn,
}) => {
const t = useI18n();
const authService = useService(AuthService);
@@ -40,14 +38,13 @@ export const SignInWithPassword: FC<AuthPanelProps> = ({
email,
password,
});
onSignedIn?.();
} catch (err) {
console.error(err);
setPasswordError(true);
} finally {
setIsLoading(false);
}
}, [isLoading, authService, email, password, onSignedIn]);
}, [isLoading, authService, email, password]);
const sendMagicLink = useAsyncCallback(async () => {
if (sendingEmail) return;
@@ -55,7 +52,7 @@ export const SignInWithPassword: FC<AuthPanelProps> = ({
try {
if (verifyToken) {
await authService.sendEmailMagicLink(email, verifyToken, challenge);
setAuthState('afterSignInSendEmail');
setAuthData({ state: 'afterSignInSendEmail' });
}
} catch (err) {
console.error(err);
@@ -65,12 +62,11 @@ export const SignInWithPassword: FC<AuthPanelProps> = ({
// TODO(@eyhn): handle error better
}
setSendingEmail(false);
}, [sendingEmail, verifyToken, authService, email, challenge, setAuthState]);
}, [sendingEmail, verifyToken, authService, email, challenge, setAuthData]);
const sendChangePasswordEmail = useCallback(() => {
setEmailType('changePassword');
setAuthState('sendEmail');
}, [setAuthState, setEmailType]);
setAuthData({ state: 'sendEmail', emailType: 'changePassword' });
}, [setAuthData]);
return (
<>
@@ -140,8 +136,8 @@ export const SignInWithPassword: FC<AuthPanelProps> = ({
</Wrapper>
<BackButton
onClick={useCallback(() => {
setAuthState('signIn');
}, [setAuthState])}
setAuthData({ state: 'signIn' });
}, [setAuthData])}
/>
</>
);
@@ -1,16 +1,14 @@
import { notify } from '@affine/component';
import { AuthInput, ModalHeader } from '@affine/component/auth-components';
import { Button } from '@affine/component/ui/button';
import { authAtom } from '@affine/core/atoms';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { track } from '@affine/core/mixpanel';
import { Trans, useI18n } from '@affine/i18n';
import { ArrowRightBigIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { useService } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import { useAtomValue } from 'jotai';
import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { AuthService } from '../../../modules/cloud';
@@ -24,36 +22,19 @@ function validateEmail(email: string) {
return emailRegex.test(email);
}
export const SignIn: FC<AuthPanelProps> = ({
setAuthState,
setAuthEmail,
email,
onSignedIn,
export const SignIn: FC<AuthPanelProps<'signIn'>> = ({
setAuthData: setAuthState,
}) => {
const t = useI18n();
const authService = useService(AuthService);
const [searchParams] = useSearchParams();
const [isMutating, setIsMutating] = useState(false);
const [verifyToken, challenge] = useCaptcha();
const [email, setEmail] = useState('');
const [isValidEmail, setIsValidEmail] = useState(true);
const { openModal } = useAtomValue(authAtom);
const errorMsg = searchParams.get('error');
useEffect(() => {
const timeout = setInterval(() => {
// revalidate session to get the latest status
authService.session.revalidate();
}, 3000);
return () => {
clearInterval(timeout);
};
}, [authService]);
const loginStatus = useLiveData(authService.session.status$);
if (loginStatus === 'authenticated' && openModal) {
onSignedIn?.();
}
const onContinue = useAsyncCallback(async () => {
if (!validateEmail(email)) {
setIsValidEmail(false);
@@ -61,10 +42,8 @@ export const SignIn: FC<AuthPanelProps> = ({
}
setIsValidEmail(true);
setIsMutating(true);
setAuthEmail(email);
try {
const { hasPassword, registered } =
await authService.checkUserByEmail(email);
@@ -74,16 +53,25 @@ export const SignIn: FC<AuthPanelProps> = ({
// provider password sign-in if user has by default
// If with payment, onl support email sign in to avoid redirect to affine app
if (hasPassword) {
setAuthState('signInWithPassword');
setAuthState({
state: 'signInWithPassword',
email,
});
} else {
track.$.$.auth.signIn();
await authService.sendEmailMagicLink(email, verifyToken, challenge);
setAuthState('afterSignInSendEmail');
setAuthState({
state: 'afterSignInSendEmail',
email,
});
}
} else {
await authService.sendEmailMagicLink(email, verifyToken, challenge);
track.$.$.auth.signUp();
setAuthState('afterSignUpSendEmail');
setAuthState({
state: 'afterSignUpSendEmail',
email,
});
}
}
} catch (err) {
@@ -96,7 +84,7 @@ export const SignIn: FC<AuthPanelProps> = ({
}
setIsMutating(false);
}, [authService, challenge, email, setAuthEmail, setAuthState, verifyToken]);
}, [authService, challenge, email, setAuthState, verifyToken]);
return (
<>
@@ -111,13 +99,7 @@ export const SignIn: FC<AuthPanelProps> = ({
<AuthInput
label={t['com.affine.settings.email']()}
placeholder={t['com.affine.auth.sign.email.placeholder']()}
value={email}
onChange={useCallback(
(value: string) => {
setAuthEmail(value);
},
[setAuthEmail]
)}
onChange={setEmail}
error={!isValidEmail}
errorHint={
isValidEmail ? '' : t['com.affine.auth.sign.email.error']()
@@ -195,6 +195,7 @@ export const AccountSetting: FC = () => {
setAuthModal({
openModal: true,
state: 'sendEmail',
// @ts-expect-error accont email is always defined
email: account.email,
emailType: account.info?.emailVerified ? 'changeEmail' : 'verifyEmail',
});
@@ -204,6 +205,7 @@ export const AccountSetting: FC = () => {
setAuthModal({
openModal: true,
state: 'sendEmail',
// @ts-expect-error accont email is always defined
email: account.email,
emailType: account.info?.hasPassword ? 'changePassword' : 'setPassword',
});
@@ -21,7 +21,7 @@ import {
WorkspacesService,
} from '@toeverything/infra';
import clsx from 'clsx';
import { useAtom } from 'jotai/react';
import { useSetAtom } from 'jotai/react';
import {
type MouseEvent,
Suspense,
@@ -81,7 +81,7 @@ export const UserInfo = ({ onAccountSettingClick, active }: UserInfoProps) => {
export const SignInButton = () => {
const t = useI18n();
const [, setAuthModal] = useAtom(authAtom);
const setAuthModal = useSetAtom(authAtom);
return (
<div