feat: support google login on desktop (#4053)

Co-authored-by: Alex Yang <himself65@outlook.com>
This commit is contained in:
Peng Xiao
2023-08-31 12:51:49 +08:00
committed by GitHub
parent ba735d8b57
commit 4e45554585
14 changed files with 256 additions and 96 deletions
@@ -35,7 +35,7 @@ export const AfterSignInSendEmail: FC<AuthPanelProps> = ({
onClick={useCallback(() => {
signInCloud('email', {
email,
callbackUrl: buildCallbackUrl('signIn'),
callbackUrl: buildCallbackUrl('/auth/signIn'),
redirect: true,
}).catch(console.error);
}, [email])}
@@ -34,7 +34,7 @@ export const AfterSignUpSendEmail: FC<AuthPanelProps> = ({
onClick={useCallback(() => {
signInCloud('email', {
email: email,
callbackUrl: buildCallbackUrl('signUp'),
callbackUrl: buildCallbackUrl('/auth/signUp'),
redirect: true,
}).catch(console.error);
}, [email])}
@@ -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]);
@@ -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<AuthPanelProps> = ({
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<AuthPanelProps> = ({
} 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<AuthPanelProps> = ({
}}
icon={<GoogleDuotoneIcon />}
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']()}
+28
View File
@@ -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;
};
+136 -75
View File
@@ -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<Channel, string>;
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 (
<div className={styles.root}>
<div className={styles.topNav}>
if (!urlToOpen) {
return null;
}
return (
<div className={styles.root}>
<div className={styles.topNav}>
<a
href="https://affine.pro"
target="_blank"
rel="noreferrer"
className={styles.affineLogo}
>
<Logo1Icon width={24} height={24} />
</a>
<div className={styles.topNavLinks}>
<a
href="https://affine.pro"
target="_blank"
rel="noreferrer"
className={styles.affineLogo}
className={styles.topNavLink}
>
<Logo1Icon width={24} height={24} />
Official Website
</a>
<div className={styles.topNavLinks}>
<a
href="https://affine.pro"
target="_blank"
rel="noreferrer"
className={styles.topNavLink}
>
Official Website
</a>
<a
href="https://community.affine.pro/home"
target="_blank"
rel="noreferrer"
className={styles.topNavLink}
>
AFFiNE Community
</a>
<a
href="https://affine.pro/blog"
target="_blank"
rel="noreferrer"
className={styles.topNavLink}
>
Blog
</a>
<a
href="https://affine.pro/about-us"
target="_blank"
rel="noreferrer"
className={styles.topNavLink}
>
Contact us
</a>
</div>
<Button onClick={openDownloadLink}>
{t['com.affine.auth.open.affine.download-app']()}
</Button>
</div>
<div className={styles.centerContent}>
<img src={appIcon} alt={appName} width={120} height={120} />
<div className={styles.prompt}>
<Trans i18nKey="com.affine.auth.open.affine.prompt">
Open {appName} app now
</Trans>
</div>
<a
className={styles.tryAgainLink}
href={urlToOpen}
href="https://community.affine.pro/home"
target="_blank"
rel="noreferrer"
className={styles.topNavLink}
>
{t['com.affine.auth.open.affine.try-again']()}
AFFiNE Community
</a>
<a
href="https://affine.pro/blog"
target="_blank"
rel="noreferrer"
className={styles.topNavLink}
>
Blog
</a>
<a
href="https://affine.pro/about-us"
target="_blank"
rel="noreferrer"
className={styles.topNavLink}
>
Contact us
</a>
</div>
<Button onClick={openDownloadLink}>
{t['com.affine.auth.open.affine.download-app']()}
</Button>
</div>
);
} else {
<div className={styles.centerContent}>
<img src={appIcon} alt={appName} width={120} height={120} />
<div className={styles.prompt}>
<Trans i18nKey="com.affine.auth.open.affine.prompt">
Open {appName} app now
</Trans>
</div>
<a
className={styles.tryAgainLink}
href={urlToOpen}
target="_blank"
rel="noreferrer"
>
{t['com.affine.auth.open.affine.try-again']()}
</a>
</div>
</div>
);
};
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 <OpenAppImpl urlToOpen={urlToOpen} channel={channel} />;
};
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 <OpenAppImpl urlToOpen={urlToOpen} channel={channel} />;
};
export const Component = () => {
const { action } = useLoaderData() as LoaderData;
if (action === 'url') {
return <OpenUrl />;
} else if (action === 'oauth-jwt') {
return <OpenOAuthJwt />;
}
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,
};
};
+5 -1
View File
@@ -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'),
+32
View File
@@ -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);
}
}
}
+18 -3
View File
@@ -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<void>;
export async function setCookie(origin: string, cookie: string): Promise<void>;
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);
}
+1 -1
View File
@@ -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();
}
+13 -7
View File
@@ -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,
};
}
@@ -6,5 +6,8 @@ query getCurrentUser {
emailVerified
avatarUrl
createdAt
token {
token
}
}
}
+3
View File
@@ -125,6 +125,9 @@ query getCurrentUser {
emailVerified
avatarUrl
createdAt
token {
token
}
}
}`,
};
+1
View File
@@ -147,6 +147,7 @@ export type GetCurrentUserQuery = {
emailVerified: string | null;
avatarUrl: string | null;
createdAt: string | null;
token: { __typename?: 'TokenType'; token: string };
};
};