diff --git a/apps/core/src/components/affine/auth/after-sign-in-send-email.tsx b/apps/core/src/components/affine/auth/after-sign-in-send-email.tsx index 339d1d2b6..646c3b686 100644 --- a/apps/core/src/components/affine/auth/after-sign-in-send-email.tsx +++ b/apps/core/src/components/affine/auth/after-sign-in-send-email.tsx @@ -35,7 +35,7 @@ export const AfterSignInSendEmail: FC = ({ onClick={useCallback(() => { signInCloud('email', { email, - callbackUrl: buildCallbackUrl('signIn'), + callbackUrl: buildCallbackUrl('/auth/signIn'), redirect: true, }).catch(console.error); }, [email])} diff --git a/apps/core/src/components/affine/auth/after-sign-up-send-email.tsx b/apps/core/src/components/affine/auth/after-sign-up-send-email.tsx index 39d4233a1..68ae46cbb 100644 --- a/apps/core/src/components/affine/auth/after-sign-up-send-email.tsx +++ b/apps/core/src/components/affine/auth/after-sign-up-send-email.tsx @@ -34,7 +34,7 @@ export const AfterSignUpSendEmail: FC = ({ onClick={useCallback(() => { signInCloud('email', { email: email, - callbackUrl: buildCallbackUrl('signUp'), + callbackUrl: buildCallbackUrl('/auth/signUp'), redirect: true, }).catch(console.error); }, [email])} diff --git a/apps/core/src/components/affine/auth/callback-url.ts b/apps/core/src/components/affine/auth/callback-url.ts index cbc2086b6..17c97beab 100644 --- a/apps/core/src/components/affine/auth/callback-url.ts +++ b/apps/core/src/components/affine/auth/callback-url.ts @@ -1,9 +1,6 @@ import { isDesktop } from '@affine/env/constant'; -type Action = 'signUp' | 'changePassword' | 'signIn' | 'signUp'; - -export function buildCallbackUrl(action: Action) { - const callbackUrl = `/auth/${action}`; +export function buildCallbackUrl(callbackUrl: string) { const params: string[][] = []; if (isDesktop && window.appInfo.schema) { params.push(['schema', window.appInfo.schema]); diff --git a/apps/core/src/components/affine/auth/sign-in.tsx b/apps/core/src/components/affine/auth/sign-in.tsx index 2a30cb3dd..3fc8db151 100644 --- a/apps/core/src/components/affine/auth/sign-in.tsx +++ b/apps/core/src/components/affine/auth/sign-in.tsx @@ -1,6 +1,7 @@ import { AuthInput, ModalHeader } from '@affine/component/auth-components'; import { pushNotificationAtom } from '@affine/component/notification-center'; import type { Notification } from '@affine/component/notification-center/index.jotai'; +import { isDesktop } from '@affine/env/constant'; import { getUserQuery } from '@affine/graphql'; import { Trans } from '@affine/i18n'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; @@ -69,7 +70,7 @@ export const SignIn: FC = ({ if (user) { signInCloud('email', { email: email, - callbackUrl: buildCallbackUrl('signIn'), + callbackUrl: buildCallbackUrl('/auth/signIn'), redirect: false, }) .then(res => handleSendEmailError(res, pushNotification)) @@ -78,7 +79,7 @@ export const SignIn: FC = ({ } else { signInCloud('email', { email: email, - callbackUrl: buildCallbackUrl('signUp'), + callbackUrl: buildCallbackUrl('/auth/signUp'), redirect: false, }) .then(res => handleSendEmailError(res, pushNotification)) @@ -103,7 +104,16 @@ export const SignIn: FC = ({ }} icon={} onClick={useCallback(() => { - signInCloud('google').catch(console.error); + if (isDesktop) { + open( + `/desktop-signin?provider=google&callback_url=${buildCallbackUrl( + '/open-app/oauth-jwt' + )}`, + '_target' + ); + } else { + signInCloud('google').catch(console.error); + } }, [])} > {t['Continue with Google']()} diff --git a/apps/core/src/pages/desktop-signin.tsx b/apps/core/src/pages/desktop-signin.tsx new file mode 100644 index 000000000..de957fffe --- /dev/null +++ b/apps/core/src/pages/desktop-signin.tsx @@ -0,0 +1,28 @@ +import type { LoaderFunction } from 'react-router-dom'; +import { z } from 'zod'; + +import { signInCloud } from '../utils/cloud-utils'; + +const supportedProvider = z.enum(['google']); + +export const loader: LoaderFunction = async ({ request }) => { + const url = new URL(request.url); + const searchParams = url.searchParams; + const provider = searchParams.get('provider'); + const callback_url = searchParams.get('callback_url'); + if (!callback_url) { + return null; + } + const maybeProvider = supportedProvider.safeParse(provider); + if (maybeProvider.success) { + const provider = maybeProvider.data; + await signInCloud(provider, { + callbackUrl: callback_url, + }); + } + return null; +}; + +export const Component = () => { + return null; +}; diff --git a/apps/core/src/pages/open-app.tsx b/apps/core/src/pages/open-app.tsx index 7652adb0a..ee6dd92b5 100644 --- a/apps/core/src/pages/open-app.tsx +++ b/apps/core/src/pages/open-app.tsx @@ -1,9 +1,15 @@ +import { type GetCurrentUserQuery, getCurrentUserQuery } from '@affine/graphql'; import { Trans } from '@affine/i18n'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { fetcher } from '@affine/workspace/affine/gql'; import { Logo1Icon } from '@blocksuite/icons'; import { Button } from '@toeverything/components/button'; import { useCallback, useEffect, useMemo } from 'react'; -import { useSearchParams } from 'react-router-dom'; +import { + type LoaderFunction, + useLoaderData, + useSearchParams, +} from 'react-router-dom'; import { z } from 'zod'; import * as styles from './open-app.css'; @@ -45,24 +51,26 @@ const appNames = { internal: 'AFFiNE Internal', } satisfies Record; -export const Component = () => { +interface OpenAppProps { + urlToOpen?: string | null; + channel: Channel; +} + +interface LoaderData { + action: 'url' | 'oauth-jwt'; + currentUser?: GetCurrentUserQuery['currentUser']; +} + +const OpenAppImpl = ({ urlToOpen, channel }: OpenAppProps) => { const t = useAFFiNEI18N(); - const [params] = useSearchParams(); - const urlToOpen = useMemo(() => params.get('url'), [params]); - const autoOpen = useMemo(() => params.get('open') !== 'false', [params]); - const channel = useMemo(() => { - const urlObj = new URL(urlToOpen || ''); - const maybeSchema = appSchemas.safeParse(urlObj.protocol.replace(':', '')); - return schemaToChanel[maybeSchema.success ? maybeSchema.data : 'affine']; - }, [urlToOpen]); - - const appIcon = appIconMap[channel]; - const appName = appNames[channel]; - const openDownloadLink = useCallback(() => { const url = `https://affine.pro/download?channel=${channel}`; open(url, '_blank'); }, [channel]); + const appIcon = appIconMap[channel]; + const appName = appNames[channel]; + const [params] = useSearchParams(); + const autoOpen = useMemo(() => params.get('open') !== 'false', [params]); useEffect(() => { if (!urlToOpen || lastOpened === urlToOpen || !autoOpen) { @@ -72,80 +80,133 @@ export const Component = () => { open(urlToOpen, '_blank'); }, [urlToOpen, autoOpen]); - if (urlToOpen) { - return ( -
-
+ if (!urlToOpen) { + return null; + } + + return ( +
+
+ + + + +
- + Official Website - - - - -
- - + +
- ); - } else { + +
+ {appName} + +
+ + Open {appName} app now + +
+ + + {t['com.affine.auth.open.affine.try-again']()} + +
+
+ ); +}; + +const OpenUrl = () => { + const [params] = useSearchParams(); + const urlToOpen = useMemo(() => params.get('url'), [params]); + const channel = useMemo(() => { + const urlObj = new URL(urlToOpen || ''); + const maybeSchema = appSchemas.safeParse(urlObj.protocol.replace(':', '')); + return schemaToChanel[maybeSchema.success ? maybeSchema.data : 'affine']; + }, [urlToOpen]); + + return ; +}; + +const OpenOAuthJwt = () => { + const { currentUser } = useLoaderData() as LoaderData; + const [params] = useSearchParams(); + const schema = useMemo(() => { + const maybeSchema = appSchemas.safeParse(params.get('schema')); + return maybeSchema.success ? maybeSchema.data : 'affine'; + }, [params]); + const channel = schemaToChanel[schema as Schema]; + + if (!currentUser || !currentUser?.token?.token) { return null; } + + const urlToOpen = `${schema}://oauth-jwt?token=${currentUser.token.token}`; + + return ; +}; + +export const Component = () => { + const { action } = useLoaderData() as LoaderData; + + if (action === 'url') { + return ; + } else if (action === 'oauth-jwt') { + return ; + } + return null; +}; + +export const loader: LoaderFunction = async args => { + const action = args.params.action || ''; + const res = await fetcher({ + query: getCurrentUserQuery, + }).catch(console.error); + + return { + action, + currentUser: res?.currentUser || null, + }; }; diff --git a/apps/core/src/router.ts b/apps/core/src/router.ts index 7b616def9..b067d9d7e 100644 --- a/apps/core/src/router.ts +++ b/apps/core/src/router.ts @@ -49,9 +49,13 @@ export const routes = [ lazy: () => import('./pages/sign-in'), }, { - path: '/open-app', + path: '/open-app/:action', lazy: () => import('./pages/open-app'), }, + { + path: '/desktop-signin', + lazy: () => import('./pages/desktop-signin'), + }, { path: '*', lazy: () => import('./pages/404'), diff --git a/apps/electron/src/main/deep-link.ts b/apps/electron/src/main/deep-link.ts index af9c13eb0..0f0d81eb4 100644 --- a/apps/electron/src/main/deep-link.ts +++ b/apps/electron/src/main/deep-link.ts @@ -7,6 +7,7 @@ import { logger } from './logger'; import { handleOpenUrlInHiddenWindow, restoreOrCreateWindow, + setCookie, } from './main-window'; import { uiSubjects } from './ui'; @@ -62,6 +63,8 @@ async function handleAffineUrl(url: string) { if (urlToOpen) { await handleSignIn(urlToOpen); } + } else if (urlObj.hostname === 'oauth-jwt') { + await handleOauthJwt(url); } } @@ -103,3 +106,32 @@ async function handleSignIn(url: string) { } } } + +async function handleOauthJwt(url: string) { + if (url) { + try { + const mainWindow = await restoreOrCreateWindow(); + mainWindow.show(); + const urlObj = new URL(url); + const token = urlObj.searchParams.get('token'); + + if (!token) { + logger.error('no token in url', url); + return; + } + + // set token to cookie + await setCookie({ + url: new URL(mainWindow.webContents.getURL()).origin, + httpOnly: true, + value: token, + name: 'next-auth.session-token', + }); + + // force reload app + mainWindow.webContents.reload(); + } 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 05f65bab9..c9dc522d1 100644 --- a/apps/electron/src/main/main-window.ts +++ b/apps/electron/src/main/main-window.ts @@ -1,6 +1,6 @@ import assert from 'node:assert'; -import { BrowserWindow, nativeTheme } from 'electron'; +import { BrowserWindow, type CookiesSetDetails, nativeTheme } from 'electron'; import electronWindowState from 'electron-window-state'; import { join } from 'path'; @@ -174,7 +174,22 @@ export function reloadApp() { browserWindow?.reload(); } -export async function setCookie(origin: string, cookie: string) { +export async function setCookie(cookie: CookiesSetDetails): Promise; +export async function setCookie(origin: string, cookie: string): Promise; + +export async function setCookie( + arg0: CookiesSetDetails | string, + arg1?: string +) { const window = await restoreOrCreateWindow(); - await window.webContents.session.cookies.set(parseCookie(cookie, origin)); + const details = + typeof arg1 === 'string' && typeof arg0 === 'string' + ? parseCookie(arg0, arg1) + : arg0; + + if (typeof details !== 'object') { + throw new Error('invalid cookie details'); + } + + await window.webContents.session.cookies.set(details); } diff --git a/apps/server/src/modules/auth/resolver.ts b/apps/server/src/modules/auth/resolver.ts index 50a7b1e21..478808358 100644 --- a/apps/server/src/modules/auth/resolver.ts +++ b/apps/server/src/modules/auth/resolver.ts @@ -34,7 +34,7 @@ export class AuthResolver { @ResolveField(() => TokenType) token(@CurrentUser() currentUser: UserType, @Parent() user: UserType) { - if (user !== currentUser) { + if (user.id !== currentUser.id) { throw new ForbiddenException(); } diff --git a/apps/server/src/modules/users/resolver.ts b/apps/server/src/modules/users/resolver.ts index c2f25b863..b4aefdcf3 100644 --- a/apps/server/src/modules/users/resolver.ts +++ b/apps/server/src/modules/users/resolver.ts @@ -83,14 +83,20 @@ export class UserResolver { description: 'Get current user', }) async currentUser(@CurrentUser() user: User) { + const storedUser = await this.prisma.user.findUnique({ + where: { id: user.id }, + }); + if (!storedUser) { + throw new BadRequestException(`User ${user.id} not found in db`); + } return { - id: user.id, - name: user.name, - email: user.email, - emailVerified: user.emailVerified, - avatarUrl: user.avatarUrl, - createdAt: user.createdAt, - hasPassword: !!user.password, + id: storedUser.id, + name: storedUser.name, + email: storedUser.email, + emailVerified: storedUser.emailVerified, + avatarUrl: storedUser.avatarUrl, + createdAt: storedUser.createdAt, + hasPassword: !!storedUser.password, }; } diff --git a/packages/graphql/src/graphql/get-current-user.gql b/packages/graphql/src/graphql/get-current-user.gql index e7d6b7e04..385592eaf 100644 --- a/packages/graphql/src/graphql/get-current-user.gql +++ b/packages/graphql/src/graphql/get-current-user.gql @@ -6,5 +6,8 @@ query getCurrentUser { emailVerified avatarUrl createdAt + token { + token + } } } diff --git a/packages/graphql/src/graphql/index.ts b/packages/graphql/src/graphql/index.ts index 5bf4d85b9..db24fd9a2 100644 --- a/packages/graphql/src/graphql/index.ts +++ b/packages/graphql/src/graphql/index.ts @@ -125,6 +125,9 @@ query getCurrentUser { emailVerified avatarUrl createdAt + token { + token + } } }`, }; diff --git a/packages/graphql/src/schema.ts b/packages/graphql/src/schema.ts index 9b9842a4f..20384868c 100644 --- a/packages/graphql/src/schema.ts +++ b/packages/graphql/src/schema.ts @@ -147,6 +147,7 @@ export type GetCurrentUserQuery = { emailVerified: string | null; avatarUrl: string | null; createdAt: string | null; + token: { __typename?: 'TokenType'; token: string }; }; };