mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-12-03 21:13:36 +03:00
feat(core): captcha service (#8616)
This commit is contained in:
parent
81029db6ce
commit
7699296f11
@ -6,6 +6,7 @@ import { Telemetry } from '@affine/core/components/telemetry';
|
|||||||
import { router } from '@affine/core/desktop/router';
|
import { router } from '@affine/core/desktop/router';
|
||||||
import { configureCommonModules } from '@affine/core/modules';
|
import { configureCommonModules } from '@affine/core/modules';
|
||||||
import { configureAppTabsHeaderModule } from '@affine/core/modules/app-tabs-header';
|
import { configureAppTabsHeaderModule } from '@affine/core/modules/app-tabs-header';
|
||||||
|
import { ValidatorProvider } from '@affine/core/modules/cloud';
|
||||||
import { I18nProvider } from '@affine/core/modules/i18n';
|
import { I18nProvider } from '@affine/core/modules/i18n';
|
||||||
import { configureElectronStateStorageImpls } from '@affine/core/modules/storage';
|
import { configureElectronStateStorageImpls } from '@affine/core/modules/storage';
|
||||||
import { CustomThemeModifier } from '@affine/core/modules/theme-editor';
|
import { CustomThemeModifier } from '@affine/core/modules/theme-editor';
|
||||||
@ -75,6 +76,15 @@ framework.impl(ClientSchemaProvider, {
|
|||||||
return appInfo?.schema;
|
return appInfo?.schema;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
framework.impl(ValidatorProvider, {
|
||||||
|
async validate(_challenge, resource) {
|
||||||
|
const token = await apis?.ui?.getChallengeResponse(resource);
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('Challenge failed');
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
});
|
||||||
const frameworkProvider = framework.provider();
|
const frameworkProvider = framework.provider();
|
||||||
|
|
||||||
// setup application lifecycle events, and emit application start event
|
// setup application lifecycle events, and emit application start event
|
||||||
|
@ -7,14 +7,14 @@ import {
|
|||||||
} from '@affine/component/auth-components';
|
} from '@affine/component/auth-components';
|
||||||
import { Button } from '@affine/component/ui/button';
|
import { Button } from '@affine/component/ui/button';
|
||||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||||
import { AuthService } from '@affine/core/modules/cloud';
|
import { AuthService, CaptchaService } from '@affine/core/modules/cloud';
|
||||||
import { Trans, useI18n } from '@affine/i18n';
|
import { Trans, useI18n } from '@affine/i18n';
|
||||||
import { useService } from '@toeverything/infra';
|
import { useLiveData, useService } from '@toeverything/infra';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import type { AuthPanelProps } from './index';
|
import type { AuthPanelProps } from './index';
|
||||||
import * as style from './style.css';
|
import * as style from './style.css';
|
||||||
import { Captcha, useCaptcha } from './use-captcha';
|
import { Captcha } from './use-captcha';
|
||||||
|
|
||||||
export const AfterSignInSendEmail = ({
|
export const AfterSignInSendEmail = ({
|
||||||
setAuthData: setAuth,
|
setAuthData: setAuth,
|
||||||
@ -37,21 +37,23 @@ export const AfterSignInSendEmail = ({
|
|||||||
|
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const authService = useService(AuthService);
|
const authService = useService(AuthService);
|
||||||
|
const captchaService = useService(CaptchaService);
|
||||||
|
|
||||||
const [verifyToken, challenge] = useCaptcha();
|
const verifyToken = useLiveData(captchaService.verifyToken$);
|
||||||
|
const needCaptcha = useLiveData(captchaService.needCaptcha$);
|
||||||
|
const challenge = useLiveData(captchaService.challenge$);
|
||||||
|
|
||||||
const onResendClick = useAsyncCallback(async () => {
|
const onResendClick = useAsyncCallback(async () => {
|
||||||
setIsSending(true);
|
setIsSending(true);
|
||||||
try {
|
try {
|
||||||
if (verifyToken) {
|
setResendCountDown(60);
|
||||||
setResendCountDown(60);
|
captchaService.revalidate();
|
||||||
await authService.sendEmailMagicLink(
|
await authService.sendEmailMagicLink(
|
||||||
email,
|
email,
|
||||||
verifyToken,
|
verifyToken,
|
||||||
challenge,
|
challenge,
|
||||||
redirectUrl
|
redirectUrl
|
||||||
);
|
);
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
notify.error({
|
notify.error({
|
||||||
@ -59,7 +61,7 @@ export const AfterSignInSendEmail = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
setIsSending(false);
|
setIsSending(false);
|
||||||
}, [authService, challenge, email, redirectUrl, verifyToken]);
|
}, [authService, captchaService, challenge, email, redirectUrl, verifyToken]);
|
||||||
|
|
||||||
const onSignInWithPasswordClick = useCallback(() => {
|
const onSignInWithPasswordClick = useCallback(() => {
|
||||||
setAuth({ state: 'signInWithPassword' });
|
setAuth({ state: 'signInWithPassword' });
|
||||||
@ -89,8 +91,10 @@ export const AfterSignInSendEmail = ({
|
|||||||
<>
|
<>
|
||||||
<Captcha />
|
<Captcha />
|
||||||
<Button
|
<Button
|
||||||
style={!verifyToken ? { cursor: 'not-allowed' } : {}}
|
style={
|
||||||
disabled={!verifyToken || isSending}
|
!verifyToken && needCaptcha ? { cursor: 'not-allowed' } : {}
|
||||||
|
}
|
||||||
|
disabled={(!verifyToken && needCaptcha) || isSending}
|
||||||
variant="plain"
|
variant="plain"
|
||||||
size="large"
|
size="large"
|
||||||
onClick={onResendClick}
|
onClick={onResendClick}
|
||||||
|
@ -7,15 +7,16 @@ import {
|
|||||||
} from '@affine/component/auth-components';
|
} from '@affine/component/auth-components';
|
||||||
import { Button } from '@affine/component/ui/button';
|
import { Button } from '@affine/component/ui/button';
|
||||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||||
|
import { CaptchaService } from '@affine/core/modules/cloud';
|
||||||
import { Trans, useI18n } from '@affine/i18n';
|
import { Trans, useI18n } from '@affine/i18n';
|
||||||
import { 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 { AuthService } from '../../../modules/cloud';
|
import { AuthService } from '../../../modules/cloud';
|
||||||
import type { AuthPanelProps } from './index';
|
import type { AuthPanelProps } from './index';
|
||||||
import * as style from './style.css';
|
import * as style from './style.css';
|
||||||
import { Captcha, useCaptcha } from './use-captcha';
|
import { Captcha } from './use-captcha';
|
||||||
|
|
||||||
export const AfterSignUpSendEmail: FC<
|
export const AfterSignUpSendEmail: FC<
|
||||||
AuthPanelProps<'afterSignUpSendEmail'>
|
AuthPanelProps<'afterSignUpSendEmail'>
|
||||||
@ -36,19 +37,22 @@ export const AfterSignUpSendEmail: FC<
|
|||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const authService = useService(AuthService);
|
const authService = useService(AuthService);
|
||||||
|
|
||||||
const [verifyToken, challenge] = useCaptcha();
|
const captchaService = useService(CaptchaService);
|
||||||
|
|
||||||
|
const verifyToken = useLiveData(captchaService.verifyToken$);
|
||||||
|
const needCaptcha = useLiveData(captchaService.needCaptcha$);
|
||||||
|
const challenge = useLiveData(captchaService.challenge$);
|
||||||
|
|
||||||
const onResendClick = useAsyncCallback(async () => {
|
const onResendClick = useAsyncCallback(async () => {
|
||||||
setIsSending(true);
|
setIsSending(true);
|
||||||
try {
|
try {
|
||||||
if (verifyToken) {
|
captchaService.revalidate();
|
||||||
await authService.sendEmailMagicLink(
|
await authService.sendEmailMagicLink(
|
||||||
email,
|
email,
|
||||||
verifyToken,
|
verifyToken,
|
||||||
challenge,
|
challenge,
|
||||||
redirectUrl
|
redirectUrl
|
||||||
);
|
);
|
||||||
}
|
|
||||||
setResendCountDown(60);
|
setResendCountDown(60);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@ -57,7 +61,7 @@ export const AfterSignUpSendEmail: FC<
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
setIsSending(false);
|
setIsSending(false);
|
||||||
}, [authService, challenge, email, redirectUrl, verifyToken]);
|
}, [authService, captchaService, challenge, email, redirectUrl, verifyToken]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -79,8 +83,10 @@ export const AfterSignUpSendEmail: FC<
|
|||||||
<>
|
<>
|
||||||
<Captcha />
|
<Captcha />
|
||||||
<Button
|
<Button
|
||||||
style={!verifyToken ? { cursor: 'not-allowed' } : {}}
|
style={
|
||||||
disabled={!verifyToken || isSending}
|
!verifyToken && needCaptcha ? { cursor: 'not-allowed' } : {}
|
||||||
|
}
|
||||||
|
disabled={(!verifyToken && needCaptcha) || isSending}
|
||||||
variant="plain"
|
variant="plain"
|
||||||
size="large"
|
size="large"
|
||||||
onClick={onResendClick}
|
onClick={onResendClick}
|
||||||
|
@ -6,15 +6,15 @@ import {
|
|||||||
} from '@affine/component/auth-components';
|
} from '@affine/component/auth-components';
|
||||||
import { Button } from '@affine/component/ui/button';
|
import { Button } from '@affine/component/ui/button';
|
||||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||||
import { AuthService } from '@affine/core/modules/cloud';
|
import { AuthService, CaptchaService } from '@affine/core/modules/cloud';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
import { useService } from '@toeverything/infra';
|
import { useLiveData, useService } from '@toeverything/infra';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
import type { AuthPanelProps } from './index';
|
import type { AuthPanelProps } from './index';
|
||||||
import * as styles from './style.css';
|
import * as styles from './style.css';
|
||||||
import { useCaptcha } from './use-captcha';
|
import { Captcha } from './use-captcha';
|
||||||
|
|
||||||
export const SignInWithPassword: FC<AuthPanelProps<'signInWithPassword'>> = ({
|
export const SignInWithPassword: FC<AuthPanelProps<'signInWithPassword'>> = ({
|
||||||
setAuthData,
|
setAuthData,
|
||||||
@ -26,15 +26,20 @@ export const SignInWithPassword: FC<AuthPanelProps<'signInWithPassword'>> = ({
|
|||||||
|
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [passwordError, setPasswordError] = useState(false);
|
const [passwordError, setPasswordError] = useState(false);
|
||||||
const [verifyToken, challenge, refreshChallenge] = useCaptcha();
|
const captchaService = useService(CaptchaService);
|
||||||
|
|
||||||
|
const verifyToken = useLiveData(captchaService.verifyToken$);
|
||||||
|
const needCaptcha = useLiveData(captchaService.needCaptcha$);
|
||||||
|
const challenge = useLiveData(captchaService.challenge$);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [sendingEmail, setSendingEmail] = useState(false);
|
const [sendingEmail, setSendingEmail] = useState(false);
|
||||||
|
|
||||||
const onSignIn = useAsyncCallback(async () => {
|
const onSignIn = useAsyncCallback(async () => {
|
||||||
if (isLoading || !verifyToken) return;
|
if (isLoading) return;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
captchaService.revalidate();
|
||||||
await authService.signInPassword({
|
await authService.signInPassword({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
@ -44,33 +49,31 @@ export const SignInWithPassword: FC<AuthPanelProps<'signInWithPassword'>> = ({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
setPasswordError(true);
|
setPasswordError(true);
|
||||||
refreshChallenge?.();
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
isLoading,
|
isLoading,
|
||||||
verifyToken,
|
verifyToken,
|
||||||
|
captchaService,
|
||||||
authService,
|
authService,
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
challenge,
|
challenge,
|
||||||
refreshChallenge,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const sendMagicLink = useAsyncCallback(async () => {
|
const sendMagicLink = useAsyncCallback(async () => {
|
||||||
if (sendingEmail) return;
|
if (sendingEmail) return;
|
||||||
setSendingEmail(true);
|
setSendingEmail(true);
|
||||||
try {
|
try {
|
||||||
if (verifyToken) {
|
captchaService.revalidate();
|
||||||
await authService.sendEmailMagicLink(
|
await authService.sendEmailMagicLink(
|
||||||
email,
|
email,
|
||||||
verifyToken,
|
verifyToken,
|
||||||
challenge,
|
challenge,
|
||||||
redirectUrl
|
redirectUrl
|
||||||
);
|
);
|
||||||
setAuthData({ state: 'afterSignInSendEmail' });
|
setAuthData({ state: 'afterSignInSendEmail' });
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
notify.error({
|
notify.error({
|
||||||
@ -82,6 +85,7 @@ export const SignInWithPassword: FC<AuthPanelProps<'signInWithPassword'>> = ({
|
|||||||
}, [
|
}, [
|
||||||
sendingEmail,
|
sendingEmail,
|
||||||
verifyToken,
|
verifyToken,
|
||||||
|
captchaService,
|
||||||
authService,
|
authService,
|
||||||
email,
|
email,
|
||||||
challenge,
|
challenge,
|
||||||
@ -139,21 +143,24 @@ export const SignInWithPassword: FC<AuthPanelProps<'signInWithPassword'>> = ({
|
|||||||
{t['com.affine.auth.forget']()}
|
{t['com.affine.auth.forget']()}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.sendMagicLinkButtonRow}>
|
{(verifyToken || !needCaptcha) && (
|
||||||
<a
|
<div className={styles.sendMagicLinkButtonRow}>
|
||||||
data-testid="send-magic-link-button"
|
<a
|
||||||
className={styles.linkButton}
|
data-testid="send-magic-link-button"
|
||||||
onClick={sendMagicLink}
|
className={styles.linkButton}
|
||||||
>
|
onClick={sendMagicLink}
|
||||||
{t['com.affine.auth.sign.auth.code.send-email.sign-in']()}
|
>
|
||||||
</a>
|
{t['com.affine.auth.sign.auth.code.send-email.sign-in']()}
|
||||||
</div>
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!verifyToken && needCaptcha && <Captcha />}
|
||||||
<Button
|
<Button
|
||||||
data-testid="sign-in-button"
|
data-testid="sign-in-button"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="extraLarge"
|
size="extraLarge"
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
disabled={isLoading}
|
disabled={isLoading || (!verifyToken && needCaptcha)}
|
||||||
onClick={onSignIn}
|
onClick={onSignIn}
|
||||||
>
|
>
|
||||||
{t['com.affine.auth.sign.in']()}
|
{t['com.affine.auth.sign.in']()}
|
||||||
|
@ -2,9 +2,10 @@ import { notify } from '@affine/component';
|
|||||||
import { AuthInput, ModalHeader } from '@affine/component/auth-components';
|
import { AuthInput, ModalHeader } from '@affine/component/auth-components';
|
||||||
import { Button } from '@affine/component/ui/button';
|
import { Button } from '@affine/component/ui/button';
|
||||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||||
|
import { CaptchaService } from '@affine/core/modules/cloud';
|
||||||
import { Trans, useI18n } from '@affine/i18n';
|
import { Trans, useI18n } from '@affine/i18n';
|
||||||
import { ArrowRightBigIcon } from '@blocksuite/icons/rc';
|
import { ArrowRightBigIcon } from '@blocksuite/icons/rc';
|
||||||
import { useService } from '@toeverything/infra';
|
import { useLiveData, useService } from '@toeverything/infra';
|
||||||
import { cssVar } from '@toeverything/theme';
|
import { cssVar } from '@toeverything/theme';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
@ -15,7 +16,7 @@ import { emailRegex } from '../../../utils/email-regex';
|
|||||||
import type { AuthPanelProps } from './index';
|
import type { AuthPanelProps } from './index';
|
||||||
import { OAuth } from './oauth';
|
import { OAuth } from './oauth';
|
||||||
import * as style from './style.css';
|
import * as style from './style.css';
|
||||||
import { Captcha, useCaptcha } from './use-captcha';
|
import { Captcha } from './use-captcha';
|
||||||
|
|
||||||
function validateEmail(email: string) {
|
function validateEmail(email: string) {
|
||||||
return emailRegex.test(email);
|
return emailRegex.test(email);
|
||||||
@ -30,7 +31,11 @@ export const SignIn: FC<AuthPanelProps<'signIn'>> = ({
|
|||||||
const authService = useService(AuthService);
|
const authService = useService(AuthService);
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const [isMutating, setIsMutating] = useState(false);
|
const [isMutating, setIsMutating] = useState(false);
|
||||||
const [verifyToken, challenge, refreshChallenge] = useCaptcha();
|
const captchaService = useService(CaptchaService);
|
||||||
|
|
||||||
|
const verifyToken = useLiveData(captchaService.verifyToken$);
|
||||||
|
const needCaptcha = useLiveData(captchaService.needCaptcha$);
|
||||||
|
const challenge = useLiveData(captchaService.challenge$);
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
|
|
||||||
const [isValidEmail, setIsValidEmail] = useState(true);
|
const [isValidEmail, setIsValidEmail] = useState(true);
|
||||||
@ -49,29 +54,16 @@ export const SignIn: FC<AuthPanelProps<'signIn'>> = ({
|
|||||||
const { hasPassword, registered } =
|
const { hasPassword, registered } =
|
||||||
await authService.checkUserByEmail(email);
|
await authService.checkUserByEmail(email);
|
||||||
|
|
||||||
if (verifyToken) {
|
if (registered) {
|
||||||
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({
|
||||||
refreshChallenge?.();
|
state: 'signInWithPassword',
|
||||||
setAuthState({
|
email,
|
||||||
state: 'signInWithPassword',
|
});
|
||||||
email,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await authService.sendEmailMagicLink(
|
|
||||||
email,
|
|
||||||
verifyToken,
|
|
||||||
challenge,
|
|
||||||
redirectUrl
|
|
||||||
);
|
|
||||||
setAuthState({
|
|
||||||
state: 'afterSignInSendEmail',
|
|
||||||
email,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
|
captchaService.revalidate();
|
||||||
await authService.sendEmailMagicLink(
|
await authService.sendEmailMagicLink(
|
||||||
email,
|
email,
|
||||||
verifyToken,
|
verifyToken,
|
||||||
@ -79,10 +71,22 @@ export const SignIn: FC<AuthPanelProps<'signIn'>> = ({
|
|||||||
redirectUrl
|
redirectUrl
|
||||||
);
|
);
|
||||||
setAuthState({
|
setAuthState({
|
||||||
state: 'afterSignUpSendEmail',
|
state: 'afterSignInSendEmail',
|
||||||
email,
|
email,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
captchaService.revalidate();
|
||||||
|
await authService.sendEmailMagicLink(
|
||||||
|
email,
|
||||||
|
verifyToken,
|
||||||
|
challenge,
|
||||||
|
redirectUrl
|
||||||
|
);
|
||||||
|
setAuthState({
|
||||||
|
state: 'afterSignUpSendEmail',
|
||||||
|
email,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@ -96,10 +100,10 @@ export const SignIn: FC<AuthPanelProps<'signIn'>> = ({
|
|||||||
setIsMutating(false);
|
setIsMutating(false);
|
||||||
}, [
|
}, [
|
||||||
authService,
|
authService,
|
||||||
|
captchaService,
|
||||||
challenge,
|
challenge,
|
||||||
email,
|
email,
|
||||||
redirectUrl,
|
redirectUrl,
|
||||||
refreshChallenge,
|
|
||||||
setAuthState,
|
setAuthState,
|
||||||
verifyToken,
|
verifyToken,
|
||||||
]);
|
]);
|
||||||
@ -125,7 +129,7 @@ export const SignIn: FC<AuthPanelProps<'signIn'>> = ({
|
|||||||
onEnter={onContinue}
|
onEnter={onContinue}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{verifyToken ? (
|
{verifyToken || !needCaptcha ? (
|
||||||
<Button
|
<Button
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
size="extraLarge"
|
size="extraLarge"
|
||||||
|
@ -1,120 +1,45 @@
|
|||||||
import { apis } from '@affine/electron-api';
|
import { CaptchaService } from '@affine/core/modules/cloud';
|
||||||
import { Turnstile } from '@marsidev/react-turnstile';
|
import { Turnstile } from '@marsidev/react-turnstile';
|
||||||
import { useLiveData, useService } from '@toeverything/infra';
|
import { useLiveData, useService } from '@toeverything/infra';
|
||||||
import { atom, useAtom, useSetAtom } from 'jotai';
|
import { useCallback, useEffect } from 'react';
|
||||||
import { useEffect, useRef } from 'react';
|
|
||||||
import useSWR from 'swr';
|
|
||||||
|
|
||||||
import { ServerConfigService } from '../../../modules/cloud';
|
|
||||||
import * as style from './style.css';
|
import * as style from './style.css';
|
||||||
|
|
||||||
type Challenge = {
|
|
||||||
challenge: string;
|
|
||||||
resource: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const challengeFetcher = async (url: string) => {
|
|
||||||
if (!BUILD_CONFIG.isElectron) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(url);
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error('Failed to fetch challenge');
|
|
||||||
}
|
|
||||||
const challenge = (await res.json()) as Challenge;
|
|
||||||
if (!challenge || !challenge.challenge || !challenge.resource) {
|
|
||||||
throw new Error('Invalid challenge');
|
|
||||||
}
|
|
||||||
|
|
||||||
return challenge;
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateChallengeResponse = async (challenge: string) => {
|
|
||||||
if (!BUILD_CONFIG.isElectron) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return await apis?.ui?.getChallengeResponse(challenge);
|
|
||||||
};
|
|
||||||
|
|
||||||
const captchaAtom = atom<string | undefined>(undefined);
|
|
||||||
const responseAtom = atom<string | undefined>(undefined);
|
|
||||||
|
|
||||||
const useHasCaptcha = () => {
|
|
||||||
const serverConfig = useService(ServerConfigService).serverConfig;
|
|
||||||
const hasCaptcha = useLiveData(serverConfig.features$.map(r => r?.captcha));
|
|
||||||
return hasCaptcha || false;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Captcha = () => {
|
export const Captcha = () => {
|
||||||
const setCaptcha = useSetAtom(captchaAtom);
|
const captchaService = useService(CaptchaService);
|
||||||
const [response] = useAtom(responseAtom);
|
const hasCaptchaFeature = useLiveData(captchaService.needCaptcha$);
|
||||||
const hasCaptchaFeature = useHasCaptcha();
|
const isLoading = useLiveData(captchaService.isLoading$);
|
||||||
|
const verifyToken = useLiveData(captchaService.verifyToken$);
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasCaptchaFeature) {
|
||||||
|
captchaService.revalidate();
|
||||||
|
}
|
||||||
|
}, [captchaService, hasCaptchaFeature]);
|
||||||
|
|
||||||
|
const handleTurnstileSuccess = useCallback(
|
||||||
|
(token: string) => {
|
||||||
|
captchaService.verifyToken$.next(token);
|
||||||
|
},
|
||||||
|
[captchaService]
|
||||||
|
);
|
||||||
|
|
||||||
if (!hasCaptchaFeature) {
|
if (!hasCaptchaFeature) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (BUILD_CONFIG.isElectron) {
|
if (isLoading) {
|
||||||
if (response) {
|
return <div className={style.captchaWrapper}>Loading...</div>;
|
||||||
return <div className={style.captchaWrapper}>Making Challenge</div>;
|
}
|
||||||
} else {
|
|
||||||
return <div className={style.captchaWrapper}>Verified Client</div>;
|
if (verifyToken) {
|
||||||
}
|
return <div className={style.captchaWrapper}>Verified Client</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Turnstile
|
<Turnstile
|
||||||
className={style.captchaWrapper}
|
className={style.captchaWrapper}
|
||||||
siteKey={process.env.CAPTCHA_SITE_KEY || '1x00000000000000000000AA'}
|
siteKey={process.env.CAPTCHA_SITE_KEY || '1x00000000000000000000AA'}
|
||||||
onSuccess={setCaptcha}
|
onSuccess={handleTurnstileSuccess}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useCaptcha = (): [string | undefined, string?, (() => void)?] => {
|
|
||||||
const [verifyToken] = useAtom(captchaAtom);
|
|
||||||
const [response, setResponse] = useAtom(responseAtom);
|
|
||||||
const hasCaptchaFeature = useHasCaptcha();
|
|
||||||
|
|
||||||
const { data: challenge, mutate } = useSWR(
|
|
||||||
'/api/auth/challenge',
|
|
||||||
challengeFetcher,
|
|
||||||
{
|
|
||||||
suspense: false,
|
|
||||||
revalidateOnFocus: false,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const prevChallenge = useRef('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
BUILD_CONFIG.isElectron &&
|
|
||||||
hasCaptchaFeature &&
|
|
||||||
challenge?.challenge &&
|
|
||||||
prevChallenge.current !== challenge.challenge
|
|
||||||
) {
|
|
||||||
prevChallenge.current = challenge.challenge;
|
|
||||||
generateChallengeResponse(challenge.resource)
|
|
||||||
.then(setResponse)
|
|
||||||
.catch(err => {
|
|
||||||
console.error('Error getting challenge response:', err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [challenge, hasCaptchaFeature, setResponse]);
|
|
||||||
|
|
||||||
if (!hasCaptchaFeature) {
|
|
||||||
return ['XXXX.DUMMY.TOKEN.XXXX'];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (BUILD_CONFIG.isElectron) {
|
|
||||||
if (response) {
|
|
||||||
return [response, challenge?.challenge, mutate];
|
|
||||||
} else {
|
|
||||||
return [undefined, challenge?.challenge];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [verifyToken];
|
|
||||||
};
|
|
||||||
|
@ -6,8 +6,10 @@ export {
|
|||||||
isNetworkError,
|
isNetworkError,
|
||||||
NetworkError,
|
NetworkError,
|
||||||
} from './error';
|
} from './error';
|
||||||
|
export { ValidatorProvider } from './provider/validator';
|
||||||
export { WebSocketAuthProvider } from './provider/websocket-auth';
|
export { WebSocketAuthProvider } from './provider/websocket-auth';
|
||||||
export { AccountChanged, AuthService } from './services/auth';
|
export { AccountChanged, AuthService } from './services/auth';
|
||||||
|
export { CaptchaService } from './services/captcha';
|
||||||
export { FetchService } from './services/fetch';
|
export { FetchService } from './services/fetch';
|
||||||
export { GraphQLService } from './services/graphql';
|
export { GraphQLService } from './services/graphql';
|
||||||
export { InvoicesService } from './services/invoices';
|
export { InvoicesService } from './services/invoices';
|
||||||
@ -38,8 +40,10 @@ import { UserCopilotQuota } from './entities/user-copilot-quota';
|
|||||||
import { UserFeature } from './entities/user-feature';
|
import { UserFeature } from './entities/user-feature';
|
||||||
import { UserQuota } from './entities/user-quota';
|
import { UserQuota } from './entities/user-quota';
|
||||||
import { DefaultFetchProvider, FetchProvider } from './provider/fetch';
|
import { DefaultFetchProvider, FetchProvider } from './provider/fetch';
|
||||||
|
import { ValidatorProvider } from './provider/validator';
|
||||||
import { WebSocketAuthProvider } from './provider/websocket-auth';
|
import { WebSocketAuthProvider } from './provider/websocket-auth';
|
||||||
import { AuthService } from './services/auth';
|
import { AuthService } from './services/auth';
|
||||||
|
import { CaptchaService } from './services/captcha';
|
||||||
import { CloudDocMetaService } from './services/cloud-doc-meta';
|
import { CloudDocMetaService } from './services/cloud-doc-meta';
|
||||||
import { FetchService } from './services/fetch';
|
import { FetchService } from './services/fetch';
|
||||||
import { GraphQLService } from './services/graphql';
|
import { GraphQLService } from './services/graphql';
|
||||||
@ -75,6 +79,13 @@ export function configureCloudModule(framework: Framework) {
|
|||||||
.service(ServerConfigService)
|
.service(ServerConfigService)
|
||||||
.entity(ServerConfig, [ServerConfigStore])
|
.entity(ServerConfig, [ServerConfigStore])
|
||||||
.store(ServerConfigStore, [GraphQLService])
|
.store(ServerConfigStore, [GraphQLService])
|
||||||
|
.service(CaptchaService, f => {
|
||||||
|
return new CaptchaService(
|
||||||
|
f.get(ServerConfigService),
|
||||||
|
f.get(FetchService),
|
||||||
|
f.getOptional(ValidatorProvider)
|
||||||
|
);
|
||||||
|
})
|
||||||
.service(AuthService, [FetchService, AuthStore, UrlService])
|
.service(AuthService, [FetchService, AuthStore, UrlService])
|
||||||
.store(AuthStore, [FetchService, GraphQLService, GlobalState])
|
.store(AuthStore, [FetchService, GraphQLService, GlobalState])
|
||||||
.entity(AuthSession, [AuthStore])
|
.entity(AuthSession, [AuthStore])
|
||||||
|
@ -0,0 +1,12 @@
|
|||||||
|
import { createIdentifier } from '@toeverything/infra';
|
||||||
|
|
||||||
|
export interface ValidatorProvider {
|
||||||
|
/**
|
||||||
|
* Calculate a token based on the server's challenge and resource to pass the
|
||||||
|
* challenge validation.
|
||||||
|
*/
|
||||||
|
validate: (challenge: string, resource: string) => Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ValidatorProvider =
|
||||||
|
createIdentifier<ValidatorProvider>('ValidatorProvider');
|
@ -113,7 +113,7 @@ export class AuthService extends Service {
|
|||||||
|
|
||||||
async sendEmailMagicLink(
|
async sendEmailMagicLink(
|
||||||
email: string,
|
email: string,
|
||||||
verifyToken: string,
|
verifyToken?: string,
|
||||||
challenge?: string,
|
challenge?: string,
|
||||||
redirectUrl?: string // url to redirect to after signed-in
|
redirectUrl?: string // url to redirect to after signed-in
|
||||||
) {
|
) {
|
||||||
@ -137,7 +137,7 @@ export class AuthService extends Service {
|
|||||||
}),
|
}),
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
...this.captchaHeaders(verifyToken, challenge),
|
...(verifyToken ? this.captchaHeaders(verifyToken, challenge) : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -224,7 +224,7 @@ export class AuthService extends Service {
|
|||||||
async signInPassword(credential: {
|
async signInPassword(credential: {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
verifyToken: string;
|
verifyToken?: string;
|
||||||
challenge?: string;
|
challenge?: string;
|
||||||
}) {
|
}) {
|
||||||
track.$.$.auth.signIn({ method: 'password' });
|
track.$.$.auth.signIn({ method: 'password' });
|
||||||
@ -234,7 +234,9 @@ export class AuthService extends Service {
|
|||||||
body: JSON.stringify(credential),
|
body: JSON.stringify(credential),
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
...this.captchaHeaders(credential.verifyToken, credential.challenge),
|
...(credential.verifyToken
|
||||||
|
? this.captchaHeaders(credential.verifyToken, credential.challenge)
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.session.revalidate();
|
this.session.revalidate();
|
||||||
|
75
packages/frontend/core/src/modules/cloud/services/captcha.ts
Normal file
75
packages/frontend/core/src/modules/cloud/services/captcha.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import {
|
||||||
|
catchErrorInto,
|
||||||
|
effect,
|
||||||
|
fromPromise,
|
||||||
|
LiveData,
|
||||||
|
onComplete,
|
||||||
|
onStart,
|
||||||
|
Service,
|
||||||
|
} from '@toeverything/infra';
|
||||||
|
import { EMPTY, mergeMap, switchMap } from 'rxjs';
|
||||||
|
|
||||||
|
import type { ValidatorProvider } from '../provider/validator';
|
||||||
|
import type { FetchService } from './fetch';
|
||||||
|
import type { ServerConfigService } from './server-config';
|
||||||
|
|
||||||
|
export class CaptchaService extends Service {
|
||||||
|
needCaptcha$ = this.serverConfigService.serverConfig.features$.map(
|
||||||
|
r => r?.captcha || false
|
||||||
|
);
|
||||||
|
challenge$ = new LiveData<string | undefined>(undefined);
|
||||||
|
isLoading$ = new LiveData(false);
|
||||||
|
verifyToken$ = new LiveData<string | undefined>(undefined);
|
||||||
|
error$ = new LiveData<any | undefined>(undefined);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly serverConfigService: ServerConfigService,
|
||||||
|
private readonly fetchService: FetchService,
|
||||||
|
public readonly validatorProvider?: ValidatorProvider
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidate = effect(
|
||||||
|
switchMap(() => {
|
||||||
|
return fromPromise(async signal => {
|
||||||
|
if (!this.needCaptcha$.value) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const res = await this.fetchService.fetch('/api/auth/challenge', {
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
challenge: string;
|
||||||
|
resource: string;
|
||||||
|
};
|
||||||
|
if (!data || !data.challenge || !data.resource) {
|
||||||
|
throw new Error('Invalid challenge');
|
||||||
|
}
|
||||||
|
if (this.validatorProvider) {
|
||||||
|
const token = await this.validatorProvider.validate(
|
||||||
|
data.challenge,
|
||||||
|
data.resource
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
challenge: data.challenge,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { challenge: data.challenge, token: undefined };
|
||||||
|
}).pipe(
|
||||||
|
mergeMap(({ challenge, token }) => {
|
||||||
|
this.verifyToken$.next(token);
|
||||||
|
this.challenge$.next(challenge);
|
||||||
|
return EMPTY;
|
||||||
|
}),
|
||||||
|
catchErrorInto(this.error$),
|
||||||
|
onStart(() => {
|
||||||
|
this.verifyToken$.next(undefined);
|
||||||
|
this.isLoading$.next(true);
|
||||||
|
}),
|
||||||
|
onComplete(() => this.isLoading$.next(false))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
@ -10,7 +10,7 @@
|
|||||||
"fr": 75,
|
"fr": 75,
|
||||||
"hi": 2,
|
"hi": 2,
|
||||||
"it": 1,
|
"it": 1,
|
||||||
"ja": 99,
|
"ja": 100,
|
||||||
"ko": 89,
|
"ko": 89,
|
||||||
"pl": 0,
|
"pl": 0,
|
||||||
"pt-BR": 97,
|
"pt-BR": 97,
|
||||||
|
@ -189,7 +189,7 @@ export async function loginUser(
|
|||||||
}
|
}
|
||||||
await page.waitForTimeout(200);
|
await page.waitForTimeout(200);
|
||||||
await page.getByTestId('sign-in-button').click();
|
await page.getByTestId('sign-in-button').click();
|
||||||
await page.waitForTimeout(200);
|
await page.waitForTimeout(500);
|
||||||
if (config?.afterLogin) {
|
if (config?.afterLogin) {
|
||||||
await config.afterLogin();
|
await config.afterLogin();
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user