feat(core): add subscribe link (#6610)

This commit is contained in:
EYHN
2024-04-18 13:28:32 +00:00
parent 5437c6567b
commit 09832dc940
11 changed files with 216 additions and 48 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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(
replace: logic === RouteLogic.REPLACE, '/signIn' +
...otherOptions, (redirectUri
}); ? `?redirect_uri=${encodeURIComponent(redirectUri)}`
: ''),
{
replace: logic === RouteLogic.REPLACE,
...otherOptions,
}
);
}, },
[navigate] [navigate]
); );

View File

@@ -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() {

View File

@@ -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(

View File

@@ -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
)}`; )}`;
} }

View File

@@ -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,

View File

@@ -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']) => {

View 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'),
});

View 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>
);
};

View File

@@ -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'),