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

View File

@@ -1,6 +1,5 @@
import { atom } from 'jotai';
import type { AuthProps } from '../components/affine/auth';
import type { SettingProps } from '../components/affine/setting-modal';
import type { ActiveTab } from '../components/affine/setting-modal/types';
// modal atoms
@@ -37,18 +36,37 @@ export const openSettingModalAtom = atom<SettingAtom>({
open: false,
});
export type AuthAtom = {
openModal: boolean;
state: AuthProps['state'];
email?: string;
emailType?: AuthProps['emailType'];
};
export type AuthAtomData =
| { state: 'signIn' }
| {
state: 'afterSignUpSendEmail';
email: string;
}
| {
state: 'afterSignInSendEmail';
email: string;
}
| {
state: 'signInWithPassword';
email: string;
}
| {
state: 'sendEmail';
email: string;
emailType:
| 'setPassword'
| 'changePassword'
| 'changeEmail'
| 'verifyEmail';
};
export const authAtom = atom<AuthAtom>({
export const authAtom = atom<
AuthAtomData & {
openModal: boolean;
}
>({
openModal: false,
state: 'signIn',
email: '',
emailType: 'changeEmail',
});
export type AllPageFilterOption = 'docs' | 'collections' | 'tags';

View File

@@ -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 (
<>

View File

@@ -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])}
/>
</>
);

View File

@@ -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} />;
}

View File

@@ -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.' });

View File

@@ -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

View File

@@ -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])}
/>
</>
);

View File

@@ -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']()

View File

@@ -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',
});

View File

@@ -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

View File

@@ -1,6 +1,5 @@
import { AIProvider } from '@affine/core/blocksuite/presets/ai';
import { buildAppUrl, popupWindow } from '@affine/core/utils';
import { apis, appInfo } from '@affine/electron-api';
import { appInfo } from '@affine/electron-api';
import type { OAuthProviderType } from '@affine/graphql';
import {
ApplicationFocused,
@@ -97,11 +96,7 @@ export class AuthService extends Service {
email,
// we call it [callbackUrl] instead of [redirect_uri]
// to make it clear the url is used to finish the sign-in process instead of redirect after signed-in
callbackUrl: buildAppUrl('/magic-link', {
desktop: environment.isDesktop,
openInHiddenWindow: true,
redirectFromWeb: true,
}),
callbackUrl: `/magic-link?client=${environment.isDesktop ? appInfo?.schema : 'web'}`,
}),
headers: {
'content-type': 'application/json',
@@ -113,19 +108,28 @@ export class AuthService extends Service {
}
}
async signInOauth(provider: OAuthProviderType) {
async signInMagicLink(email: string, token: string) {
await this.fetchService.fetch('/api/auth/magic-link', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, token }),
});
}
async oauthPreflight(
provider: OAuthProviderType,
/** @deprecated*/ redirectUrl?: string
) {
const res = await this.fetchService.fetch('/api/oauth/preflight', {
method: 'POST',
body: JSON.stringify({ provider }),
body: JSON.stringify({ provider, redirect_uri: redirectUrl }),
headers: {
'content-type': 'application/json',
},
});
if (!res.ok) {
throw new Error(`Failed to sign in with ${provider}`);
}
let { url } = await res.json();
// change `state=xxx` to `state={state:xxx,native:true}`
@@ -140,13 +144,19 @@ export class AuthService extends Service {
);
url = oauthUrl.toString();
if (environment.isDesktop) {
await apis?.ui.openExternal(url);
} else {
popupWindow(url);
}
return url;
}
return;
async signInOauth(code: string, state: string) {
const res = await this.fetchService.fetch('/api/oauth/callback', {
method: 'POST',
body: JSON.stringify({ code, state }),
headers: {
'content-type': 'application/json',
},
});
return await res.json();
}
async signInPassword(credential: { email: string; password: string }) {

View File

@@ -11,7 +11,7 @@ import { useCallback, useEffect, useState } from 'react';
import { SignOutModal } from '../components/affine/sign-out-modal';
import { RouteLogic, useNavigateHelper } from '../hooks/use-navigate-helper';
import { AuthService } from '../modules/cloud';
import { SignIn } from './sign-in';
import { SignIn } from './auth/sign-in';
export const PageNotFound = ({
noPermission,

View File

@@ -22,9 +22,9 @@ import type { LoaderFunction } from 'react-router-dom';
import { redirect, useParams, useSearchParams } from 'react-router-dom';
import { z } from 'zod';
import { useMutation } from '../hooks/use-mutation';
import { RouteLogic, useNavigateHelper } from '../hooks/use-navigate-helper';
import { AuthService, ServerConfigService } from '../modules/cloud';
import { useMutation } from '../../hooks/use-mutation';
import { RouteLogic, useNavigateHelper } from '../../hooks/use-navigate-helper';
import { AuthService, ServerConfigService } from '../../modules/cloud';
const authTypeSchema = z.enum([
'onboarding',

View File

@@ -0,0 +1,62 @@
import { AuthService } from '@affine/core/modules/cloud';
import { OAuthProviderType } from '@affine/graphql';
import { useService } from '@toeverything/infra';
import { useEffect } from 'react';
import {
type LoaderFunction,
redirect,
useLoaderData,
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
useNavigate,
} from 'react-router-dom';
import { z } from 'zod';
const supportedProvider = z.nativeEnum(OAuthProviderType);
interface LoaderData {
provider: OAuthProviderType;
redirectUri: string;
}
export const loader: LoaderFunction = async ({ request }) => {
const url = new URL(request.url);
const searchParams = url.searchParams;
const provider = searchParams.get('provider');
const redirectUri = searchParams.get('redirect_uri');
// sign out first
await fetch('/api/auth/sign-out');
const maybeProvider = supportedProvider.safeParse(provider);
if (maybeProvider.success) {
return {
provider,
redirectUri,
};
}
return redirect(
`/signIn?error=${encodeURIComponent(`Invalid oauth provider ${provider}`)}`
);
};
export const Component = () => {
const auth = useService(AuthService);
const data = useLoaderData() as LoaderData;
const nav = useNavigate();
useEffect(() => {
auth
.oauthPreflight(data.provider, data.redirectUri)
.then(url => {
// this is the url of oauth provider auth page, can't navigate with react-router
location.href = url;
})
.catch(e => {
nav(`/signIn?error=${encodeURIComponent(e.message)}`);
});
}, [data, auth, nav]);
return null;
};

View File

@@ -0,0 +1,72 @@
import { useService } from '@toeverything/infra';
import { useEffect } from 'react';
import {
type LoaderFunction,
redirect,
useLoaderData,
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
useNavigate,
} from 'react-router-dom';
import { AuthService } from '../../modules/cloud';
interface LoaderData {
token: string;
email: string;
redirectUri: string | null;
}
export const loader: LoaderFunction = ({ request }) => {
const url = new URL(request.url);
const params = url.searchParams;
const client = params.get('client');
const email = params.get('email');
const token = params.get('token');
const redirectUri = params.get('redirect_uri');
if (!email || !token) {
return redirect('/signIn?error=Invalid magic link');
}
const payload: LoaderData = {
email,
token,
redirectUri,
};
if (!client || client === 'web') {
return payload;
}
const authParams = new URLSearchParams();
authParams.set('method', 'magic-link');
authParams.set('payload', JSON.stringify(payload));
return redirect(
`/open-app/url?url=${encodeURIComponent(`${client}://authentication?${authParams.toString()}`)}`
);
};
export const Component = () => {
// TODO(@eyhn): loading ui
const auth = useService(AuthService);
const data = useLoaderData() as LoaderData;
const nav = useNavigate();
useEffect(() => {
auth
.signInMagicLink(data.email, data.token)
.then(() => {
// compatible with old client
if (data.redirectUri) {
nav(data.redirectUri);
}
})
.catch(e => {
nav(`/signIn?error=${encodeURIComponent(e.message)}`);
});
}, [data, auth, nav]);
return null;
};

View File

@@ -0,0 +1,73 @@
import { useService } from '@toeverything/infra';
import { useEffect } from 'react';
import {
type LoaderFunction,
redirect,
useLoaderData,
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
useNavigate,
} from 'react-router-dom';
import { AuthService } from '../../modules/cloud';
interface LoaderData {
state: string;
code: string;
}
export const loader: LoaderFunction = async ({ request }) => {
const url = new URL(request.url);
const queries = url.searchParams;
const code = queries.get('code');
let stateStr = queries.get('state') ?? '{}';
if (!code || !stateStr) {
return redirect('/signIn?error=Invalid oauth callback parameters');
}
try {
const { state, client } = JSON.parse(stateStr);
stateStr = state;
const payload: LoaderData = {
state,
code,
};
if (!client || client === 'web') {
return payload;
}
const authParams = new URLSearchParams();
authParams.set('method', 'oauth');
authParams.set('payload', JSON.stringify(payload));
return redirect(
`/open-app/url?url=${encodeURIComponent(`${client}://authentication?${authParams.toString()}`)}`
);
} catch {
return redirect('/signIn?error=Invalid oauth callback parameters');
}
};
export const Component = () => {
const auth = useService(AuthService);
const data = useLoaderData() as LoaderData;
const nav = useNavigate();
useEffect(() => {
auth
.signInOauth(data.code, data.state)
.then(({ redirectUri }) => {
if (redirectUri) {
nav(redirectUri);
}
})
.catch(e => {
nav(`/signIn?error=${encodeURIComponent(e.message)}`);
});
}, [data, auth, nav]);
return null;
};

View File

@@ -1,20 +1,16 @@
import { AffineOtherPageLayout } from '@affine/component/affine-other-page-layout';
import { SignInPageContainer } from '@affine/component/auth-components';
import { AuthService } from '@affine/core/modules/cloud';
import { appInfo } from '@affine/electron-api';
import { useLiveData, useService } from '@toeverything/infra';
import { useAtom } from 'jotai';
import { useCallback, useEffect } from 'react';
import { useEffect } from 'react';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { useNavigate, useSearchParams } from 'react-router-dom';
import { authAtom } from '../atoms';
import type { AuthProps } from '../components/affine/auth';
import { AuthPanel } from '../components/affine/auth';
import { RouteLogic, useNavigateHelper } from '../hooks/use-navigate-helper';
import { AuthPanel } from '../../components/affine/auth';
import { RouteLogic, useNavigateHelper } from '../../hooks/use-navigate-helper';
export const SignIn = () => {
const [{ state, email = '', emailType = 'changePassword' }, setAuthAtom] =
useAtom(authAtom);
const session = useService(AuthService).session;
const status = useLiveData(session.status$);
const isRevalidating = useLiveData(session.isRevalidating$);
@@ -24,6 +20,10 @@ export const SignIn = () => {
const isLoggedIn = status === 'authenticated' && !isRevalidating;
useEffect(() => {
if (environment.isDesktop && appInfo?.windowName === 'hidden-window') {
return;
}
if (isLoggedIn) {
const redirectUri = searchParams.get('redirect_uri');
if (redirectUri) {
@@ -36,40 +36,12 @@ export const SignIn = () => {
});
}
}
}, [jumpToIndex, navigate, setAuthAtom, isLoggedIn, searchParams]);
const onSetEmailType = useCallback(
(emailType: AuthProps['emailType']) => {
setAuthAtom(prev => ({ ...prev, emailType }));
},
[setAuthAtom]
);
const onSetAuthState = useCallback(
(state: AuthProps['state']) => {
setAuthAtom(prev => ({ ...prev, state }));
},
[setAuthAtom]
);
const onSetAuthEmail = useCallback(
(email: AuthProps['email']) => {
setAuthAtom(prev => ({ ...prev, email }));
},
[setAuthAtom]
);
}, [jumpToIndex, navigate, isLoggedIn, searchParams]);
return (
<SignInPageContainer>
<div style={{ maxWidth: '400px', width: '100%' }}>
<AuthPanel
state={state}
email={email}
emailType={emailType}
setEmailType={onSetEmailType}
setAuthState={onSetAuthState}
setAuthEmail={onSetAuthEmail}
/>
<AuthPanel />
</div>
</SignInPageContainer>
);

View File

@@ -1,43 +0,0 @@
import { OAuthProviderType } from '@affine/graphql';
import type { LoaderFunction } from 'react-router-dom';
import { z } from 'zod';
const supportedProvider = z.enum([
'google',
...Object.values(OAuthProviderType),
]);
export const loader: LoaderFunction = async ({ request }) => {
const url = new URL(request.url);
const searchParams = url.searchParams;
const provider = searchParams.get('provider');
const redirectUri =
searchParams.get('redirect_uri') ??
/* backward compatibility */ searchParams.get('callback_url');
if (!redirectUri) {
return null;
}
// sign out first
await fetch('/api/auth/sign-out');
const maybeProvider = supportedProvider.safeParse(provider);
if (maybeProvider.success) {
let provider = maybeProvider.data;
// BACKWARD COMPATIBILITY
if (provider === 'google') {
provider = OAuthProviderType.Google;
}
location.href = `${
runtimeConfig.serverUrlPrefix
}/oauth/login?provider=${provider}&redirect_uri=${encodeURIComponent(
redirectUri
)}`;
}
return null;
};
export const Component = () => {
return null;
};

View File

@@ -1,55 +0,0 @@
import { useLiveData, useService } from '@toeverything/infra';
import { useEffect } from 'react';
import { type LoaderFunction, redirect } from 'react-router-dom';
import { AuthService } from '../modules/cloud';
export const loader: LoaderFunction = async ({ request }) => {
const url = new URL(request.url);
const queries = url.searchParams;
const email = queries.get('email');
const token = queries.get('token');
const redirectUri = queries.get('redirect_uri');
if (!email || !token) {
return redirect('/404');
}
const res = await fetch('/api/auth/magic-link', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, token }),
});
if (!res.ok) {
let error: string;
try {
const { message } = await res.json();
error = message;
} catch {
error = 'failed to verify sign-in token';
}
return redirect(`/signIn?error=${encodeURIComponent(error)}`);
}
location.href = redirectUri || '/';
return null;
};
export const Component = () => {
const service = useService(AuthService);
const user = useLiveData(service.session.account$);
useEffect(() => {
service.session.revalidate();
}, [service]);
// TODO(@pengx17): window.close() in electron hidden window will close main window as well
if (!environment.isDesktop && user) {
window.close();
}
// TODO(@eyhn): loading ui
return null;
};

View File

@@ -1,74 +0,0 @@
import { useLiveData, useService } from '@toeverything/infra';
import { useEffect } from 'react';
import { type LoaderFunction, redirect } from 'react-router-dom';
import { AuthService } from '../modules/cloud';
export const loader: LoaderFunction = async ({ request }) => {
const url = new URL(request.url);
const queries = url.searchParams;
const code = queries.get('code');
let stateStr = queries.get('state') ?? '{}';
let error: string | undefined;
try {
const { state, client } = JSON.parse(stateStr);
stateStr = state;
// bypass code & state to redirect_uri
if (!environment.isDesktop && client && client !== 'web') {
url.searchParams.set('state', JSON.stringify({ state }));
return redirect(
`/open-app/url?url=${encodeURIComponent(`${client}://${url.pathname}${url.search}`)}&hidden=true`
);
}
} catch {
error = 'Invalid oauth callback parameters';
}
const res = await fetch('/api/oauth/callback', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ code, state: stateStr }),
});
if (!res.ok) {
try {
const { message } = await res.json();
error = message;
} catch {
error = 'failed to verify sign-in token';
}
}
if (error) {
// TODO(@pengx17): in desktop app, the callback page will be opened in a hidden window
// how could we tell the main window to show the error message?
return redirect(`/signIn?error=${encodeURIComponent(error)}`);
} else {
const body = await res.json();
/* @deprecated handle for old client */
if (body.redirect_uri) {
return redirect(body.redirect_uri);
}
}
return null;
};
export const Component = () => {
const service = useService(AuthService);
const user = useLiveData(service.session.account$);
useEffect(() => {
service.session.revalidate();
}, [service]);
// TODO(@pengx17): window.close() in electron hidden window will close main window as well
if (!environment.isDesktop && user) {
window.close();
}
return null;
};

View File

@@ -1,7 +1,7 @@
import { AuthModal } from '@affine/core/components/affine/auth';
import { BlocksuiteHeaderTitle } from '@affine/core/components/blocksuite/block-suite-header/title';
import { EditorModeSwitch } from '@affine/core/components/blocksuite/block-suite-mode-switch';
import ShareHeaderRightItem from '@affine/core/components/cloud/share-header-right-item';
import { AuthModal } from '@affine/core/providers/modal-provider';
import type { DocMode } from '@blocksuite/blocks';
import type { DocCollection } from '@blocksuite/store';

View File

@@ -13,8 +13,8 @@ import type { ReactElement } from 'react';
import { useCallback, useEffect } from 'react';
import type { SettingAtom } from '../atoms';
import { authAtom, openSettingModalAtom, openSignOutModalAtom } from '../atoms';
import { AuthModal as Auth } from '../components/affine/auth';
import { openSettingModalAtom, openSignOutModalAtom } from '../atoms';
import { AuthModal } from '../components/affine/auth';
import { AiLoginRequiredModal } from '../components/affine/auth/ai-login-required';
import { HistoryTipsModal } from '../components/affine/history-tips-modal';
import { IssueFeedbackModal } from '../components/affine/issue-feedback-modal';
@@ -88,46 +88,6 @@ export const Setting = () => {
);
};
export const AuthModal = (): ReactElement => {
const [
{ openModal, state, email = '', emailType = 'changePassword' },
setAuthAtom,
] = useAtom(authAtom);
return (
<Auth
open={openModal}
state={state}
email={email}
emailType={emailType}
setEmailType={useCallback(
emailType => {
setAuthAtom(prev => ({ ...prev, emailType }));
},
[setAuthAtom]
)}
setOpen={useCallback(
open => {
setAuthAtom(prev => ({ ...prev, openModal: open }));
},
[setAuthAtom]
)}
setAuthState={useCallback(
state => {
setAuthAtom(prev => ({ ...prev, state }));
},
[setAuthAtom]
)}
setAuthEmail={useCallback(
email => {
setAuthAtom(prev => ({ ...prev, email }));
},
[setAuthAtom]
)}
/>
);
};
export function CurrentWorkspaceModals() {
const currentWorkspace = useService(WorkspaceService).workspace;

View File

@@ -54,10 +54,6 @@ export const topLevelRoutes = [
path: '/admin-panel',
lazy: () => import('./pages/admin-panel'),
},
{
path: '/auth/:authType',
lazy: () => import('./pages/auth'),
},
{
path: '/expired',
lazy: () => import('./pages/expired'),
@@ -66,14 +62,6 @@ export const topLevelRoutes = [
path: '/invite/:inviteId',
lazy: () => import('./pages/invite'),
},
{
path: '/signIn',
lazy: () => import('./pages/sign-in'),
},
{
path: '/magic-link',
lazy: () => import('./pages/magic-link'),
},
{
path: '/upgrade-success',
lazy: () => import('./pages/upgrade-success'),
@@ -111,18 +99,33 @@ export const topLevelRoutes = [
lazy: () => import('./pages/import-template'),
},
{
path: '/oauth/callback',
lazy: () => import('./pages/oauth-callback'),
path: '/auth/:authType',
lazy: () => import(/* webpackChunkName: "auth" */ './pages/auth/auth'),
},
{
path: '/open-app/:action',
lazy: () => import('./pages/open-app'),
path: '/signIn',
lazy: () =>
import(/* webpackChunkName: "auth" */ './pages/auth/sign-in'),
},
{
path: '/magic-link',
lazy: () =>
import(/* webpackChunkName: "auth" */ './pages/auth/magic-link'),
},
{
path: '/oauth/callback',
lazy: () =>
import(/* webpackChunkName: "auth" */ './pages/auth/oauth-callback'),
},
// deprecated, keep for old client compatibility
// TODO(@forehalo): remove
{
path: '/desktop-signin',
lazy: () => import('./pages/desktop-signin'),
lazy: () => import('./pages/auth/desktop-signin'),
},
{
path: '/open-app/:action',
lazy: () => import('./pages/open-app'),
},
{
path: '*',