captcha-basic/useCaptcha.tsx (240 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 } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import ReactDOM from 'react-dom/client';
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 } = useTranslation('plugin', {
keyPrefix: 'basic_captcha.frontend',
});
const refKey = useRef<CaptchaKey>(captchaKey);
const refCallback = useRef<SubmitCallback>();
const pending = useRef(false);
const autoInitCaptchaData = /email/i.test(refKey.current);
const [stateShow, setStateShow] = useState(false);
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 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 = (evt) => {
evt.preventDefault();
setImgCode({
value: evt.target.value || '',
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();
}
}, []);
useLayoutEffect(() => {
refImgCode.current = imgCode;
refCaptcha.current = captcha;
}, [captcha, imgCode]);
useEffect(() => {
refRoot.current?.render(
<Modal
size="sm"
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">
<div className="mb-3 p-2 d-flex align-items-center justify-content-center bg-light rounded-2">
<img
src={captcha?.captcha_img}
alt="captcha img"
width="auto"
height="60px"
/>
</div>
<InputGroup>
<Form.Control
type="text"
autoComplete="off"
placeholder={t('placeholder')}
isInvalid={imgCode?.isInvalid}
onChange={handleChange}
value={imgCode.value}
/>
<Button
onClick={fetchCaptchaData}
variant="outline-secondary"
title={t('refresh', { keyPrefix: 'btns' })}
style={{
borderTopRightRadius: '0.375rem',
borderBottomRightRadius: '0.375rem',
}}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-arrow-repeat"
viewBox="0 0 16 16">
<path d="M11.534 7h3.932a.25.25 0 0 1 .192.41l-1.966 2.36a.25.25 0 0 1-.384 0l-1.966-2.36a.25.25 0 0 1 .192-.41m-11 2h3.932a.25.25 0 0 0 .192-.41L2.692 6.23a.25.25 0 0 0-.384 0L.342 8.59A.25.25 0 0 0 .534 9" />
<path
fillRule="evenodd"
d="M8 3c-1.552 0-2.94.707-3.857 1.818a.5.5 0 1 1-.771-.636A6.002 6.002 0 0 1 13.917 7H12.9A5 5 0 0 0 8 3M3.1 9a5.002 5.002 0 0 0 8.757 2.182.5.5 0 1 1 .771.636A6.002 6.002 0 0 1 2.083 9z"
/>
</svg>
</Button>
<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;