captcha-google-v2/useCaptcha.tsx (248 lines of code) (raw):
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useRef, useState, useLayoutEffect } from 'react';
import { Modal, Form, Button, InputGroup, Spinner } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import ReCAPTCHA from "react-google-recaptcha";
import ReactDOM from 'react-dom/client';
import { languageKeys } from './common';
import type {
FormValue,
ImgCodeRes,
CaptchaKey,
FieldError,
ImgCodeReq,
} from './interface';
export interface Props {
captchaKey: CaptchaKey;
commonProps?: any;
}
const checkImgCode = ({
captchaKey,
commonProps,
}: Props) => {
return new Promise<ImgCodeRes>((resolve) => {
fetch(`/answer/api/v1/user/action/record?action=${captchaKey}`, {
headers: {
...commonProps?.headers
}
})
.then((resp) => {
return resp.json();
})
.then((data) => {
console.log('checkImgCode', data);
resolve(data.data);
});
});
};
type SubmitCallback = {
(): void;
};
const Index = ({
captchaKey,
commonProps,
}: Props) => {
const refRoot = useRef<ReactDOM.Root | null>(null);
if (refRoot.current === null) {
refRoot.current = ReactDOM.createRoot(document.createElement('div'));
}
const { t, i18n } = useTranslation('plugin', {
keyPrefix: 'google_v2_captcha.frontend',
});
const refKey = useRef<CaptchaKey>(captchaKey);
const refCallback = useRef<SubmitCallback>();
const pending = useRef(false);
const autoInitCaptchaData = /email/i.test(refKey.current);
const [isLoading, setIsLoading] = useState(true);
const [stateShow, setStateShow] = useState(false);
const [googleKey, setGoogleKey] = useState('');
const [captcha, setCaptcha] = useState<ImgCodeRes>({
captcha_id: '',
captcha_img: '',
verify: false,
});
const [imgCode, setImgCode] = useState<FormValue>({
value: '',
isInvalid: false,
errorMsg: '',
});
const refCaptcha = useRef(captcha);
const refImgCode = useRef(imgCode);
const getCaptchaKey = () => {
fetch(`/answer/api/v1/captcha/config`)
.then((resp) => {
return resp.json();
})
.then((data) => {
setGoogleKey(data?.data?.config.key);
})
}
const fetchCaptchaData = () => {
pending.current = true;
checkImgCode({
captchaKey: refKey.current,
commonProps,
})
.then((resp) => {
setCaptcha(resp);
})
.finally(() => {
pending.current = false;
});
};
const resetCapture = () => {
setCaptcha({
captcha_id: '',
captcha_img: '',
verify: false,
});
};
const resetImgCode = () => {
setImgCode({
value: '',
isInvalid: false,
errorMsg: '',
});
};
const resetCallback = () => {
refCallback.current = undefined;
};
const show = () => {
if (!stateShow) {
setStateShow(true);
}
};
/**
* There are some cases where the React scheduler cancels the execution of some functions,
* which prevents them from closing properly:
* for example, if the parent component uninstalls the child component directly,
* and the `captchaModal.close()` call is inside the child component.
* In this case, call `await captchaModal.close()` and wait for the close action to complete.
*/
const close = () => {
setStateShow(false);
resetCapture();
resetImgCode();
resetCallback();
const p = new Promise<void>((resolve) => {
setTimeout(resolve, 50);
});
return p;
};
const handleCaptchaError = (fel: FieldError[] = []) => {
console.log('handleCaptchaError', fel);
const captchaErr = fel.find((o) => {
return o.error_field === 'captcha_code';
});
const ri = refImgCode.current;
if (captchaErr) {
/**
* `imgCode.value` No value but a validation error is received,
* indicating that it is the first time the interface has returned a CAPTCHA error,
* triggering the CAPTCHA logic. There is no need to display the error message at this point.
*/
if (ri.value) {
setImgCode({
...ri,
isInvalid: true,
errorMsg: captchaErr.error_msg,
});
}
fetchCaptchaData();
show();
} else {
close();
}
// Assist business logic in filtering CAPTCHA error messages when necessary
return captchaErr;
};
const handleChange = (token) => {
setImgCode({
value: token || '',
isInvalid: false,
errorMsg: '',
});
};
const getCaptcha = () => {
const rc = refCaptcha.current;
const ri = refImgCode.current;
const r = {
verify: !!rc?.verify,
captcha_id: rc?.captcha_id,
captcha_code: ri.value,
};
return r;
};
const resolveCaptchaReq = (req: ImgCodeReq) => {
const r = getCaptcha();
if (r.verify) {
req.captcha_code = r.captcha_code;
req.captcha_id = r.captcha_id;
}
};
const handleSubmit = (evt) => {
evt.preventDefault();
if (!imgCode.value) {
return;
}
if (refCallback.current) {
refCallback.current();
}
};
useEffect(() => {
if (autoInitCaptchaData) {
fetchCaptchaData();
}
}, []);
useEffect(() => {
if (stateShow) {
getCaptchaKey();
setTimeout(() => {
setIsLoading(false);
}, 800)
} else {
setTimeout(() => {
setIsLoading(true);
}, 100)
}
}, [stateShow]);
useLayoutEffect(() => {
refImgCode.current = imgCode;
refCaptcha.current = captcha;
}, [captcha, imgCode]);
useEffect(() => {
refRoot.current?.render(
<Modal
style={{ width: '336px', margin: '0 auto', right: 0 }}
title="Captcha"
show={stateShow}
onHide={() => close()}
centered>
<Modal.Header closeButton>
<Modal.Title as="h5">{t('title')}</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form noValidate onSubmit={handleSubmit}>
<Form.Group controlId="code" className="mb-3">
<InputGroup>
{isLoading && !captchaKey && <div style={{ height: '78px' }} />}
<div className={isLoading ? 'w-100 text-center d-block' : 'w-100 text-center d-none'} style={{ position: 'absolute', top: 0, left: 0, height: '78px', lineHeight: '78px' }}>
<Spinner animation="border" variant="secondary" />
</div>
{googleKey && (
<ReCAPTCHA
sitekey={googleKey}
theme="light"
size="normal"
className={isLoading ? 'invisible' : 'visible'}
hl={languageKeys[i18n.language] || 'en'}
onChange={(token) =>handleChange(token)}
onErrored={() => resetImgCode()}
onExpired={() => resetImgCode()}
/>
)}
<Form.Control
type="text"
autoComplete="off"
className="d-none"
isInvalid={imgCode?.isInvalid}
/>
<Form.Control.Feedback type="invalid">
{imgCode?.errorMsg}
</Form.Control.Feedback>
</InputGroup>
</Form.Group>
<div className="d-grid">
<Button type="submit" disabled={!imgCode.value}>
{t('verify')}
</Button>
</div>
</Form>
</Modal.Body>
</Modal>,
);
});
const r = {
close,
show,
check: (submitFunc: SubmitCallback) => {
if (pending.current) {
return false;
}
refCallback.current = submitFunc;
if (captcha?.verify) {
show();
return false;
}
return submitFunc();
},
getCaptcha,
resolveCaptchaReq,
fetchCaptchaData,
handleCaptchaError,
};
return r;
};
export default Index;