) => {
- return navigate('/signIn', {
- replace: logic === RouteLogic.REPLACE,
- ...otherOptions,
- });
+ return navigate(
+ '/signIn' +
+ (redirectUri
+ ? `?redirect_uri=${encodeURIComponent(redirectUri)}`
+ : ''),
+ {
+ replace: logic === RouteLogic.REPLACE,
+ ...otherOptions,
+ }
+ );
},
[navigate]
);
diff --git a/packages/frontend/core/src/modules/cloud/entities/session.ts b/packages/frontend/core/src/modules/cloud/entities/session.ts
index 83c311236..c5dd7b664 100644
--- a/packages/frontend/core/src/modules/cloud/entities/session.ts
+++ b/packages/frontend/core/src/modules/cloud/entities/session.ts
@@ -106,9 +106,12 @@ export class AuthSession extends Entity {
}
}
- async waitForRevalidation() {
+ async waitForRevalidation(signal?: AbortSignal) {
this.revalidate();
- await this.isRevalidating$.waitFor(isRevalidating => !isRevalidating);
+ await this.isRevalidating$.waitFor(
+ isRevalidating => !isRevalidating,
+ signal
+ );
}
async removeAvatar() {
diff --git a/packages/frontend/core/src/modules/cloud/entities/subscription.ts b/packages/frontend/core/src/modules/cloud/entities/subscription.ts
index 8b3eb61e0..71393ece2 100644
--- a/packages/frontend/core/src/modules/cloud/entities/subscription.ts
+++ b/packages/frontend/core/src/modules/cloud/entities/subscription.ts
@@ -84,9 +84,12 @@ export class Subscription extends Entity {
await this.waitForRevalidation();
}
- async waitForRevalidation() {
+ async waitForRevalidation(signal?: AbortSignal) {
this.revalidate();
- await this.isRevalidating$.waitFor(isRevalidating => !isRevalidating);
+ await this.isRevalidating$.waitFor(
+ isRevalidating => !isRevalidating,
+ signal
+ );
}
revalidate = effect(
diff --git a/packages/frontend/core/src/modules/cloud/services/auth.ts b/packages/frontend/core/src/modules/cloud/services/auth.ts
index 8f955c0a3..cc7ae434b 100644
--- a/packages/frontend/core/src/modules/cloud/services/auth.ts
+++ b/packages/frontend/core/src/modules/cloud/services/auth.ts
@@ -76,18 +76,18 @@ export class AuthService extends Service {
async sendEmailMagicLink(
email: string,
verifyToken: string,
- challenge?: string
+ challenge?: string,
+ redirectUri?: string | null
) {
const searchParams = new URLSearchParams();
if (challenge) {
searchParams.set('challenge', challenge);
}
searchParams.set('token', verifyToken);
- const redirectUri = new URL(location.href);
- if (environment.isDesktop) {
- redirectUri.pathname = this.buildRedirectUri('/open-app/signin-redirect');
- }
- searchParams.set('redirect_uri', redirectUri.toString());
+ 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(),
@@ -104,7 +104,7 @@ export class AuthService extends Service {
}
}
- async signInOauth(provider: OAuthProviderType) {
+ async signInOauth(provider: OAuthProviderType, redirectUri?: string | null) {
if (environment.isDesktop) {
await apis?.ui.openExternal(
`${
@@ -117,7 +117,7 @@ export class AuthService extends Service {
location.href = `${
runtimeConfig.serverUrlPrefix
}/oauth/login?provider=${provider}&redirect_uri=${encodeURIComponent(
- location.pathname
+ redirectUri ?? location.pathname
)}`;
}
diff --git a/packages/frontend/core/src/pages/invite.tsx b/packages/frontend/core/src/pages/invite.tsx
index 69ae5bc67..8fff63943 100644
--- a/packages/frontend/core/src/pages/invite.tsx
+++ b/packages/frontend/core/src/pages/invite.tsx
@@ -79,11 +79,10 @@ export const Component = () => {
if (loginStatus === 'unauthenticated' && !isRevalidating) {
// We can not pass function to navigate state, so we need to save it in atom
setOnceSignedInEvent(openWorkspace);
- jumpToSignIn(RouteLogic.REPLACE, {
- state: {
- callbackURL: `/workspace/${inviteInfo.workspace.id}/all`,
- },
- });
+ jumpToSignIn(
+ `/workspace/${inviteInfo.workspace.id}/all`,
+ RouteLogic.REPLACE
+ );
}
}, [
inviteInfo.workspace.id,
diff --git a/packages/frontend/core/src/pages/sign-in.tsx b/packages/frontend/core/src/pages/sign-in.tsx
index 7bbf5e2fa..28b9e5084 100644
--- a/packages/frontend/core/src/pages/sign-in.tsx
+++ b/packages/frontend/core/src/pages/sign-in.tsx
@@ -5,25 +5,19 @@ import { useLiveData, useService } from '@toeverything/infra';
import { useAtom } from 'jotai';
import { useCallback, useEffect } from 'react';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
-import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
+import { useNavigate, useSearchParams } from 'react-router-dom';
import { authAtom } from '../atoms';
import type { AuthProps } from '../components/affine/auth';
import { AuthPanel } from '../components/affine/auth';
import { RouteLogic, useNavigateHelper } from '../hooks/use-navigate-helper';
-interface LocationState {
- state?: {
- callbackURL?: string;
- };
-}
export const SignIn = () => {
const [{ state, email = '', emailType = 'changePassword' }, setAuthAtom] =
useAtom(authAtom);
const session = useService(AuthService).session;
const status = useLiveData(session.status$);
const isRevalidating = useLiveData(session.isRevalidating$);
- const location = useLocation() as LocationState;
const navigate = useNavigate();
const { jumpToIndex } = useNavigateHelper();
const [searchParams] = useSearchParams();
@@ -31,8 +25,9 @@ export const SignIn = () => {
useEffect(() => {
if (isLoggedIn) {
- if (location.state?.callbackURL) {
- navigate(location.state.callbackURL, {
+ const redirectUri = searchParams.get('redirect_uri');
+ if (redirectUri) {
+ navigate(redirectUri, {
replace: true,
});
} else {
@@ -41,14 +36,7 @@ export const SignIn = () => {
});
}
}
- }, [
- jumpToIndex,
- location.state,
- navigate,
- setAuthAtom,
- isLoggedIn,
- searchParams,
- ]);
+ }, [jumpToIndex, navigate, setAuthAtom, isLoggedIn, searchParams]);
const onSetEmailType = useCallback(
(emailType: AuthProps['emailType']) => {
diff --git a/packages/frontend/core/src/pages/subscribe.css.ts b/packages/frontend/core/src/pages/subscribe.css.ts
new file mode 100644
index 000000000..bcba5554e
--- /dev/null
+++ b/packages/frontend/core/src/pages/subscribe.css.ts
@@ -0,0 +1,13 @@
+import { cssVar } from '@toeverything/theme';
+import { style } from '@vanilla-extract/css';
+
+export const container = style({
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'center',
+ height: '100vh',
+ width: '100%',
+ lineHeight: 4,
+ color: cssVar('--affine-text-secondary-color'),
+});
diff --git a/packages/frontend/core/src/pages/subscribe.tsx b/packages/frontend/core/src/pages/subscribe.tsx
new file mode 100644
index 000000000..162f0651c
--- /dev/null
+++ b/packages/frontend/core/src/pages/subscribe.tsx
@@ -0,0 +1,122 @@
+import { Button, Loading } from '@affine/component';
+import { SubscriptionPlan, SubscriptionRecurring } from '@affine/graphql';
+import { effect, fromPromise, useServices } from '@toeverything/infra';
+import { nanoid } from 'nanoid';
+import { useEffect, useMemo, useState } from 'react';
+import { useSearchParams } from 'react-router-dom';
+import { EMPTY, mergeMap, switchMap } from 'rxjs';
+
+import { RouteLogic, useNavigateHelper } from '../hooks/use-navigate-helper';
+import { AuthService, SubscriptionService } from '../modules/cloud';
+import { container } from './subscribe.css';
+
+export const Component = () => {
+ const { authService, subscriptionService } = useServices({
+ AuthService,
+ SubscriptionService,
+ });
+ const [searchParams] = useSearchParams();
+ const [message, setMessage] = useState('');
+ const [error, setError] = useState('');
+ const [retryKey, setRetryKey] = useState(0);
+ const { jumpToSignIn, jumpToIndex } = useNavigateHelper();
+ const idempotencyKey = useMemo(() => nanoid(), []);
+
+ const plan = searchParams.get('plan') as string | null;
+ const recurring = searchParams.get('recurring') as string | null;
+
+ useEffect(() => {
+ const call = effect(
+ switchMap(() => {
+ return fromPromise(async signal => {
+ retryKey;
+ // TODO: i18n
+ setMessage('Checking account status...');
+ setError('');
+ await authService.session.waitForRevalidation(signal);
+ const loggedIn =
+ authService.session.status$.value === 'authenticated';
+ if (!loggedIn) {
+ setMessage('Redirecting to sign in...');
+ jumpToSignIn(
+ location.pathname + location.search,
+ RouteLogic.REPLACE
+ );
+ return;
+ }
+ setMessage('Checking subscription status...');
+ await subscriptionService.subscription.waitForRevalidation(signal);
+ const subscribed = !!subscriptionService.subscription.ai$.value;
+ if (!subscribed) {
+ setMessage('Creating checkout...');
+ try {
+ const checkout = await subscriptionService.createCheckoutSession({
+ idempotencyKey,
+ plan:
+ plan?.toLowerCase() === 'ai'
+ ? SubscriptionPlan.AI
+ : SubscriptionPlan.Pro,
+ coupon: null,
+ recurring:
+ recurring?.toLowerCase() === 'monthly'
+ ? SubscriptionRecurring.Monthly
+ : SubscriptionRecurring.Yearly,
+ successCallbackLink: null,
+ });
+ setMessage('Redirecting...');
+ location.href = checkout;
+ } catch (err) {
+ console.error(err);
+ setError('Something went wrong. Please try again.');
+ }
+ } else {
+ setMessage('Your account is already subscribed. Redirecting...');
+ await new Promise(resolve => {
+ setTimeout(resolve, 5000);
+ });
+ jumpToIndex(RouteLogic.REPLACE);
+ }
+ }).pipe(mergeMap(() => EMPTY));
+ })
+ );
+
+ call();
+
+ return () => {
+ call.unsubscribe();
+ };
+ }, [
+ authService,
+ subscriptionService,
+ jumpToSignIn,
+ idempotencyKey,
+ plan,
+ jumpToIndex,
+ recurring,
+ retryKey,
+ ]);
+
+ useEffect(() => {
+ authService.session.revalidate();
+ }, [authService]);
+
+ return (
+
+ {!error ? (
+ <>
+ {message}
+
+
+ >
+ ) : (
+ <>
+ {error}
+
+
+ >
+ )}
+
+ );
+};
diff --git a/packages/frontend/core/src/router.tsx b/packages/frontend/core/src/router.tsx
index d362881f3..44179d521 100644
--- a/packages/frontend/core/src/router.tsx
+++ b/packages/frontend/core/src/router.tsx
@@ -96,6 +96,10 @@ export const topLevelRoutes = [
path: '/redirect-proxy',
lazy: () => import('./pages/redirect'),
},
+ {
+ path: '/subscribe',
+ lazy: () => import('./pages/subscribe'),
+ },
{
path: '*',
lazy: () => import('./pages/404'),