= ({
onEnter={onContinue}
/>
- {verifyToken ? null : }
-
{verifyToken ? (
- ) : null}
+ ) : (
+
+ )}
+
+ {errorMsg && {errorMsg}
}
{/*prettier-ignore*/}
diff --git a/packages/frontend/core/src/components/affine/auth/style.css.ts b/packages/frontend/core/src/components/affine/auth/style.css.ts
index cd7695528..fdbe8d55b 100644
--- a/packages/frontend/core/src/components/affine/auth/style.css.ts
+++ b/packages/frontend/core/src/components/affine/auth/style.css.ts
@@ -14,6 +14,14 @@ export const authMessage = style({
fontSize: cssVar('fontXs'),
lineHeight: 1.5,
});
+
+export const errorMessage = style({
+ marginTop: '30px',
+ color: cssVar('textHighlightForegroundRed'),
+ fontSize: cssVar('fontXs'),
+ lineHeight: 1.5,
+});
+
globalStyle(`${authMessage} a`, {
color: cssVar('linkColor'),
});
diff --git a/packages/frontend/core/src/modules/cloud/services/auth.ts b/packages/frontend/core/src/modules/cloud/services/auth.ts
index 9fea3730c..f4ee13e2b 100644
--- a/packages/frontend/core/src/modules/cloud/services/auth.ts
+++ b/packages/frontend/core/src/modules/cloud/services/auth.ts
@@ -1,4 +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 type { OAuthProviderType } from '@affine/graphql';
import {
@@ -80,72 +81,82 @@ export class AuthService extends Service {
async sendEmailMagicLink(
email: string,
verifyToken: string,
- challenge?: string,
- redirectUri?: string | null
+ challenge?: string
) {
const searchParams = new URLSearchParams();
if (challenge) {
searchParams.set('challenge', challenge);
}
searchParams.set('token', verifyToken);
- const redirect = environment.isDesktop
- ? this.buildRedirectUri('/open-app/signin-redirect')
- : (redirectUri ?? location.href);
- searchParams.set('redirect_uri', redirect.toString());
const res = await this.fetchService.fetch(
'/api/auth/sign-in?' + searchParams.toString(),
{
method: 'POST',
- body: JSON.stringify({ email }),
+ body: JSON.stringify({
+ 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,
+ }),
+ }),
headers: {
'content-type': 'application/json',
},
}
);
- if (!res?.ok) {
+ if (!res.ok) {
throw new Error('Failed to send email');
}
}
- async signInOauth(provider: OAuthProviderType, redirectUri?: string | null) {
+ async signInOauth(provider: OAuthProviderType) {
+ const res = await this.fetchService.fetch('/api/oauth/preflight', {
+ method: 'POST',
+ body: JSON.stringify({ provider }),
+ 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}`
+ // so we could know the callback should be redirect to native app
+ const oauthUrl = new URL(url);
+ oauthUrl.searchParams.set(
+ 'state',
+ JSON.stringify({
+ state: oauthUrl.searchParams.get('state'),
+ client: environment.isDesktop ? appInfo?.schema : 'web',
+ })
+ );
+ url = oauthUrl.toString();
+
if (environment.isDesktop) {
- await apis?.ui.openExternal(
- `${
- runtimeConfig.serverUrlPrefix
- }/desktop-signin?provider=${provider}&redirect_uri=${this.buildRedirectUri(
- '/open-app/signin-redirect'
- )}`
- );
+ await apis?.ui.openExternal(url);
} else {
- location.href = `${
- runtimeConfig.serverUrlPrefix
- }/oauth/login?provider=${provider}&redirect_uri=${encodeURIComponent(
- redirectUri ?? location.pathname
- )}`;
+ popupWindow(url);
}
return;
}
async signInPassword(credential: { email: string; password: string }) {
- const searchParams = new URLSearchParams();
- const redirectUri = new URL(location.href);
- if (environment.isDesktop) {
- redirectUri.pathname = this.buildRedirectUri('/open-app/signin-redirect');
- }
- searchParams.set('redirect_uri', redirectUri.toString());
-
- const res = await this.fetchService.fetch(
- '/api/auth/sign-in?' + searchParams.toString(),
- {
- method: 'POST',
- body: JSON.stringify(credential),
- headers: {
- 'content-type': 'application/json',
- },
- }
- );
+ const res = await this.fetchService.fetch('/api/auth/sign-in', {
+ method: 'POST',
+ body: JSON.stringify(credential),
+ headers: {
+ 'content-type': 'application/json',
+ },
+ });
if (!res.ok) {
throw new Error('Failed to sign in');
}
@@ -158,19 +169,6 @@ export class AuthService extends Service {
this.session.revalidate();
}
- private buildRedirectUri(callbackUrl: string) {
- const params: string[][] = [];
- if (environment.isDesktop && appInfo?.schema) {
- params.push(['schema', appInfo.schema]);
- }
- const query =
- params.length > 0
- ? '?' +
- params.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&')
- : '';
- return callbackUrl + query;
- }
-
checkUserByEmail(email: string) {
return this.store.checkUserByEmail(email);
}
diff --git a/packages/frontend/core/src/modules/cloud/stores/auth.ts b/packages/frontend/core/src/modules/cloud/stores/auth.ts
index 535fb1778..06e860d70 100644
--- a/packages/frontend/core/src/modules/cloud/stores/auth.ts
+++ b/packages/frontend/core/src/modules/cloud/stores/auth.ts
@@ -1,5 +1,4 @@
import {
- getUserQuery,
removeAvatarMutation,
updateUserProfileMutation,
uploadAvatarMutation,
@@ -81,15 +80,23 @@ export class AuthStore extends Store {
}
async checkUserByEmail(email: string) {
- const data = await this.gqlService.gql({
- query: getUserQuery,
- variables: {
- email,
+ const res = await this.fetchService.fetch('/api/auth/preflight', {
+ method: 'POST',
+ body: JSON.stringify({ email }),
+ headers: {
+ 'content-type': 'application/json',
},
});
- return {
- isExist: !!data.user,
- hasPassword: !!data.user?.hasPassword,
+
+ if (!res.ok) {
+ throw new Error(`Failed to check user by email: ${email}`);
+ }
+
+ const data = (await res.json()) as {
+ registered: boolean;
+ hasPassword: boolean;
};
+
+ return data;
}
}
diff --git a/packages/frontend/core/src/pages/magic-link.tsx b/packages/frontend/core/src/pages/magic-link.tsx
index 6f833c425..ff36e5952 100644
--- a/packages/frontend/core/src/pages/magic-link.tsx
+++ b/packages/frontend/core/src/pages/magic-link.tsx
@@ -1,5 +1,9 @@
+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;
@@ -35,6 +39,17 @@ export const loader: LoaderFunction = async ({ request }) => {
};
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
new file mode 100644
index 000000000..fb996f405
--- /dev/null
+++ b/packages/frontend/core/src/pages/oauth-callback.tsx
@@ -0,0 +1,74 @@
+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/open-app.tsx b/packages/frontend/core/src/pages/open-app.tsx
index ff84e73a5..3276a4f03 100644
--- a/packages/frontend/core/src/pages/open-app.tsx
+++ b/packages/frontend/core/src/pages/open-app.tsx
@@ -148,24 +148,31 @@ const OpenAppImpl = ({ urlToOpen, channel }: OpenAppProps) => {
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]);
+ const urlToOpen = params.get('url');
+ params.delete('url');
- return ;
+ const urlObj = new URL(urlToOpen || '');
+ const maybeSchema = appSchemas.safeParse(urlObj.protocol.replace(':', ''));
+ const channel =
+ schemaToChanel[maybeSchema.success ? maybeSchema.data : 'affine'];
+
+ params.forEach((v, k) => {
+ urlObj.searchParams.set(k, v);
+ });
+
+ return ;
};
+/**
+ * @deprecated
+ */
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 next = useMemo(() => params.get('next'), [params]);
+
+ const maybeSchema = appSchemas.safeParse(params.get('schema'));
+ const schema = maybeSchema.success ? maybeSchema.data : 'affine';
+ const next = params.get('next');
const channel = schemaToChanel[schema as Schema];
if (!currentUser || !currentUser?.token?.sessionToken) {
diff --git a/packages/frontend/core/src/pages/redirect.tsx b/packages/frontend/core/src/pages/redirect.tsx
index d4869f475..fad78a794 100644
--- a/packages/frontend/core/src/pages/redirect.tsx
+++ b/packages/frontend/core/src/pages/redirect.tsx
@@ -2,6 +2,7 @@ import { DebugLogger } from '@affine/debug';
import { type LoaderFunction, Navigate, useLoaderData } from 'react-router-dom';
const trustedDomain = [
+ 'google.com',
'stripe.com',
'github.com',
'twitter.com',
diff --git a/packages/frontend/core/src/router.tsx b/packages/frontend/core/src/router.tsx
index 3fe3d4567..37dbd7de0 100644
--- a/packages/frontend/core/src/router.tsx
+++ b/packages/frontend/core/src/router.tsx
@@ -74,10 +74,6 @@ export const topLevelRoutes = [
path: '/magic-link',
lazy: () => import('./pages/magic-link'),
},
- {
- path: '/open-app/:action',
- lazy: () => import('./pages/open-app'),
- },
{
path: '/upgrade-success',
lazy: () => import('./pages/upgrade-success'),
@@ -86,10 +82,6 @@ export const topLevelRoutes = [
path: '/ai-upgrade-success',
lazy: () => import('./pages/ai-upgrade-success'),
},
- {
- path: '/desktop-signin',
- lazy: () => import('./pages/desktop-signin'),
- },
{
path: '/onboarding',
lazy: () => import('./pages/onboarding'),
@@ -118,6 +110,20 @@ export const topLevelRoutes = [
path: '/template/import',
lazy: () => import('./pages/import-template'),
},
+ {
+ path: '/oauth/callback',
+ lazy: () => import('./pages/oauth-callback'),
+ },
+ {
+ path: '/open-app/:action',
+ lazy: () => import('./pages/open-app'),
+ },
+ // deprecated, keep for old client compatibility
+ // TODO(@forehalo): remove
+ {
+ path: '/desktop-signin',
+ lazy: () => import('./pages/desktop-signin'),
+ },
{
path: '*',
lazy: () => import('./pages/404'),
diff --git a/packages/frontend/core/src/utils/index.ts b/packages/frontend/core/src/utils/index.ts
index c83da23b3..2311d8c22 100644
--- a/packages/frontend/core/src/utils/index.ts
+++ b/packages/frontend/core/src/utils/index.ts
@@ -6,3 +6,4 @@ export * from './popup';
export * from './string2color';
export * from './toast';
export * from './unflatten-object';
+export * from './url';
diff --git a/packages/frontend/core/src/utils/url.ts b/packages/frontend/core/src/utils/url.ts
new file mode 100644
index 000000000..b916efefe
--- /dev/null
+++ b/packages/frontend/core/src/utils/url.ts
@@ -0,0 +1,33 @@
+import { appInfo } from '@affine/electron-api';
+
+interface AppUrlOptions {
+ desktop?: boolean | string;
+ openInHiddenWindow?: boolean;
+ redirectFromWeb?: boolean;
+}
+
+export function buildAppUrl(path: string, opts: AppUrlOptions = {}) {
+ // TODO(@EYHN): should use server base url
+ const webBase = runtimeConfig.serverUrlPrefix;
+ // TODO(@pengx17): how could we know the corresponding app schema in web environment
+ if (opts.desktop && appInfo?.schema) {
+ const urlCtor = new URL(path, webBase);
+
+ if (opts.openInHiddenWindow) {
+ urlCtor.searchParams.set('hidden', 'true');
+ }
+
+ const url = `${appInfo.schema}://${urlCtor.pathname}${urlCtor.search}`;
+
+ if (opts.redirectFromWeb) {
+ const redirect_uri = new URL('/open-app/url', webBase);
+ redirect_uri.searchParams.set('url', url);
+
+ return redirect_uri.toString();
+ }
+
+ return url;
+ } else {
+ return new URL(path, webBase).toString();
+ }
+}
diff --git a/packages/frontend/electron/renderer/app.tsx b/packages/frontend/electron/renderer/app.tsx
index f3a68287c..4b0cad8fd 100644
--- a/packages/frontend/electron/renderer/app.tsx
+++ b/packages/frontend/electron/renderer/app.tsx
@@ -39,6 +39,8 @@ const desktopWhiteList = [
'/upgrade-success',
'/ai-upgrade-success',
'/share',
+ '/oauth',
+ '/magic-link',
];
if (
!environment.isDesktop &&
diff --git a/packages/frontend/electron/src/main/deep-link.ts b/packages/frontend/electron/src/main/deep-link.ts
index f4d7a8f63..b49fa3c9b 100644
--- a/packages/frontend/electron/src/main/deep-link.ts
+++ b/packages/frontend/electron/src/main/deep-link.ts
@@ -2,13 +2,13 @@ import path from 'node:path';
import type { App } from 'electron';
-import { buildType, CLOUD_BASE_URL, isDev } from './config';
+import { buildType, isDev } from './config';
import { mainWindowOrigin } from './constants';
import { logger } from './logger';
import {
getMainWindow,
- handleOpenUrlInHiddenWindow,
- setCookie,
+ openUrlInHiddenWindow,
+ openUrlInMainWindow,
} from './windows-manager';
let protocol = buildType === 'stable' ? 'affine' : `affine-${buildType}`;
@@ -61,51 +61,28 @@ async function handleAffineUrl(url: string) {
logger.info('open affine url', url);
const urlObj = new URL(url);
logger.info('handle affine schema action', urlObj.hostname);
- // handle more actions here
- // hostname is the action name
- if (urlObj.hostname === 'signin-redirect') {
- await handleOauthJwt(url);
- }
+
if (urlObj.hostname === 'bring-to-front') {
const mainWindow = await getMainWindow();
if (mainWindow) {
mainWindow.show();
}
+ } else {
+ await openUrl(urlObj);
}
}
-async function handleOauthJwt(url: string) {
- const mainWindow = await getMainWindow();
- if (url && mainWindow) {
- try {
- mainWindow.show();
- const urlObj = new URL(url);
- const token = urlObj.searchParams.get('token');
+async function openUrl(urlObj: URL) {
+ const params = urlObj.searchParams;
- if (!token) {
- logger.error('no token in url', url);
- return;
- }
+ const openInHiddenWindow = params.get('hidden');
+ params.delete('hidden');
- // set token to cookie
- await setCookie({
- url: CLOUD_BASE_URL,
- httpOnly: true,
- value: token,
- secure: true,
- name: 'affine_session',
- expirationDate: Math.floor(
- Date.now() / 1000 +
- 3600 *
- 24 *
- 399 /* as long as possible, cookie max expires is 400 days */
- ),
- });
-
- // hacks to refresh auth state in the main window
- await handleOpenUrlInHiddenWindow(mainWindowOrigin + '/auth/signIn');
- } catch (e) {
- logger.error('failed to open url in popup', e);
- }
+ 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);
}
}
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 65eff862d..00ee021f1 100644
--- a/packages/frontend/electron/src/main/windows-manager/main-window.ts
+++ b/packages/frontend/electron/src/main/windows-manager/main-window.ts
@@ -229,16 +229,15 @@ export async function showMainWindow() {
/**
* Open a URL in a hidden window.
- * This is useful for opening a URL in the background without user interaction for *authentication*.
*/
-export async function handleOpenUrlInHiddenWindow(url: string) {
+export async function openUrlInHiddenWindow(url: string) {
const win = new BrowserWindow({
width: 1200,
height: 600,
webPreferences: {
preload: join(__dirname, './preload.js'),
},
- show: false,
+ show: environment.isDebug,
});
win.on('close', e => {
e.preventDefault();
@@ -250,3 +249,11 @@ export async function handleOpenUrlInHiddenWindow(url: string) {
await win.loadURL(url);
return win;
}
+
+export async function openUrlInMainWindow(url: string) {
+ const mainWindow = await getMainWindow();
+ if (mainWindow) {
+ mainWindow.show();
+ await mainWindow.loadURL(url);
+ }
+}
diff --git a/packages/frontend/electron/src/preload/electron-api.ts b/packages/frontend/electron/src/preload/electron-api.ts
index 9299ff4d5..6b47e3693 100644
--- a/packages/frontend/electron/src/preload/electron-api.ts
+++ b/packages/frontend/electron/src/preload/electron-api.ts
@@ -29,12 +29,20 @@ export function getElectronAPIs() {
};
}
+type Schema =
+ | 'affine'
+ | 'affine-canary'
+ | 'affine-beta'
+ | 'affine-internal'
+ | 'affine-dev';
+
// todo: remove duplicated codes
const ReleaseTypeSchema = z.enum(['stable', 'beta', 'canary', 'internal']);
const envBuildType = (process.env.BUILD_TYPE || 'canary').trim().toLowerCase();
const buildType = ReleaseTypeSchema.parse(envBuildType);
const isDev = process.env.NODE_ENV === 'development';
-let schema = buildType === 'stable' ? 'affine' : `affine-${envBuildType}`;
+let schema =
+ buildType === 'stable' ? 'affine' : (`affine-${envBuildType}` as Schema);
schema = isDev ? 'affine-dev' : schema;
export const appInfo = {
@@ -45,7 +53,7 @@ export const appInfo = {
viewId:
process.argv.find(arg => arg.startsWith('--view-id='))?.split('=')[1] ??
'unknown',
- schema: `${schema}`,
+ schema,
};
function getMainAPIs() {
diff --git a/tools/cli/src/webpack/config.ts b/tools/cli/src/webpack/config.ts
index e11d67a37..6b69c67e6 100644
--- a/tools/cli/src/webpack/config.ts
+++ b/tools/cli/src/webpack/config.ts
@@ -412,7 +412,6 @@ export const createConfiguration: (
{ context: '/api', target: 'http://localhost:3010' },
{ context: '/socket.io', target: 'http://localhost:3010', ws: true },
{ context: '/graphql', target: 'http://localhost:3010' },
- { context: '/oauth', target: 'http://localhost:3010' },
],
} as DevServerConfiguration,
} satisfies webpack.Configuration;