mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-21 12:06:35 +08:00
@@ -126,6 +126,8 @@ export const OnboardingPage = ({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deprecated
|
||||||
|
// TODO(@forehalo): remove
|
||||||
if (callbackUrl?.startsWith('/open-app/signin-redirect')) {
|
if (callbackUrl?.startsWith('/open-app/signin-redirect')) {
|
||||||
const url = new URL(callbackUrl, window.location.origin);
|
const url = new URL(callbackUrl, window.location.origin);
|
||||||
url.searchParams.set('next', 'onboarding');
|
url.searchParams.set('next', 'onboarding');
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const OAuthProviderMap: Record<
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function OAuth({ redirectUri }: { redirectUri?: string | null }) {
|
export function OAuth() {
|
||||||
const serverConfig = useService(ServerConfigService).serverConfig;
|
const serverConfig = useService(ServerConfigService).serverConfig;
|
||||||
const oauth = useLiveData(serverConfig.features$.map(r => r?.oauth));
|
const oauth = useLiveData(serverConfig.features$.map(r => r?.oauth));
|
||||||
const oauthProviders = useLiveData(
|
const oauthProviders = useLiveData(
|
||||||
@@ -47,21 +47,11 @@ export function OAuth({ redirectUri }: { redirectUri?: string | null }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return oauthProviders?.map(provider => (
|
return oauthProviders?.map(provider => (
|
||||||
<OAuthProvider
|
<OAuthProvider key={provider} provider={provider} />
|
||||||
key={provider}
|
|
||||||
provider={provider}
|
|
||||||
redirectUri={redirectUri}
|
|
||||||
/>
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
function OAuthProvider({
|
function OAuthProvider({ provider }: { provider: OAuthProviderType }) {
|
||||||
provider,
|
|
||||||
redirectUri,
|
|
||||||
}: {
|
|
||||||
provider: OAuthProviderType;
|
|
||||||
redirectUri?: string | null;
|
|
||||||
}) {
|
|
||||||
const { icon } = OAuthProviderMap[provider];
|
const { icon } = OAuthProviderMap[provider];
|
||||||
const authService = useService(AuthService);
|
const authService = useService(AuthService);
|
||||||
const [isConnecting, setIsConnecting] = useState(false);
|
const [isConnecting, setIsConnecting] = useState(false);
|
||||||
@@ -69,7 +59,7 @@ function OAuthProvider({
|
|||||||
const onClick = useAsyncCallback(async () => {
|
const onClick = useAsyncCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setIsConnecting(true);
|
setIsConnecting(true);
|
||||||
await authService.signInOauth(provider, redirectUri);
|
await authService.signInOauth(provider);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
notify.error({ title: 'Failed to sign in, please try again.' });
|
notify.error({ title: 'Failed to sign in, please try again.' });
|
||||||
@@ -77,7 +67,7 @@ function OAuthProvider({
|
|||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
track.$.$.auth.oauth({ provider });
|
track.$.$.auth.oauth({ provider });
|
||||||
}
|
}
|
||||||
}, [authService, provider, redirectUri]);
|
}, [authService, provider]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export const SignIn: FC<AuthPanelProps> = ({
|
|||||||
|
|
||||||
const [isValidEmail, setIsValidEmail] = useState(true);
|
const [isValidEmail, setIsValidEmail] = useState(true);
|
||||||
const { openModal } = useAtomValue(authAtom);
|
const { openModal } = useAtomValue(authAtom);
|
||||||
|
const errorMsg = searchParams.get('error');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout = setInterval(() => {
|
const timeout = setInterval(() => {
|
||||||
@@ -65,32 +66,22 @@ export const SignIn: FC<AuthPanelProps> = ({
|
|||||||
|
|
||||||
setAuthEmail(email);
|
setAuthEmail(email);
|
||||||
try {
|
try {
|
||||||
const { hasPassword, isExist: isUserExist } =
|
const { hasPassword, registered } =
|
||||||
await authService.checkUserByEmail(email);
|
await authService.checkUserByEmail(email);
|
||||||
|
|
||||||
if (verifyToken) {
|
if (verifyToken) {
|
||||||
if (isUserExist) {
|
if (registered) {
|
||||||
// provider password sign-in if user has by default
|
// provider password sign-in if user has by default
|
||||||
// If with payment, onl support email sign in to avoid redirect to affine app
|
// If with payment, onl support email sign in to avoid redirect to affine app
|
||||||
if (hasPassword) {
|
if (hasPassword) {
|
||||||
setAuthState('signInWithPassword');
|
setAuthState('signInWithPassword');
|
||||||
} else {
|
} else {
|
||||||
track.$.$.auth.signIn();
|
track.$.$.auth.signIn();
|
||||||
await authService.sendEmailMagicLink(
|
await authService.sendEmailMagicLink(email, verifyToken, challenge);
|
||||||
email,
|
|
||||||
verifyToken,
|
|
||||||
challenge,
|
|
||||||
searchParams.get('redirect_uri')
|
|
||||||
);
|
|
||||||
setAuthState('afterSignInSendEmail');
|
setAuthState('afterSignInSendEmail');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await authService.sendEmailMagicLink(
|
await authService.sendEmailMagicLink(email, verifyToken, challenge);
|
||||||
email,
|
|
||||||
verifyToken,
|
|
||||||
challenge,
|
|
||||||
searchParams.get('redirect_uri')
|
|
||||||
);
|
|
||||||
track.$.$.auth.signUp();
|
track.$.$.auth.signUp();
|
||||||
setAuthState('afterSignUpSendEmail');
|
setAuthState('afterSignUpSendEmail');
|
||||||
}
|
}
|
||||||
@@ -105,15 +96,7 @@ export const SignIn: FC<AuthPanelProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsMutating(false);
|
setIsMutating(false);
|
||||||
}, [
|
}, [authService, challenge, email, setAuthEmail, setAuthState, verifyToken]);
|
||||||
authService,
|
|
||||||
challenge,
|
|
||||||
email,
|
|
||||||
searchParams,
|
|
||||||
setAuthEmail,
|
|
||||||
setAuthState,
|
|
||||||
verifyToken,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -122,7 +105,7 @@ export const SignIn: FC<AuthPanelProps> = ({
|
|||||||
subTitle={t['com.affine.brand.affineCloud']()}
|
subTitle={t['com.affine.brand.affineCloud']()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<OAuth redirectUri={searchParams.get('redirect_uri')} />
|
<OAuth />
|
||||||
|
|
||||||
<div className={style.authModalContent}>
|
<div className={style.authModalContent}>
|
||||||
<AuthInput
|
<AuthInput
|
||||||
@@ -142,8 +125,6 @@ export const SignIn: FC<AuthPanelProps> = ({
|
|||||||
onEnter={onContinue}
|
onEnter={onContinue}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{verifyToken ? null : <Captcha />}
|
|
||||||
|
|
||||||
{verifyToken ? (
|
{verifyToken ? (
|
||||||
<Button
|
<Button
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
@@ -157,7 +138,11 @@ export const SignIn: FC<AuthPanelProps> = ({
|
|||||||
>
|
>
|
||||||
{t['com.affine.auth.sign.email.continue']()}
|
{t['com.affine.auth.sign.email.continue']()}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : (
|
||||||
|
<Captcha />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{errorMsg && <div className={style.errorMessage}>{errorMsg}</div>}
|
||||||
|
|
||||||
<div className={style.authMessage}>
|
<div className={style.authMessage}>
|
||||||
{/*prettier-ignore*/}
|
{/*prettier-ignore*/}
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ export const authMessage = style({
|
|||||||
fontSize: cssVar('fontXs'),
|
fontSize: cssVar('fontXs'),
|
||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const errorMessage = style({
|
||||||
|
marginTop: '30px',
|
||||||
|
color: cssVar('textHighlightForegroundRed'),
|
||||||
|
fontSize: cssVar('fontXs'),
|
||||||
|
lineHeight: 1.5,
|
||||||
|
});
|
||||||
|
|
||||||
globalStyle(`${authMessage} a`, {
|
globalStyle(`${authMessage} a`, {
|
||||||
color: cssVar('linkColor'),
|
color: cssVar('linkColor'),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { AIProvider } from '@affine/core/blocksuite/presets/ai';
|
import { AIProvider } from '@affine/core/blocksuite/presets/ai';
|
||||||
|
import { buildAppUrl, popupWindow } from '@affine/core/utils';
|
||||||
import { apis, appInfo } from '@affine/electron-api';
|
import { apis, appInfo } from '@affine/electron-api';
|
||||||
import type { OAuthProviderType } from '@affine/graphql';
|
import type { OAuthProviderType } from '@affine/graphql';
|
||||||
import {
|
import {
|
||||||
@@ -80,72 +81,82 @@ export class AuthService extends Service {
|
|||||||
async sendEmailMagicLink(
|
async sendEmailMagicLink(
|
||||||
email: string,
|
email: string,
|
||||||
verifyToken: string,
|
verifyToken: string,
|
||||||
challenge?: string,
|
challenge?: string
|
||||||
redirectUri?: string | null
|
|
||||||
) {
|
) {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
if (challenge) {
|
if (challenge) {
|
||||||
searchParams.set('challenge', challenge);
|
searchParams.set('challenge', challenge);
|
||||||
}
|
}
|
||||||
searchParams.set('token', verifyToken);
|
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(
|
const res = await this.fetchService.fetch(
|
||||||
'/api/auth/sign-in?' + searchParams.toString(),
|
'/api/auth/sign-in?' + searchParams.toString(),
|
||||||
{
|
{
|
||||||
method: 'POST',
|
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: {
|
headers: {
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
if (!res?.ok) {
|
if (!res.ok) {
|
||||||
throw new Error('Failed to send email');
|
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) {
|
if (environment.isDesktop) {
|
||||||
await apis?.ui.openExternal(
|
await apis?.ui.openExternal(url);
|
||||||
`${
|
|
||||||
runtimeConfig.serverUrlPrefix
|
|
||||||
}/desktop-signin?provider=${provider}&redirect_uri=${this.buildRedirectUri(
|
|
||||||
'/open-app/signin-redirect'
|
|
||||||
)}`
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
location.href = `${
|
popupWindow(url);
|
||||||
runtimeConfig.serverUrlPrefix
|
|
||||||
}/oauth/login?provider=${provider}&redirect_uri=${encodeURIComponent(
|
|
||||||
redirectUri ?? location.pathname
|
|
||||||
)}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
async signInPassword(credential: { email: string; password: string }) {
|
async signInPassword(credential: { email: string; password: string }) {
|
||||||
const searchParams = new URLSearchParams();
|
const res = await this.fetchService.fetch('/api/auth/sign-in', {
|
||||||
const redirectUri = new URL(location.href);
|
method: 'POST',
|
||||||
if (environment.isDesktop) {
|
body: JSON.stringify(credential),
|
||||||
redirectUri.pathname = this.buildRedirectUri('/open-app/signin-redirect');
|
headers: {
|
||||||
}
|
'content-type': 'application/json',
|
||||||
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',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error('Failed to sign in');
|
throw new Error('Failed to sign in');
|
||||||
}
|
}
|
||||||
@@ -158,19 +169,6 @@ export class AuthService extends Service {
|
|||||||
this.session.revalidate();
|
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) {
|
checkUserByEmail(email: string) {
|
||||||
return this.store.checkUserByEmail(email);
|
return this.store.checkUserByEmail(email);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
getUserQuery,
|
|
||||||
removeAvatarMutation,
|
removeAvatarMutation,
|
||||||
updateUserProfileMutation,
|
updateUserProfileMutation,
|
||||||
uploadAvatarMutation,
|
uploadAvatarMutation,
|
||||||
@@ -81,15 +80,23 @@ export class AuthStore extends Store {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async checkUserByEmail(email: string) {
|
async checkUserByEmail(email: string) {
|
||||||
const data = await this.gqlService.gql({
|
const res = await this.fetchService.fetch('/api/auth/preflight', {
|
||||||
query: getUserQuery,
|
method: 'POST',
|
||||||
variables: {
|
body: JSON.stringify({ email }),
|
||||||
email,
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return {
|
|
||||||
isExist: !!data.user,
|
if (!res.ok) {
|
||||||
hasPassword: !!data.user?.hasPassword,
|
throw new Error(`Failed to check user by email: ${email}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
registered: boolean;
|
||||||
|
hasPassword: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
import { useLiveData, useService } from '@toeverything/infra';
|
||||||
|
import { useEffect } from 'react';
|
||||||
import { type LoaderFunction, redirect } from 'react-router-dom';
|
import { type LoaderFunction, redirect } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { AuthService } from '../modules/cloud';
|
||||||
|
|
||||||
export const loader: LoaderFunction = async ({ request }) => {
|
export const loader: LoaderFunction = async ({ request }) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const queries = url.searchParams;
|
const queries = url.searchParams;
|
||||||
@@ -35,6 +39,17 @@ export const loader: LoaderFunction = async ({ request }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const Component = () => {
|
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
|
// TODO(@eyhn): loading ui
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -148,24 +148,31 @@ const OpenAppImpl = ({ urlToOpen, channel }: OpenAppProps) => {
|
|||||||
|
|
||||||
const OpenUrl = () => {
|
const OpenUrl = () => {
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const urlToOpen = useMemo(() => params.get('url'), [params]);
|
const urlToOpen = params.get('url');
|
||||||
const channel = useMemo(() => {
|
params.delete('url');
|
||||||
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 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 <OpenAppImpl urlToOpen={urlObj.toString()} channel={channel} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
const OpenOAuthJwt = () => {
|
const OpenOAuthJwt = () => {
|
||||||
const { currentUser } = useLoaderData() as LoaderData;
|
const { currentUser } = useLoaderData() as LoaderData;
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const schema = useMemo(() => {
|
|
||||||
const maybeSchema = appSchemas.safeParse(params.get('schema'));
|
const maybeSchema = appSchemas.safeParse(params.get('schema'));
|
||||||
return maybeSchema.success ? maybeSchema.data : 'affine';
|
const schema = maybeSchema.success ? maybeSchema.data : 'affine';
|
||||||
}, [params]);
|
const next = params.get('next');
|
||||||
const next = useMemo(() => params.get('next'), [params]);
|
|
||||||
const channel = schemaToChanel[schema as Schema];
|
const channel = schemaToChanel[schema as Schema];
|
||||||
|
|
||||||
if (!currentUser || !currentUser?.token?.sessionToken) {
|
if (!currentUser || !currentUser?.token?.sessionToken) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { DebugLogger } from '@affine/debug';
|
|||||||
import { type LoaderFunction, Navigate, useLoaderData } from 'react-router-dom';
|
import { type LoaderFunction, Navigate, useLoaderData } from 'react-router-dom';
|
||||||
|
|
||||||
const trustedDomain = [
|
const trustedDomain = [
|
||||||
|
'google.com',
|
||||||
'stripe.com',
|
'stripe.com',
|
||||||
'github.com',
|
'github.com',
|
||||||
'twitter.com',
|
'twitter.com',
|
||||||
|
|||||||
@@ -74,10 +74,6 @@ export const topLevelRoutes = [
|
|||||||
path: '/magic-link',
|
path: '/magic-link',
|
||||||
lazy: () => import('./pages/magic-link'),
|
lazy: () => import('./pages/magic-link'),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/open-app/:action',
|
|
||||||
lazy: () => import('./pages/open-app'),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/upgrade-success',
|
path: '/upgrade-success',
|
||||||
lazy: () => import('./pages/upgrade-success'),
|
lazy: () => import('./pages/upgrade-success'),
|
||||||
@@ -86,10 +82,6 @@ export const topLevelRoutes = [
|
|||||||
path: '/ai-upgrade-success',
|
path: '/ai-upgrade-success',
|
||||||
lazy: () => import('./pages/ai-upgrade-success'),
|
lazy: () => import('./pages/ai-upgrade-success'),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/desktop-signin',
|
|
||||||
lazy: () => import('./pages/desktop-signin'),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/onboarding',
|
path: '/onboarding',
|
||||||
lazy: () => import('./pages/onboarding'),
|
lazy: () => import('./pages/onboarding'),
|
||||||
@@ -118,6 +110,20 @@ export const topLevelRoutes = [
|
|||||||
path: '/template/import',
|
path: '/template/import',
|
||||||
lazy: () => import('./pages/import-template'),
|
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: '*',
|
path: '*',
|
||||||
lazy: () => import('./pages/404'),
|
lazy: () => import('./pages/404'),
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ export * from './popup';
|
|||||||
export * from './string2color';
|
export * from './string2color';
|
||||||
export * from './toast';
|
export * from './toast';
|
||||||
export * from './unflatten-object';
|
export * from './unflatten-object';
|
||||||
|
export * from './url';
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,8 @@ const desktopWhiteList = [
|
|||||||
'/upgrade-success',
|
'/upgrade-success',
|
||||||
'/ai-upgrade-success',
|
'/ai-upgrade-success',
|
||||||
'/share',
|
'/share',
|
||||||
|
'/oauth',
|
||||||
|
'/magic-link',
|
||||||
];
|
];
|
||||||
if (
|
if (
|
||||||
!environment.isDesktop &&
|
!environment.isDesktop &&
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import path from 'node:path';
|
|||||||
|
|
||||||
import type { App } from 'electron';
|
import type { App } from 'electron';
|
||||||
|
|
||||||
import { buildType, CLOUD_BASE_URL, isDev } from './config';
|
import { buildType, isDev } from './config';
|
||||||
import { mainWindowOrigin } from './constants';
|
import { mainWindowOrigin } from './constants';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
import {
|
import {
|
||||||
getMainWindow,
|
getMainWindow,
|
||||||
handleOpenUrlInHiddenWindow,
|
openUrlInHiddenWindow,
|
||||||
setCookie,
|
openUrlInMainWindow,
|
||||||
} from './windows-manager';
|
} from './windows-manager';
|
||||||
|
|
||||||
let protocol = buildType === 'stable' ? 'affine' : `affine-${buildType}`;
|
let protocol = buildType === 'stable' ? 'affine' : `affine-${buildType}`;
|
||||||
@@ -61,51 +61,28 @@ async function handleAffineUrl(url: string) {
|
|||||||
logger.info('open affine url', url);
|
logger.info('open affine url', url);
|
||||||
const urlObj = new URL(url);
|
const urlObj = new URL(url);
|
||||||
logger.info('handle affine schema action', urlObj.hostname);
|
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') {
|
if (urlObj.hostname === 'bring-to-front') {
|
||||||
const mainWindow = await getMainWindow();
|
const mainWindow = await getMainWindow();
|
||||||
if (mainWindow) {
|
if (mainWindow) {
|
||||||
mainWindow.show();
|
mainWindow.show();
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
await openUrl(urlObj);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleOauthJwt(url: string) {
|
async function openUrl(urlObj: URL) {
|
||||||
const mainWindow = await getMainWindow();
|
const params = urlObj.searchParams;
|
||||||
if (url && mainWindow) {
|
|
||||||
try {
|
|
||||||
mainWindow.show();
|
|
||||||
const urlObj = new URL(url);
|
|
||||||
const token = urlObj.searchParams.get('token');
|
|
||||||
|
|
||||||
if (!token) {
|
const openInHiddenWindow = params.get('hidden');
|
||||||
logger.error('no token in url', url);
|
params.delete('hidden');
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// set token to cookie
|
const url = mainWindowOrigin + urlObj.pathname + '?' + params.toString();
|
||||||
await setCookie({
|
if (!openInHiddenWindow) {
|
||||||
url: CLOUD_BASE_URL,
|
await openUrlInHiddenWindow(url);
|
||||||
httpOnly: true,
|
} else {
|
||||||
value: token,
|
// TODO(@pengx17): somehow the page won't load the url passed, help needed
|
||||||
secure: true,
|
await openUrlInMainWindow(url);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,16 +229,15 @@ export async function showMainWindow() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Open a URL in a hidden window.
|
* 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({
|
const win = new BrowserWindow({
|
||||||
width: 1200,
|
width: 1200,
|
||||||
height: 600,
|
height: 600,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: join(__dirname, './preload.js'),
|
preload: join(__dirname, './preload.js'),
|
||||||
},
|
},
|
||||||
show: false,
|
show: environment.isDebug,
|
||||||
});
|
});
|
||||||
win.on('close', e => {
|
win.on('close', e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -250,3 +249,11 @@ export async function handleOpenUrlInHiddenWindow(url: string) {
|
|||||||
await win.loadURL(url);
|
await win.loadURL(url);
|
||||||
return win;
|
return win;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function openUrlInMainWindow(url: string) {
|
||||||
|
const mainWindow = await getMainWindow();
|
||||||
|
if (mainWindow) {
|
||||||
|
mainWindow.show();
|
||||||
|
await mainWindow.loadURL(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,12 +29,20 @@ export function getElectronAPIs() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Schema =
|
||||||
|
| 'affine'
|
||||||
|
| 'affine-canary'
|
||||||
|
| 'affine-beta'
|
||||||
|
| 'affine-internal'
|
||||||
|
| 'affine-dev';
|
||||||
|
|
||||||
// todo: remove duplicated codes
|
// todo: remove duplicated codes
|
||||||
const ReleaseTypeSchema = z.enum(['stable', 'beta', 'canary', 'internal']);
|
const ReleaseTypeSchema = z.enum(['stable', 'beta', 'canary', 'internal']);
|
||||||
const envBuildType = (process.env.BUILD_TYPE || 'canary').trim().toLowerCase();
|
const envBuildType = (process.env.BUILD_TYPE || 'canary').trim().toLowerCase();
|
||||||
const buildType = ReleaseTypeSchema.parse(envBuildType);
|
const buildType = ReleaseTypeSchema.parse(envBuildType);
|
||||||
const isDev = process.env.NODE_ENV === 'development';
|
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;
|
schema = isDev ? 'affine-dev' : schema;
|
||||||
|
|
||||||
export const appInfo = {
|
export const appInfo = {
|
||||||
@@ -45,7 +53,7 @@ export const appInfo = {
|
|||||||
viewId:
|
viewId:
|
||||||
process.argv.find(arg => arg.startsWith('--view-id='))?.split('=')[1] ??
|
process.argv.find(arg => arg.startsWith('--view-id='))?.split('=')[1] ??
|
||||||
'unknown',
|
'unknown',
|
||||||
schema: `${schema}`,
|
schema,
|
||||||
};
|
};
|
||||||
|
|
||||||
function getMainAPIs() {
|
function getMainAPIs() {
|
||||||
|
|||||||
@@ -412,7 +412,6 @@ export const createConfiguration: (
|
|||||||
{ context: '/api', target: 'http://localhost:3010' },
|
{ context: '/api', target: 'http://localhost:3010' },
|
||||||
{ context: '/socket.io', target: 'http://localhost:3010', ws: true },
|
{ context: '/socket.io', target: 'http://localhost:3010', ws: true },
|
||||||
{ context: '/graphql', target: 'http://localhost:3010' },
|
{ context: '/graphql', target: 'http://localhost:3010' },
|
||||||
{ context: '/oauth', target: 'http://localhost:3010' },
|
|
||||||
],
|
],
|
||||||
} as DevServerConfiguration,
|
} as DevServerConfiguration,
|
||||||
} satisfies webpack.Configuration;
|
} satisfies webpack.Configuration;
|
||||||
|
|||||||
Reference in New Issue
Block a user