From a2e4ef904bf281d3c2a3969b1b7bf4b78a80a12b Mon Sep 17 00:00:00 2001 From: Peng Xiao Date: Fri, 1 Sep 2023 01:49:22 +0800 Subject: [PATCH] refactor: remove hacky email login (#4075) Co-authored-by: Alex Yang --- .../components/affine/auth/callback-url.ts | 13 ---- .../src/components/affine/auth/sign-in.tsx | 4 +- .../src/components/affine/auth/use-auth.ts | 17 +----- apps/core/src/pages/open-app.tsx | 6 +- apps/core/src/providers/modal-provider.tsx | 2 +- apps/core/src/utils/cloud-utils.tsx | 45 ++++++++++++-- apps/electron/src/main/deep-link.ts | 61 ++++--------------- apps/electron/src/main/main-window.ts | 3 +- apps/electron/src/main/protocol.ts | 42 +++---------- apps/electron/src/main/ui/events.ts | 4 +- apps/electron/src/main/ui/subject.ts | 4 +- .../src/modules/auth/next-auth-options.ts | 26 +------- packages/infra/src/type.ts | 4 +- 13 files changed, 78 insertions(+), 153 deletions(-) delete mode 100644 apps/core/src/components/affine/auth/callback-url.ts diff --git a/apps/core/src/components/affine/auth/callback-url.ts b/apps/core/src/components/affine/auth/callback-url.ts deleted file mode 100644 index 17c97beab2..0000000000 --- a/apps/core/src/components/affine/auth/callback-url.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { isDesktop } from '@affine/env/constant'; - -export function buildCallbackUrl(callbackUrl: string) { - const params: string[][] = []; - if (isDesktop && window.appInfo.schema) { - params.push(['schema', window.appInfo.schema]); - } - const query = - params.length > 0 - ? '?' + params.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&') - : ''; - return callbackUrl + query; -} diff --git a/apps/core/src/components/affine/auth/sign-in.tsx b/apps/core/src/components/affine/auth/sign-in.tsx index cfc89233a3..892e441b73 100644 --- a/apps/core/src/components/affine/auth/sign-in.tsx +++ b/apps/core/src/components/affine/auth/sign-in.tsx @@ -83,8 +83,8 @@ export const SignIn: FC = ({ marginTop: 30, }} icon={} - onClick={useCallback(async () => { - await signInWithGoogle(); + onClick={useCallback(() => { + signInWithGoogle(); }, [signInWithGoogle])} > {t['Continue with Google']()} diff --git a/apps/core/src/components/affine/auth/use-auth.ts b/apps/core/src/components/affine/auth/use-auth.ts index 5c201d8e17..a17ef89a02 100644 --- a/apps/core/src/components/affine/auth/use-auth.ts +++ b/apps/core/src/components/affine/auth/use-auth.ts @@ -1,12 +1,10 @@ import { pushNotificationAtom } from '@affine/component/notification-center'; import type { Notification } from '@affine/component/notification-center/index.jotai'; -import { isDesktop } from '@affine/env/constant'; import { atom, useAtom, useSetAtom } from 'jotai'; import { type SignInResponse } from 'next-auth/react'; import { useCallback } from 'react'; import { signInCloud } from '../../../utils/cloud-utils'; -import { buildCallbackUrl } from './callback-url'; const COUNT_DOWN_TIME = 60; const INTERNAL_BETA_URL = `https://community.affine.pro/c/insider-general/`; @@ -77,7 +75,7 @@ export const useAuth = ({ onNoAccess }: { onNoAccess: () => void }) => { const res = await signInCloud('email', { email: email, - callbackUrl: buildCallbackUrl('signIn'), + callbackUrl: '/auth/signIn', redirect: false, }).catch(console.error); @@ -100,7 +98,7 @@ export const useAuth = ({ onNoAccess }: { onNoAccess: () => void }) => { const res = await signInCloud('email', { email: email, - callbackUrl: buildCallbackUrl('signUp'), + callbackUrl: '/auth/signUp', redirect: false, }).catch(console.error); @@ -114,16 +112,7 @@ export const useAuth = ({ onNoAccess }: { onNoAccess: () => void }) => { ); const signInWithGoogle = useCallback(() => { - if (isDesktop) { - open( - `/desktop-signin?provider=google&callback_url=${buildCallbackUrl( - '/open-app/oauth-jwt' - )}`, - '_target' - ); - } else { - signInCloud('google').catch(console.error); - } + signInCloud('google').catch(console.error); }, []); return { diff --git a/apps/core/src/pages/open-app.tsx b/apps/core/src/pages/open-app.tsx index ee6dd92b57..1b44c8e337 100644 --- a/apps/core/src/pages/open-app.tsx +++ b/apps/core/src/pages/open-app.tsx @@ -76,8 +76,10 @@ const OpenAppImpl = ({ urlToOpen, channel }: OpenAppProps) => { if (!urlToOpen || lastOpened === urlToOpen || !autoOpen) { return; } - lastOpened = urlToOpen; - open(urlToOpen, '_blank'); + setTimeout(() => { + lastOpened = urlToOpen; + open(urlToOpen, '_blank'); + }, 1000); }, [urlToOpen, autoOpen]); if (!urlToOpen) { diff --git a/apps/core/src/providers/modal-provider.tsx b/apps/core/src/providers/modal-provider.tsx index a2c90d8792..ddeaeb26be 100644 --- a/apps/core/src/providers/modal-provider.tsx +++ b/apps/core/src/providers/modal-provider.tsx @@ -164,7 +164,7 @@ export const DesktopLoginModal = (): ReactElement => { useEffect(() => { return window.events?.ui.onFinishLogin(({ success, email }) => { - if (email !== signingEmail) { + if (email && email !== signingEmail) { return; } setSigningEmail(undefined); diff --git a/apps/core/src/utils/cloud-utils.tsx b/apps/core/src/utils/cloud-utils.tsx index 2d5d20b59b..f4e9ae02f8 100644 --- a/apps/core/src/utils/cloud-utils.tsx +++ b/apps/core/src/utils/cloud-utils.tsx @@ -1,15 +1,36 @@ +import { isDesktop } from '@affine/env/constant'; import { refreshRootMetadataAtom } from '@affine/workspace/atom'; import { getCurrentStore } from '@toeverything/infra/atom'; // eslint-disable-next-line @typescript-eslint/no-restricted-imports import { signIn, signOut } from 'next-auth/react'; import { startTransition } from 'react'; -export const signInCloud: typeof signIn = async (...args) => { - return signIn(...args).then(result => { - // do not refresh root metadata, - // because the session won't change in this callback - return result; - }); +export const signInCloud: typeof signIn = async (provider, ...rest) => { + if (isDesktop) { + if (provider === 'google') { + open( + `/desktop-signin?provider=google&callback_url=${buildCallbackUrl( + '/open-app/oauth-jwt' + )}`, + '_target' + ); + return; + } else if (provider === 'email') { + const [options, ...tail] = rest; + return signIn( + provider, + { + ...options, + callbackUrl: buildCallbackUrl('/open-app/oauth-jwt'), + }, + ...tail + ); + } else { + throw new Error('Unsupported provider'); + } + } else { + return signIn(provider, ...rest); + } }; export const signOutCloud: typeof signOut = async (...args) => { @@ -22,3 +43,15 @@ export const signOutCloud: typeof signOut = async (...args) => { return result; }); }; + +export function buildCallbackUrl(callbackUrl: string) { + const params: string[][] = []; + if (isDesktop && window.appInfo.schema) { + params.push(['schema', window.appInfo.schema]); + } + const query = + params.length > 0 + ? '?' + params.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&') + : ''; + return callbackUrl + query; +} diff --git a/apps/electron/src/main/deep-link.ts b/apps/electron/src/main/deep-link.ts index 0f0d81eb47..cfa6d93f3e 100644 --- a/apps/electron/src/main/deep-link.ts +++ b/apps/electron/src/main/deep-link.ts @@ -58,55 +58,11 @@ async function handleAffineUrl(url: string) { logger.info('handle affine schema action', urlObj.hostname); // handle more actions here // hostname is the action name - if (urlObj.hostname === 'sign-in') { - const urlToOpen = urlObj.search.slice(1); - if (urlToOpen) { - await handleSignIn(urlToOpen); - } - } else if (urlObj.hostname === 'oauth-jwt') { + if (urlObj.hostname === 'oauth-jwt') { await handleOauthJwt(url); } } -// todo: move to another place? -async function handleSignIn(url: string) { - if (url) { - try { - const mainWindow = await restoreOrCreateWindow(); - mainWindow.show(); - const urlObj = new URL(url); - const email = urlObj.searchParams.get('email'); - - if (!email) { - logger.error('no email in url', url); - return; - } - - uiSubjects.onStartLogin.next({ - email, - }); - const window = await handleOpenUrlInHiddenWindow(url); - logger.info('opened url in hidden window', window.webContents.getURL()); - // check path - // - if path === /auth/{signIn,signUp}, we know sign in succeeded - // - if path === expired, we know sign in failed - const finalUrl = new URL(window.webContents.getURL()); - console.log('final url', finalUrl); - // hack: wait for the hidden window to send broadcast message to the main window - // that's how next-auth works for cross-tab communication - setTimeout(() => { - window.destroy(); - }, 3000); - uiSubjects.onFinishLogin.next({ - success: ['/auth/signIn', '/auth/signUp'].includes(finalUrl.pathname), - email, - }); - } catch (e) { - logger.error('failed to open url in popup', e); - } - } -} - async function handleOauthJwt(url: string) { if (url) { try { @@ -114,6 +70,7 @@ async function handleOauthJwt(url: string) { mainWindow.show(); const urlObj = new URL(url); const token = urlObj.searchParams.get('token'); + const mainOrigin = new URL(mainWindow.webContents.getURL()).origin; if (!token) { logger.error('no token in url', url); @@ -122,14 +79,22 @@ async function handleOauthJwt(url: string) { // set token to cookie await setCookie({ - url: new URL(mainWindow.webContents.getURL()).origin, + url: mainOrigin, httpOnly: true, value: token, name: 'next-auth.session-token', }); - // force reload app - mainWindow.webContents.reload(); + // hacks to refresh auth state in the main window + const window = await handleOpenUrlInHiddenWindow( + mainOrigin + '/auth/signIn' + ); + uiSubjects.onFinishLogin.next({ + success: true, + }); + setTimeout(() => { + window.destroy(); + }, 3000); } catch (e) { logger.error('failed to open url in popup', e); } diff --git a/apps/electron/src/main/main-window.ts b/apps/electron/src/main/main-window.ts index c9dc522d1b..51fa618224 100644 --- a/apps/electron/src/main/main-window.ts +++ b/apps/electron/src/main/main-window.ts @@ -5,7 +5,6 @@ import electronWindowState from 'electron-window-state'; import { join } from 'path'; import { isMacOS, isWindows } from '../shared/utils'; -import { CLOUD_BASE_URL } from './config'; import { getExposedMeta } from './exposed'; import { ensureHelperProcess } from './helper-process'; import { logger } from './logger'; @@ -115,7 +114,7 @@ async function createWindow() { /** * URL for main window. */ - const pageUrl = CLOUD_BASE_URL; // see protocol.ts + const pageUrl = process.env.DEV_SERVER_URL || 'file://.'; // see protocol.ts logger.info('loading page at', pageUrl); diff --git a/apps/electron/src/main/protocol.ts b/apps/electron/src/main/protocol.ts index 1c1993b3ad..f442fe3a88 100644 --- a/apps/electron/src/main/protocol.ts +++ b/apps/electron/src/main/protocol.ts @@ -2,8 +2,6 @@ import { net, protocol, session } from 'electron'; import { join } from 'path'; import { CLOUD_BASE_URL } from './config'; -import { setCookie } from './main-window'; -import { simpleGet } from './utils'; const NETWORK_REQUESTS = ['/api', '/ws', '/socket.io', '/graphql']; const webStaticDir = join(__dirname, '../resources/web-static'); @@ -16,38 +14,16 @@ async function handleHttpRequest(request: Request) { const clonedRequest = Object.assign(request.clone(), { bypassCustomProtocolHandlers: true, }); - const { pathname, origin } = new URL(request.url); - if ( - !origin.startsWith(CLOUD_BASE_URL) || - isNetworkResource(pathname) || - process.env.DEV_SERVER_URL // when debugging locally - ) { - // note: I don't find a good way to get over with 302 redirect - // by default in net.fetch, or don't know if there is a way to - // bypass http request handling to browser instead ... - if (pathname.startsWith('/api/auth/callback')) { - const originResponse = await simpleGet(request.url); - // hack: use window.webContents.session.cookies to set cookies - // since return set-cookie header in response doesn't work here - for (const [, cookie] of originResponse.headers.filter( - p => p[0] === 'set-cookie' - )) { - await setCookie(origin, cookie); - } - return new Response(originResponse.body, { - headers: originResponse.headers, - status: originResponse.statusCode, - }); - } else { - // just pass through (proxy) - return net.fetch(request.url, clonedRequest); - } + const urlObject = new URL(request.url); + if (isNetworkResource(urlObject.pathname)) { + // just pass through (proxy) + return net.fetch(CLOUD_BASE_URL + urlObject.pathname, clonedRequest); } else { // this will be file types (in the web-static folder) let filepath = ''; // if is a file type, load the file in resources - if (pathname.split('/').at(-1)?.includes('.')) { - filepath = join(webStaticDir, decodeURIComponent(pathname)); + if (urlObject.pathname.split('/').at(-1)?.includes('.')) { + filepath = join(webStaticDir, decodeURIComponent(urlObject.pathname)); } else { // else, fallback to load the index.html instead filepath = join(webStaticDir, 'index.html'); @@ -57,11 +33,7 @@ async function handleHttpRequest(request: Request) { } export function registerProtocol() { - protocol.handle('http', request => { - return handleHttpRequest(request); - }); - - protocol.handle('https', request => { + protocol.handle('file', request => { return handleHttpRequest(request); }); diff --git a/apps/electron/src/main/ui/events.ts b/apps/electron/src/main/ui/events.ts index 6947a9bb5a..64f3906df4 100644 --- a/apps/electron/src/main/ui/events.ts +++ b/apps/electron/src/main/ui/events.ts @@ -6,14 +6,14 @@ import { uiSubjects } from './subject'; */ export const uiEvents = { onFinishLogin: ( - fn: (result: { success: boolean; email: string }) => void + fn: (result: { success: boolean; email?: string }) => void ) => { const sub = uiSubjects.onFinishLogin.subscribe(fn); return () => { sub.unsubscribe(); }; }, - onStartLogin: (fn: (opts: { email: string }) => void) => { + onStartLogin: (fn: (opts: { email?: string }) => void) => { const sub = uiSubjects.onStartLogin.subscribe(fn); return () => { sub.unsubscribe(); diff --git a/apps/electron/src/main/ui/subject.ts b/apps/electron/src/main/ui/subject.ts index 1bafd830a8..dcc8895481 100644 --- a/apps/electron/src/main/ui/subject.ts +++ b/apps/electron/src/main/ui/subject.ts @@ -1,6 +1,6 @@ import { Subject } from 'rxjs'; export const uiSubjects = { - onStartLogin: new Subject<{ email: string }>(), - onFinishLogin: new Subject<{ success: boolean; email: string }>(), + onStartLogin: new Subject<{ email?: string }>(), + onFinishLogin: new Subject<{ success: boolean; email?: string }>(), }; diff --git a/apps/server/src/modules/auth/next-auth-options.ts b/apps/server/src/modules/auth/next-auth-options.ts index a477ec9163..3c73306fb6 100644 --- a/apps/server/src/modules/auth/next-auth-options.ts +++ b/apps/server/src/modules/auth/next-auth-options.ts @@ -20,23 +20,6 @@ import { getUtcTimestamp, UserClaim } from './service'; export const NextAuthOptionsProvide = Symbol('NextAuthOptions'); -function getSchemaFromCallbackUrl(origin: string, callbackUrl: string) { - const { searchParams } = new URL(callbackUrl, origin); - return searchParams.has('schema') ? searchParams.get('schema') : null; -} - -function wrapUrlWithOpenApp( - origin: string, - url: string, - schema: string | null -) { - if (schema) { - const urlWithSchema = `${schema}://sign-in?${url}`; - return `${origin}/open-app?url=${encodeURIComponent(urlWithSchema)}`; - } - return url; -} - export const NextAuthOptionsProvider: FactoryProvider = { provide: NextAuthOptionsProvide, useFactory(config: Config, prisma: PrismaService, mailer: MailService) { @@ -88,17 +71,12 @@ export const NextAuthOptionsProvider: FactoryProvider = { from: config.auth.email.sender, async sendVerificationRequest(params: SendVerificationRequestParams) { const { identifier, url, provider } = params; - const { searchParams, origin } = new URL(url); + const { searchParams } = new URL(url); const callbackUrl = searchParams.get('callbackUrl') || ''; if (!callbackUrl) { throw new Error('callbackUrl is not set'); } - - const schema = getSchemaFromCallbackUrl(origin, callbackUrl); - const wrappedUrl = wrapUrlWithOpenApp(origin, url, schema); - - // hack: check if link is opened via desktop - const result = await mailer.sendSignInEmail(wrappedUrl, { + const result = await mailer.sendSignInEmail(url, { to: identifier, from: provider.from, }); diff --git a/packages/infra/src/type.ts b/packages/infra/src/type.ts index 1da92f63cb..8067d68183 100644 --- a/packages/infra/src/type.ts +++ b/packages/infra/src/type.ts @@ -266,9 +266,9 @@ export interface WorkspaceEvents { } export interface UIEvents { - onStartLogin: (fn: (options: { email: string }) => void) => () => void; + onStartLogin: (fn: (options: { email?: string }) => void) => () => void; onFinishLogin: ( - fn: (result: { success: boolean; email: string }) => void + fn: (result: { success: boolean; email?: string }) => void ) => () => void; }