feat(core): add subscribe link (#6610)
This commit is contained in:
@@ -25,7 +25,7 @@ const OAuthProviderMap: Record<
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function OAuth() {
|
export function OAuth({ redirectUri }: { redirectUri?: string | null }) {
|
||||||
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(
|
||||||
@@ -42,11 +42,21 @@ export function OAuth() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return oauthProviders?.map(provider => (
|
return oauthProviders?.map(provider => (
|
||||||
<OAuthProvider key={provider} provider={provider} />
|
<OAuthProvider
|
||||||
|
key={provider}
|
||||||
|
provider={provider}
|
||||||
|
redirectUri={redirectUri}
|
||||||
|
/>
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
function OAuthProvider({ provider }: { provider: OAuthProviderType }) {
|
function OAuthProvider({
|
||||||
|
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);
|
||||||
@@ -54,7 +64,7 @@ function OAuthProvider({ provider }: { provider: OAuthProviderType }) {
|
|||||||
const onClick = useAsyncCallback(async () => {
|
const onClick = useAsyncCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setIsConnecting(true);
|
setIsConnecting(true);
|
||||||
await authService.signInOauth(provider);
|
await authService.signInOauth(provider, redirectUri);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
notify.error({ message: 'Failed to sign in, please try again.' });
|
notify.error({ message: 'Failed to sign in, please try again.' });
|
||||||
@@ -62,7 +72,7 @@ function OAuthProvider({ provider }: { provider: OAuthProviderType }) {
|
|||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
mixpanel.track('OAuth', { provider });
|
mixpanel.track('OAuth', { provider });
|
||||||
}
|
}
|
||||||
}, [authService, provider]);
|
}, [authService, provider, redirectUri]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ArrowDownBigIcon } from '@blocksuite/icons';
|
|||||||
import { useLiveData, useService } from '@toeverything/infra';
|
import { useLiveData, useService } from '@toeverything/infra';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { AuthService } from '../../../modules/cloud';
|
import { AuthService } from '../../../modules/cloud';
|
||||||
import { mixpanel } from '../../../utils';
|
import { mixpanel } from '../../../utils';
|
||||||
@@ -29,7 +30,7 @@ export const SignIn: FC<AuthPanelProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const authService = useService(AuthService);
|
const authService = useService(AuthService);
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const [isMutating, setIsMutating] = useState(false);
|
const [isMutating, setIsMutating] = useState(false);
|
||||||
const [verifyToken, challenge] = useCaptcha();
|
const [verifyToken, challenge] = useCaptcha();
|
||||||
|
|
||||||
@@ -74,11 +75,21 @@ export const SignIn: FC<AuthPanelProps> = ({
|
|||||||
mixpanel.track_forms('SignIn', 'Email', {
|
mixpanel.track_forms('SignIn', 'Email', {
|
||||||
email,
|
email,
|
||||||
});
|
});
|
||||||
await authService.sendEmailMagicLink(email, verifyToken, challenge);
|
await authService.sendEmailMagicLink(
|
||||||
|
email,
|
||||||
|
verifyToken,
|
||||||
|
challenge,
|
||||||
|
searchParams.get('redirect_uri')
|
||||||
|
);
|
||||||
setAuthState('afterSignInSendEmail');
|
setAuthState('afterSignInSendEmail');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await authService.sendEmailMagicLink(email, verifyToken, challenge);
|
await authService.sendEmailMagicLink(
|
||||||
|
email,
|
||||||
|
verifyToken,
|
||||||
|
challenge,
|
||||||
|
searchParams.get('redirect_uri')
|
||||||
|
);
|
||||||
mixpanel.track_forms('SignUp', 'Email', {
|
mixpanel.track_forms('SignUp', 'Email', {
|
||||||
email,
|
email,
|
||||||
});
|
});
|
||||||
@@ -95,7 +106,15 @@ export const SignIn: FC<AuthPanelProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsMutating(false);
|
setIsMutating(false);
|
||||||
}, [authService, challenge, email, setAuthEmail, setAuthState, verifyToken]);
|
}, [
|
||||||
|
authService,
|
||||||
|
challenge,
|
||||||
|
email,
|
||||||
|
searchParams,
|
||||||
|
setAuthEmail,
|
||||||
|
setAuthState,
|
||||||
|
verifyToken,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -104,7 +123,7 @@ export const SignIn: FC<AuthPanelProps> = ({
|
|||||||
subTitle={t['com.affine.brand.affineCloud']()}
|
subTitle={t['com.affine.brand.affineCloud']()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<OAuth />
|
<OAuth redirectUri={searchParams.get('redirect_uri')} />
|
||||||
|
|
||||||
<div className={style.authModalContent}>
|
<div className={style.authModalContent}>
|
||||||
<AuthInput
|
<AuthInput
|
||||||
|
|||||||
@@ -137,13 +137,20 @@ export function useNavigateHelper() {
|
|||||||
);
|
);
|
||||||
const jumpToSignIn = useCallback(
|
const jumpToSignIn = useCallback(
|
||||||
(
|
(
|
||||||
|
redirectUri?: string,
|
||||||
logic: RouteLogic = RouteLogic.PUSH,
|
logic: RouteLogic = RouteLogic.PUSH,
|
||||||
otherOptions?: Omit<NavigateOptions, 'replace'>
|
otherOptions?: Omit<NavigateOptions, 'replace'>
|
||||||
) => {
|
) => {
|
||||||
return navigate('/signIn', {
|
return navigate(
|
||||||
|
'/signIn' +
|
||||||
|
(redirectUri
|
||||||
|
? `?redirect_uri=${encodeURIComponent(redirectUri)}`
|
||||||
|
: ''),
|
||||||
|
{
|
||||||
replace: logic === RouteLogic.REPLACE,
|
replace: logic === RouteLogic.REPLACE,
|
||||||
...otherOptions,
|
...otherOptions,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[navigate]
|
[navigate]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -106,9 +106,12 @@ export class AuthSession extends Entity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async waitForRevalidation() {
|
async waitForRevalidation(signal?: AbortSignal) {
|
||||||
this.revalidate();
|
this.revalidate();
|
||||||
await this.isRevalidating$.waitFor(isRevalidating => !isRevalidating);
|
await this.isRevalidating$.waitFor(
|
||||||
|
isRevalidating => !isRevalidating,
|
||||||
|
signal
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeAvatar() {
|
async removeAvatar() {
|
||||||
|
|||||||
@@ -84,9 +84,12 @@ export class Subscription extends Entity {
|
|||||||
await this.waitForRevalidation();
|
await this.waitForRevalidation();
|
||||||
}
|
}
|
||||||
|
|
||||||
async waitForRevalidation() {
|
async waitForRevalidation(signal?: AbortSignal) {
|
||||||
this.revalidate();
|
this.revalidate();
|
||||||
await this.isRevalidating$.waitFor(isRevalidating => !isRevalidating);
|
await this.isRevalidating$.waitFor(
|
||||||
|
isRevalidating => !isRevalidating,
|
||||||
|
signal
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidate = effect(
|
revalidate = effect(
|
||||||
|
|||||||
@@ -76,18 +76,18 @@ 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 redirectUri = new URL(location.href);
|
const redirect = environment.isDesktop
|
||||||
if (environment.isDesktop) {
|
? this.buildRedirectUri('/open-app/signin-redirect')
|
||||||
redirectUri.pathname = this.buildRedirectUri('/open-app/signin-redirect');
|
: redirectUri ?? location.href;
|
||||||
}
|
searchParams.set('redirect_uri', redirect.toString());
|
||||||
searchParams.set('redirect_uri', redirectUri.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(),
|
||||||
@@ -104,7 +104,7 @@ export class AuthService extends Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async signInOauth(provider: OAuthProviderType) {
|
async signInOauth(provider: OAuthProviderType, redirectUri?: string | null) {
|
||||||
if (environment.isDesktop) {
|
if (environment.isDesktop) {
|
||||||
await apis?.ui.openExternal(
|
await apis?.ui.openExternal(
|
||||||
`${
|
`${
|
||||||
@@ -117,7 +117,7 @@ export class AuthService extends Service {
|
|||||||
location.href = `${
|
location.href = `${
|
||||||
runtimeConfig.serverUrlPrefix
|
runtimeConfig.serverUrlPrefix
|
||||||
}/oauth/login?provider=${provider}&redirect_uri=${encodeURIComponent(
|
}/oauth/login?provider=${provider}&redirect_uri=${encodeURIComponent(
|
||||||
location.pathname
|
redirectUri ?? location.pathname
|
||||||
)}`;
|
)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,11 +79,10 @@ export const Component = () => {
|
|||||||
if (loginStatus === 'unauthenticated' && !isRevalidating) {
|
if (loginStatus === 'unauthenticated' && !isRevalidating) {
|
||||||
// We can not pass function to navigate state, so we need to save it in atom
|
// We can not pass function to navigate state, so we need to save it in atom
|
||||||
setOnceSignedInEvent(openWorkspace);
|
setOnceSignedInEvent(openWorkspace);
|
||||||
jumpToSignIn(RouteLogic.REPLACE, {
|
jumpToSignIn(
|
||||||
state: {
|
`/workspace/${inviteInfo.workspace.id}/all`,
|
||||||
callbackURL: `/workspace/${inviteInfo.workspace.id}/all`,
|
RouteLogic.REPLACE
|
||||||
},
|
);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
inviteInfo.workspace.id,
|
inviteInfo.workspace.id,
|
||||||
|
|||||||
@@ -5,25 +5,19 @@ import { useLiveData, useService } from '@toeverything/infra';
|
|||||||
import { useAtom } from 'jotai';
|
import { useAtom } from 'jotai';
|
||||||
import { useCallback, useEffect } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
|
// 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 { authAtom } from '../atoms';
|
||||||
import type { AuthProps } from '../components/affine/auth';
|
import type { AuthProps } from '../components/affine/auth';
|
||||||
import { AuthPanel } from '../components/affine/auth';
|
import { AuthPanel } from '../components/affine/auth';
|
||||||
import { RouteLogic, useNavigateHelper } from '../hooks/use-navigate-helper';
|
import { RouteLogic, useNavigateHelper } from '../hooks/use-navigate-helper';
|
||||||
|
|
||||||
interface LocationState {
|
|
||||||
state?: {
|
|
||||||
callbackURL?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
export const SignIn = () => {
|
export const SignIn = () => {
|
||||||
const [{ state, email = '', emailType = 'changePassword' }, setAuthAtom] =
|
const [{ state, email = '', emailType = 'changePassword' }, setAuthAtom] =
|
||||||
useAtom(authAtom);
|
useAtom(authAtom);
|
||||||
const session = useService(AuthService).session;
|
const session = useService(AuthService).session;
|
||||||
const status = useLiveData(session.status$);
|
const status = useLiveData(session.status$);
|
||||||
const isRevalidating = useLiveData(session.isRevalidating$);
|
const isRevalidating = useLiveData(session.isRevalidating$);
|
||||||
const location = useLocation() as LocationState;
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { jumpToIndex } = useNavigateHelper();
|
const { jumpToIndex } = useNavigateHelper();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
@@ -31,8 +25,9 @@ export const SignIn = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
if (location.state?.callbackURL) {
|
const redirectUri = searchParams.get('redirect_uri');
|
||||||
navigate(location.state.callbackURL, {
|
if (redirectUri) {
|
||||||
|
navigate(redirectUri, {
|
||||||
replace: true,
|
replace: true,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -41,14 +36,7 @@ export const SignIn = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [jumpToIndex, navigate, setAuthAtom, isLoggedIn, searchParams]);
|
||||||
jumpToIndex,
|
|
||||||
location.state,
|
|
||||||
navigate,
|
|
||||||
setAuthAtom,
|
|
||||||
isLoggedIn,
|
|
||||||
searchParams,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const onSetEmailType = useCallback(
|
const onSetEmailType = useCallback(
|
||||||
(emailType: AuthProps['emailType']) => {
|
(emailType: AuthProps['emailType']) => {
|
||||||
|
|||||||
13
packages/frontend/core/src/pages/subscribe.css.ts
Normal file
13
packages/frontend/core/src/pages/subscribe.css.ts
Normal file
@@ -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'),
|
||||||
|
});
|
||||||
122
packages/frontend/core/src/pages/subscribe.tsx
Normal file
122
packages/frontend/core/src/pages/subscribe.tsx
Normal file
@@ -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 (
|
||||||
|
<div className={container}>
|
||||||
|
{!error ? (
|
||||||
|
<>
|
||||||
|
{message}
|
||||||
|
<br />
|
||||||
|
<Loading size={20} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{error}
|
||||||
|
<br />
|
||||||
|
<Button type="primary" onClick={() => setRetryKey(i => i + 1)}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -96,6 +96,10 @@ export const topLevelRoutes = [
|
|||||||
path: '/redirect-proxy',
|
path: '/redirect-proxy',
|
||||||
lazy: () => import('./pages/redirect'),
|
lazy: () => import('./pages/redirect'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/subscribe',
|
||||||
|
lazy: () => import('./pages/subscribe'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '*',
|
path: '*',
|
||||||
lazy: () => import('./pages/404'),
|
lazy: () => import('./pages/404'),
|
||||||
|
|||||||
Reference in New Issue
Block a user