mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 21:27:20 +00:00
feat(core): captcha service (#8616)
This commit is contained in:
@@ -7,14 +7,14 @@ import {
|
||||
} from '@affine/component/auth-components';
|
||||
import { Button } from '@affine/component/ui/button';
|
||||
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 { useService } from '@toeverything/infra';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import type { AuthPanelProps } from './index';
|
||||
import * as style from './style.css';
|
||||
import { Captcha, useCaptcha } from './use-captcha';
|
||||
import { Captcha } from './use-captcha';
|
||||
|
||||
export const AfterSignInSendEmail = ({
|
||||
setAuthData: setAuth,
|
||||
@@ -37,21 +37,23 @@ export const AfterSignInSendEmail = ({
|
||||
|
||||
const t = useI18n();
|
||||
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 () => {
|
||||
setIsSending(true);
|
||||
try {
|
||||
if (verifyToken) {
|
||||
setResendCountDown(60);
|
||||
await authService.sendEmailMagicLink(
|
||||
email,
|
||||
verifyToken,
|
||||
challenge,
|
||||
redirectUrl
|
||||
);
|
||||
}
|
||||
setResendCountDown(60);
|
||||
captchaService.revalidate();
|
||||
await authService.sendEmailMagicLink(
|
||||
email,
|
||||
verifyToken,
|
||||
challenge,
|
||||
redirectUrl
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
notify.error({
|
||||
@@ -59,7 +61,7 @@ export const AfterSignInSendEmail = ({
|
||||
});
|
||||
}
|
||||
setIsSending(false);
|
||||
}, [authService, challenge, email, redirectUrl, verifyToken]);
|
||||
}, [authService, captchaService, challenge, email, redirectUrl, verifyToken]);
|
||||
|
||||
const onSignInWithPasswordClick = useCallback(() => {
|
||||
setAuth({ state: 'signInWithPassword' });
|
||||
@@ -89,8 +91,10 @@ export const AfterSignInSendEmail = ({
|
||||
<>
|
||||
<Captcha />
|
||||
<Button
|
||||
style={!verifyToken ? { cursor: 'not-allowed' } : {}}
|
||||
disabled={!verifyToken || isSending}
|
||||
style={
|
||||
!verifyToken && needCaptcha ? { cursor: 'not-allowed' } : {}
|
||||
}
|
||||
disabled={(!verifyToken && needCaptcha) || isSending}
|
||||
variant="plain"
|
||||
size="large"
|
||||
onClick={onResendClick}
|
||||
|
||||
@@ -7,15 +7,16 @@ import {
|
||||
} from '@affine/component/auth-components';
|
||||
import { Button } from '@affine/component/ui/button';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { CaptchaService } from '@affine/core/modules/cloud';
|
||||
import { Trans, useI18n } from '@affine/i18n';
|
||||
import { useService } from '@toeverything/infra';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import type { FC } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { AuthService } from '../../../modules/cloud';
|
||||
import type { AuthPanelProps } from './index';
|
||||
import * as style from './style.css';
|
||||
import { Captcha, useCaptcha } from './use-captcha';
|
||||
import { Captcha } from './use-captcha';
|
||||
|
||||
export const AfterSignUpSendEmail: FC<
|
||||
AuthPanelProps<'afterSignUpSendEmail'>
|
||||
@@ -36,19 +37,22 @@ export const AfterSignUpSendEmail: FC<
|
||||
const t = useI18n();
|
||||
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 () => {
|
||||
setIsSending(true);
|
||||
try {
|
||||
if (verifyToken) {
|
||||
await authService.sendEmailMagicLink(
|
||||
email,
|
||||
verifyToken,
|
||||
challenge,
|
||||
redirectUrl
|
||||
);
|
||||
}
|
||||
captchaService.revalidate();
|
||||
await authService.sendEmailMagicLink(
|
||||
email,
|
||||
verifyToken,
|
||||
challenge,
|
||||
redirectUrl
|
||||
);
|
||||
setResendCountDown(60);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -57,7 +61,7 @@ export const AfterSignUpSendEmail: FC<
|
||||
});
|
||||
}
|
||||
setIsSending(false);
|
||||
}, [authService, challenge, email, redirectUrl, verifyToken]);
|
||||
}, [authService, captchaService, challenge, email, redirectUrl, verifyToken]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -79,8 +83,10 @@ export const AfterSignUpSendEmail: FC<
|
||||
<>
|
||||
<Captcha />
|
||||
<Button
|
||||
style={!verifyToken ? { cursor: 'not-allowed' } : {}}
|
||||
disabled={!verifyToken || isSending}
|
||||
style={
|
||||
!verifyToken && needCaptcha ? { cursor: 'not-allowed' } : {}
|
||||
}
|
||||
disabled={(!verifyToken && needCaptcha) || isSending}
|
||||
variant="plain"
|
||||
size="large"
|
||||
onClick={onResendClick}
|
||||
|
||||
@@ -6,15 +6,15 @@ import {
|
||||
} from '@affine/component/auth-components';
|
||||
import { Button } from '@affine/component/ui/button';
|
||||
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 { useService } from '@toeverything/infra';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import type { FC } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import type { AuthPanelProps } from './index';
|
||||
import * as styles from './style.css';
|
||||
import { useCaptcha } from './use-captcha';
|
||||
import { Captcha } from './use-captcha';
|
||||
|
||||
export const SignInWithPassword: FC<AuthPanelProps<'signInWithPassword'>> = ({
|
||||
setAuthData,
|
||||
@@ -26,15 +26,20 @@ export const SignInWithPassword: FC<AuthPanelProps<'signInWithPassword'>> = ({
|
||||
|
||||
const [password, setPassword] = useState('');
|
||||
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 [sendingEmail, setSendingEmail] = useState(false);
|
||||
|
||||
const onSignIn = useAsyncCallback(async () => {
|
||||
if (isLoading || !verifyToken) return;
|
||||
if (isLoading) return;
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
captchaService.revalidate();
|
||||
await authService.signInPassword({
|
||||
email,
|
||||
password,
|
||||
@@ -44,33 +49,31 @@ export const SignInWithPassword: FC<AuthPanelProps<'signInWithPassword'>> = ({
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setPasswordError(true);
|
||||
refreshChallenge?.();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [
|
||||
isLoading,
|
||||
verifyToken,
|
||||
captchaService,
|
||||
authService,
|
||||
email,
|
||||
password,
|
||||
challenge,
|
||||
refreshChallenge,
|
||||
]);
|
||||
|
||||
const sendMagicLink = useAsyncCallback(async () => {
|
||||
if (sendingEmail) return;
|
||||
setSendingEmail(true);
|
||||
try {
|
||||
if (verifyToken) {
|
||||
await authService.sendEmailMagicLink(
|
||||
email,
|
||||
verifyToken,
|
||||
challenge,
|
||||
redirectUrl
|
||||
);
|
||||
setAuthData({ state: 'afterSignInSendEmail' });
|
||||
}
|
||||
captchaService.revalidate();
|
||||
await authService.sendEmailMagicLink(
|
||||
email,
|
||||
verifyToken,
|
||||
challenge,
|
||||
redirectUrl
|
||||
);
|
||||
setAuthData({ state: 'afterSignInSendEmail' });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
notify.error({
|
||||
@@ -82,6 +85,7 @@ export const SignInWithPassword: FC<AuthPanelProps<'signInWithPassword'>> = ({
|
||||
}, [
|
||||
sendingEmail,
|
||||
verifyToken,
|
||||
captchaService,
|
||||
authService,
|
||||
email,
|
||||
challenge,
|
||||
@@ -139,21 +143,24 @@ export const SignInWithPassword: FC<AuthPanelProps<'signInWithPassword'>> = ({
|
||||
{t['com.affine.auth.forget']()}
|
||||
</a>
|
||||
</div>
|
||||
<div className={styles.sendMagicLinkButtonRow}>
|
||||
<a
|
||||
data-testid="send-magic-link-button"
|
||||
className={styles.linkButton}
|
||||
onClick={sendMagicLink}
|
||||
>
|
||||
{t['com.affine.auth.sign.auth.code.send-email.sign-in']()}
|
||||
</a>
|
||||
</div>
|
||||
{(verifyToken || !needCaptcha) && (
|
||||
<div className={styles.sendMagicLinkButtonRow}>
|
||||
<a
|
||||
data-testid="send-magic-link-button"
|
||||
className={styles.linkButton}
|
||||
onClick={sendMagicLink}
|
||||
>
|
||||
{t['com.affine.auth.sign.auth.code.send-email.sign-in']()}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{!verifyToken && needCaptcha && <Captcha />}
|
||||
<Button
|
||||
data-testid="sign-in-button"
|
||||
variant="primary"
|
||||
size="extraLarge"
|
||||
style={{ width: '100%' }}
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || (!verifyToken && needCaptcha)}
|
||||
onClick={onSignIn}
|
||||
>
|
||||
{t['com.affine.auth.sign.in']()}
|
||||
|
||||
@@ -2,9 +2,10 @@ import { notify } from '@affine/component';
|
||||
import { AuthInput, ModalHeader } from '@affine/component/auth-components';
|
||||
import { Button } from '@affine/component/ui/button';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { CaptchaService } from '@affine/core/modules/cloud';
|
||||
import { Trans, useI18n } from '@affine/i18n';
|
||||
import { ArrowRightBigIcon } from '@blocksuite/icons/rc';
|
||||
import { useService } from '@toeverything/infra';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import type { FC } from 'react';
|
||||
import { useState } from 'react';
|
||||
@@ -15,7 +16,7 @@ import { emailRegex } from '../../../utils/email-regex';
|
||||
import type { AuthPanelProps } from './index';
|
||||
import { OAuth } from './oauth';
|
||||
import * as style from './style.css';
|
||||
import { Captcha, useCaptcha } from './use-captcha';
|
||||
import { Captcha } from './use-captcha';
|
||||
|
||||
function validateEmail(email: string) {
|
||||
return emailRegex.test(email);
|
||||
@@ -30,7 +31,11 @@ export const SignIn: FC<AuthPanelProps<'signIn'>> = ({
|
||||
const authService = useService(AuthService);
|
||||
const [searchParams] = useSearchParams();
|
||||
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 [isValidEmail, setIsValidEmail] = useState(true);
|
||||
@@ -49,29 +54,16 @@ export const SignIn: FC<AuthPanelProps<'signIn'>> = ({
|
||||
const { hasPassword, registered } =
|
||||
await authService.checkUserByEmail(email);
|
||||
|
||||
if (verifyToken) {
|
||||
if (registered) {
|
||||
// provider password sign-in if user has by default
|
||||
// If with payment, onl support email sign in to avoid redirect to affine app
|
||||
if (hasPassword) {
|
||||
refreshChallenge?.();
|
||||
setAuthState({
|
||||
state: 'signInWithPassword',
|
||||
email,
|
||||
});
|
||||
} else {
|
||||
await authService.sendEmailMagicLink(
|
||||
email,
|
||||
verifyToken,
|
||||
challenge,
|
||||
redirectUrl
|
||||
);
|
||||
setAuthState({
|
||||
state: 'afterSignInSendEmail',
|
||||
email,
|
||||
});
|
||||
}
|
||||
if (registered) {
|
||||
// provider password sign-in if user has by default
|
||||
// If with payment, onl support email sign in to avoid redirect to affine app
|
||||
if (hasPassword) {
|
||||
setAuthState({
|
||||
state: 'signInWithPassword',
|
||||
email,
|
||||
});
|
||||
} else {
|
||||
captchaService.revalidate();
|
||||
await authService.sendEmailMagicLink(
|
||||
email,
|
||||
verifyToken,
|
||||
@@ -79,10 +71,22 @@ export const SignIn: FC<AuthPanelProps<'signIn'>> = ({
|
||||
redirectUrl
|
||||
);
|
||||
setAuthState({
|
||||
state: 'afterSignUpSendEmail',
|
||||
state: 'afterSignInSendEmail',
|
||||
email,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
captchaService.revalidate();
|
||||
await authService.sendEmailMagicLink(
|
||||
email,
|
||||
verifyToken,
|
||||
challenge,
|
||||
redirectUrl
|
||||
);
|
||||
setAuthState({
|
||||
state: 'afterSignUpSendEmail',
|
||||
email,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -96,10 +100,10 @@ export const SignIn: FC<AuthPanelProps<'signIn'>> = ({
|
||||
setIsMutating(false);
|
||||
}, [
|
||||
authService,
|
||||
captchaService,
|
||||
challenge,
|
||||
email,
|
||||
redirectUrl,
|
||||
refreshChallenge,
|
||||
setAuthState,
|
||||
verifyToken,
|
||||
]);
|
||||
@@ -125,7 +129,7 @@ export const SignIn: FC<AuthPanelProps<'signIn'>> = ({
|
||||
onEnter={onContinue}
|
||||
/>
|
||||
|
||||
{verifyToken ? (
|
||||
{verifyToken || !needCaptcha ? (
|
||||
<Button
|
||||
style={{ width: '100%' }}
|
||||
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 { useLiveData, useService } from '@toeverything/infra';
|
||||
import { atom, useAtom, useSetAtom } from 'jotai';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
import { ServerConfigService } from '../../../modules/cloud';
|
||||
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 = () => {
|
||||
const setCaptcha = useSetAtom(captchaAtom);
|
||||
const [response] = useAtom(responseAtom);
|
||||
const hasCaptchaFeature = useHasCaptcha();
|
||||
const captchaService = useService(CaptchaService);
|
||||
const hasCaptchaFeature = useLiveData(captchaService.needCaptcha$);
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (BUILD_CONFIG.isElectron) {
|
||||
if (response) {
|
||||
return <div className={style.captchaWrapper}>Making Challenge</div>;
|
||||
} else {
|
||||
return <div className={style.captchaWrapper}>Verified Client</div>;
|
||||
}
|
||||
if (isLoading) {
|
||||
return <div className={style.captchaWrapper}>Loading...</div>;
|
||||
}
|
||||
|
||||
if (verifyToken) {
|
||||
return <div className={style.captchaWrapper}>Verified Client</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Turnstile
|
||||
className={style.captchaWrapper}
|
||||
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];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user