diff --git a/packages/frontend/core/src/atoms/index.ts b/packages/frontend/core/src/atoms/index.ts index 094d0c323d..639ce8cefb 100644 --- a/packages/frontend/core/src/atoms/index.ts +++ b/packages/frontend/core/src/atoms/index.ts @@ -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({ 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({ +export const authAtom = atom< + AuthAtomData & { + openModal: boolean; + } +>({ openModal: false, state: 'signIn', - email: '', - emailType: 'changeEmail', }); export type AllPageFilterOption = 'docs' | 'collections' | 'tags'; diff --git a/packages/frontend/core/src/components/affine/auth/after-sign-in-send-email.tsx b/packages/frontend/core/src/components/affine/auth/after-sign-in-send-email.tsx index ef440c5c55..d3a35fd716 100644 --- a/packages/frontend/core/src/components/affine/auth/after-sign-in-send-email.tsx +++ b/packages/frontend/core/src/components/affine/auth/after-sign-in-send-email.tsx @@ -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 ( <> diff --git a/packages/frontend/core/src/components/affine/auth/after-sign-up-send-email.tsx b/packages/frontend/core/src/components/affine/auth/after-sign-up-send-email.tsx index 3880d4d90b..26933892b3 100644 --- a/packages/frontend/core/src/components/affine/auth/after-sign-up-send-email.tsx +++ b/packages/frontend/core/src/components/affine/auth/after-sign-up-send-email.tsx @@ -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 = ({ - 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 = ({ 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 = ({ { - setAuthState('signIn'); - }, [setAuthState])} + setAuthData({ state: 'signIn' }); + }, [setAuthData])} /> ); diff --git a/packages/frontend/core/src/components/affine/auth/index.tsx b/packages/frontend/core/src/components/affine/auth/index.tsx index c862431b73..6c49646ea0 100644 --- a/packages/frontend/core/src/components/affine/auth/index.tsx +++ b/packages/frontend/core/src/components/affine/auth/index.tsx @@ -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 = 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, + B extends Record, +> = Pick>; + +export type AuthPanelProps = { + setAuthData: ( + updates: { state: T } & Difference, AuthAtomType> + ) => void; +} & Extract; const config: { - [k in AuthProps['state']]: FC; + [k in AuthAtomData['state']]: FC>; } = { signIn: SignIn, afterSignUpSendEmail: AfterSignUpSendEmail, @@ -44,58 +43,100 @@ const config: { sendEmail: SendEmail, }; -export const AuthModal: FC = ({ - 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 ( - - + + ); -}; +} -export const AuthPanel: FC = ({ - 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 ( - + const setAuthData = useCallback( + (updates: Partial) => { + // @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 ; +} diff --git a/packages/frontend/core/src/components/affine/auth/oauth.tsx b/packages/frontend/core/src/components/affine/auth/oauth.tsx index 5fbc09fb57..b815bb7130 100644 --- a/packages/frontend/core/src/components/affine/auth/oauth.tsx +++ b/packages/frontend/core/src/components/affine/auth/oauth.tsx @@ -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.' }); diff --git a/packages/frontend/core/src/components/affine/auth/send-email.tsx b/packages/frontend/core/src/components/affine/auth/send-email.tsx index 9cd5803732..12674204c5 100644 --- a/packages/frontend/core/src/components/affine/auth/send-email.tsx +++ b/packages/frontend/core/src/components/affine/auth/send-email.tsx @@ -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 diff --git a/packages/frontend/core/src/components/affine/auth/sign-in-with-password.tsx b/packages/frontend/core/src/components/affine/auth/sign-in-with-password.tsx index ff0d38d0a1..96306b9ee3 100644 --- a/packages/frontend/core/src/components/affine/auth/sign-in-with-password.tsx +++ b/packages/frontend/core/src/components/affine/auth/sign-in-with-password.tsx @@ -16,11 +16,9 @@ import type { AuthPanelProps } from './index'; import * as styles from './style.css'; import { useCaptcha } from './use-captcha'; -export const SignInWithPassword: FC = ({ - setAuthState, - setEmailType, +export const SignInWithPassword: FC> = ({ + setAuthData, email, - onSignedIn, }) => { const t = useI18n(); const authService = useService(AuthService); @@ -40,14 +38,13 @@ export const SignInWithPassword: FC = ({ 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 = ({ 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 = ({ // 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 = ({ { - setAuthState('signIn'); - }, [setAuthState])} + setAuthData({ state: 'signIn' }); + }, [setAuthData])} /> ); diff --git a/packages/frontend/core/src/components/affine/auth/sign-in.tsx b/packages/frontend/core/src/components/affine/auth/sign-in.tsx index 5691dcb8db..1e38d76034 100644 --- a/packages/frontend/core/src/components/affine/auth/sign-in.tsx +++ b/packages/frontend/core/src/components/affine/auth/sign-in.tsx @@ -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 = ({ - setAuthState, - setAuthEmail, - email, - onSignedIn, +export const SignIn: FC> = ({ + 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 = ({ } setIsValidEmail(true); - setIsMutating(true); - setAuthEmail(email); try { const { hasPassword, registered } = await authService.checkUserByEmail(email); @@ -74,16 +53,25 @@ export const SignIn: FC = ({ // 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 = ({ } setIsMutating(false); - }, [authService, challenge, email, setAuthEmail, setAuthState, verifyToken]); + }, [authService, challenge, email, setAuthState, verifyToken]); return ( <> @@ -111,13 +99,7 @@ export const SignIn: FC = ({ { - setAuthEmail(value); - }, - [setAuthEmail] - )} + onChange={setEmail} error={!isValidEmail} errorHint={ isValidEmail ? '' : t['com.affine.auth.sign.email.error']() diff --git a/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx index 4fc6e73a0f..fef6ed426f 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx @@ -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', }); diff --git a/packages/frontend/core/src/components/affine/setting-modal/setting-sidebar/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/setting-sidebar/index.tsx index 2a18664d98..4105d97bae 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/setting-sidebar/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/setting-sidebar/index.tsx @@ -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 (
{ + 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; +}; diff --git a/packages/frontend/core/src/pages/auth/magic-link.tsx b/packages/frontend/core/src/pages/auth/magic-link.tsx new file mode 100644 index 0000000000..6cfaf4b81a --- /dev/null +++ b/packages/frontend/core/src/pages/auth/magic-link.tsx @@ -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; +}; diff --git a/packages/frontend/core/src/pages/auth/oauth-callback.tsx b/packages/frontend/core/src/pages/auth/oauth-callback.tsx new file mode 100644 index 0000000000..b62f72bddb --- /dev/null +++ b/packages/frontend/core/src/pages/auth/oauth-callback.tsx @@ -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; +}; diff --git a/packages/frontend/core/src/pages/sign-in.tsx b/packages/frontend/core/src/pages/auth/sign-in.tsx similarity index 54% rename from packages/frontend/core/src/pages/sign-in.tsx rename to packages/frontend/core/src/pages/auth/sign-in.tsx index 28b9e5084f..ed941f8d07 100644 --- a/packages/frontend/core/src/pages/sign-in.tsx +++ b/packages/frontend/core/src/pages/auth/sign-in.tsx @@ -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 (
- +
); diff --git a/packages/frontend/core/src/pages/desktop-signin.tsx b/packages/frontend/core/src/pages/desktop-signin.tsx deleted file mode 100644 index 1d5a06ecf1..0000000000 --- a/packages/frontend/core/src/pages/desktop-signin.tsx +++ /dev/null @@ -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; -}; diff --git a/packages/frontend/core/src/pages/magic-link.tsx b/packages/frontend/core/src/pages/magic-link.tsx deleted file mode 100644 index ff36e5952a..0000000000 --- a/packages/frontend/core/src/pages/magic-link.tsx +++ /dev/null @@ -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; -}; diff --git a/packages/frontend/core/src/pages/oauth-callback.tsx b/packages/frontend/core/src/pages/oauth-callback.tsx deleted file mode 100644 index fb996f405d..0000000000 --- a/packages/frontend/core/src/pages/oauth-callback.tsx +++ /dev/null @@ -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; -}; diff --git a/packages/frontend/core/src/pages/workspace/share/share-header.tsx b/packages/frontend/core/src/pages/workspace/share/share-header.tsx index 124d3c906c..cc57947a50 100644 --- a/packages/frontend/core/src/pages/workspace/share/share-header.tsx +++ b/packages/frontend/core/src/pages/workspace/share/share-header.tsx @@ -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'; diff --git a/packages/frontend/core/src/providers/modal-provider.tsx b/packages/frontend/core/src/providers/modal-provider.tsx index 2ca0330f22..3c9bb28f6e 100644 --- a/packages/frontend/core/src/providers/modal-provider.tsx +++ b/packages/frontend/core/src/providers/modal-provider.tsx @@ -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 ( - { - 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; diff --git a/packages/frontend/core/src/router.tsx b/packages/frontend/core/src/router.tsx index 37dbd7de02..f60d4e9c46 100644 --- a/packages/frontend/core/src/router.tsx +++ b/packages/frontend/core/src/router.tsx @@ -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: '*', diff --git a/packages/frontend/electron/renderer/app.tsx b/packages/frontend/electron/renderer/app.tsx index 4b0cad8fd2..f6413c72ad 100644 --- a/packages/frontend/electron/renderer/app.tsx +++ b/packages/frontend/electron/renderer/app.tsx @@ -33,7 +33,6 @@ import { lazy, Suspense } from 'react'; import { RouterProvider } from 'react-router-dom'; const desktopWhiteList = [ - '/desktop-signin', '/open-app/signin-redirect', '/open-app/url', '/upgrade-success', diff --git a/packages/frontend/electron/src/main/deep-link.ts b/packages/frontend/electron/src/main/deep-link.ts index b49fa3c9b4..a119cc3867 100644 --- a/packages/frontend/electron/src/main/deep-link.ts +++ b/packages/frontend/electron/src/main/deep-link.ts @@ -3,12 +3,13 @@ import path from 'node:path'; import type { App } from 'electron'; import { buildType, isDev } from './config'; -import { mainWindowOrigin } from './constants'; import { logger } from './logger'; +import { uiSubjects } from './ui'; import { getMainWindow, openUrlInHiddenWindow, openUrlInMainWindow, + showMainWindow, } from './windows-manager'; let protocol = buildType === 'stable' ? 'affine' : `affine-${buildType}`; @@ -58,31 +59,39 @@ export function setupDeepLink(app: App) { } async function handleAffineUrl(url: string) { + await showMainWindow(); + logger.info('open affine url', url); const urlObj = new URL(url); - logger.info('handle affine schema action', urlObj.hostname); - if (urlObj.hostname === 'bring-to-front') { - const mainWindow = await getMainWindow(); - if (mainWindow) { - mainWindow.show(); + if (urlObj.hostname === 'authentication') { + const method = urlObj.searchParams.get('method'); + const payload = JSON.parse(urlObj.searchParams.get('payload') ?? 'false'); + + if ( + !method || + (method !== 'magic-link' && method !== 'oauth') || + !payload + ) { + logger.error('Invalid authentication url', url); + return; } + + uiSubjects.authenticationRequest$.next({ + method, + payload, + }); } else { - await openUrl(urlObj); - } -} - -async function openUrl(urlObj: URL) { - const params = urlObj.searchParams; - - const openInHiddenWindow = params.get('hidden'); - params.delete('hidden'); - - const url = mainWindowOrigin + urlObj.pathname + '?' + params.toString(); - if (!openInHiddenWindow) { - await openUrlInHiddenWindow(url); - } else { - // TODO(@pengx17): somehow the page won't load the url passed, help needed - await openUrlInMainWindow(url); + const hiddenWindow = urlObj.searchParams.get('hidden') + ? await openUrlInHiddenWindow(urlObj) + : await openUrlInMainWindow(urlObj); + + const main = await getMainWindow(); + if (main && hiddenWindow) { + // when hidden window closed, the main window will be hidden somehow + hiddenWindow.on('close', () => { + main.show(); + }); + } } } diff --git a/packages/frontend/electron/src/main/ui/events.ts b/packages/frontend/electron/src/main/ui/events.ts index b8e213ca81..01461f1166 100644 --- a/packages/frontend/electron/src/main/ui/events.ts +++ b/packages/frontend/electron/src/main/ui/events.ts @@ -1,5 +1,6 @@ import type { MainEventRegister } from '../type'; import { + type AuthenticationRequest, onActiveTabChanged, onTabAction, onTabShellViewActiveChange, @@ -35,4 +36,10 @@ export const uiEvents = { onTabsStatusChange, onActiveTabChanged, onTabShellViewActiveChange, + onAuthenticationRequest: (fn: (state: AuthenticationRequest) => void) => { + const sub = uiSubjects.authenticationRequest$.subscribe(fn); + return () => { + sub.unsubscribe(); + }; + }, } satisfies Record; diff --git a/packages/frontend/electron/src/main/ui/subject.ts b/packages/frontend/electron/src/main/ui/subject.ts index a69158c8f8..d2832c462e 100644 --- a/packages/frontend/electron/src/main/ui/subject.ts +++ b/packages/frontend/electron/src/main/ui/subject.ts @@ -1,7 +1,10 @@ import { Subject } from 'rxjs'; +import type { AuthenticationRequest } from '../windows-manager'; + export const uiSubjects = { onMaximized$: new Subject(), onFullScreen$: new Subject(), onToggleRightSidebar$: new Subject(), + authenticationRequest$: new Subject(), }; diff --git a/packages/frontend/electron/src/main/windows-manager/authentication.ts b/packages/frontend/electron/src/main/windows-manager/authentication.ts new file mode 100644 index 0000000000..7a70e91452 --- /dev/null +++ b/packages/frontend/electron/src/main/windows-manager/authentication.ts @@ -0,0 +1,4 @@ +export interface AuthenticationRequest { + method: 'magic-link' | 'oauth'; + payload: Record; +} diff --git a/packages/frontend/electron/src/main/windows-manager/index.ts b/packages/frontend/electron/src/main/windows-manager/index.ts index 8b2e79e3d8..2a231cdd66 100644 --- a/packages/frontend/electron/src/main/windows-manager/index.ts +++ b/packages/frontend/electron/src/main/windows-manager/index.ts @@ -1,3 +1,4 @@ +export * from './authentication'; export * from './launcher'; export * from './main-window'; export * from './onboarding'; diff --git a/packages/frontend/electron/src/main/windows-manager/main-window.ts b/packages/frontend/electron/src/main/windows-manager/main-window.ts index 00ee021f1e..076cff77c2 100644 --- a/packages/frontend/electron/src/main/windows-manager/main-window.ts +++ b/packages/frontend/electron/src/main/windows-manager/main-window.ts @@ -6,6 +6,7 @@ import { BehaviorSubject } from 'rxjs'; import { isLinux, isMacOS, isWindows } from '../../shared/utils'; import { buildType } from '../config'; +import { mainWindowOrigin } from '../constants'; import { ensureHelperProcess } from '../helper-process'; import { logger } from '../logger'; import { uiSubjects } from '../ui/subject'; @@ -227,33 +228,60 @@ export async function showMainWindow() { window.focus(); } +const getWindowAdditionalArguments = async () => { + const { getExposedMeta } = await import('../exposed'); + const mainExposedMeta = getExposedMeta(); + return [ + `--main-exposed-meta=` + JSON.stringify(mainExposedMeta), + `--window-name=hidden-window`, + ]; +}; + +function transformToAppUrl(url: URL) { + const params = url.searchParams; + return mainWindowOrigin + url.pathname + '?' + params.toString(); +} + /** * Open a URL in a hidden window. */ -export async function openUrlInHiddenWindow(url: string) { +export async function openUrlInHiddenWindow(urlObj: URL) { + const url = transformToAppUrl(urlObj); const win = new BrowserWindow({ width: 1200, height: 600, webPreferences: { preload: join(__dirname, './preload.js'), + additionalArguments: await getWindowAdditionalArguments(), }, show: environment.isDebug, }); + + if (environment.isDebug) { + win.webContents.openDevTools(); + } + win.on('close', e => { e.preventDefault(); - if (!win.isDestroyed()) { + if (win && !win.isDestroyed()) { win.destroy(); } }); logger.info('loading page at', url); - await win.loadURL(url); + win.loadURL(url).catch(e => { + logger.error('failed to load url', e); + }); return win; } -export async function openUrlInMainWindow(url: string) { +// TODO(@pengx17): somehow the page won't load the url passed, help needed +export async function openUrlInMainWindow(urlObj: URL) { + const url = transformToAppUrl(urlObj); + logger.info('loading page at', url); const mainWindow = await getMainWindow(); if (mainWindow) { - mainWindow.show(); await mainWindow.loadURL(url); } + + return null; } diff --git a/packages/frontend/mobile/src/pages/sign-in.tsx b/packages/frontend/mobile/src/pages/sign-in.tsx index 3687f114f1..10a9ec3277 100644 --- a/packages/frontend/mobile/src/pages/sign-in.tsx +++ b/packages/frontend/mobile/src/pages/sign-in.tsx @@ -1,5 +1,5 @@ // Default route fallback for mobile -import { SignIn } from '@affine/core/pages/sign-in'; +import { SignIn } from '@affine/core/pages/auth/sign-in'; export const Component = () => { // placeholder impl