Эх сурвалжийг харах

Feat: forgot-password page

AH-dark 2 жил өмнө
parent
commit
7d692f3dd5

+ 2 - 0
package.json

@@ -46,6 +46,7 @@
     "react-app-polyfill": "^3.0.0",
     "react-device-detect": "^2.2.2",
     "react-dom": "^18.2.0",
+    "react-google-recaptcha": "^2.1.0",
     "react-i18next": "^12.1.1",
     "react-redux": "^8.0.5",
     "react-refresh": "^0.11.0",
@@ -81,6 +82,7 @@
     "@types/qs": "^6.9.7",
     "@types/react": "^18.0.26",
     "@types/react-dom": "^18.0.6",
+    "@types/react-google-recaptcha": "^2.1.5",
     "@types/uuid": "^8.3.4",
     "@types/webpack-env": "^1.18.0",
     "@typescript-eslint/eslint-plugin": "5.40.0",

+ 30 - 0
pnpm-lock.yaml

@@ -22,6 +22,7 @@ specifiers:
   '@types/qs': ^6.9.7
   '@types/react': ^18.0.26
   '@types/react-dom': ^18.0.6
+  '@types/react-google-recaptcha': ^2.1.5
   '@types/uuid': ^8.3.4
   '@types/webpack-env': ^1.18.0
   '@typescript-eslint/eslint-plugin': 5.40.0
@@ -73,6 +74,7 @@ specifiers:
   react-device-detect: ^2.2.2
   react-dom: ^18.2.0
   react-error-overlay: 6.0.9
+  react-google-recaptcha: ^2.1.0
   react-i18next: ^12.1.1
   react-redux: ^8.0.5
   react-refresh: ^0.11.0
@@ -144,6 +146,7 @@ dependencies:
   react-app-polyfill: 3.0.0
   react-device-detect: 2.2.2_biqbaboplfbrettd7655fr4n2y
   react-dom: 18.2.0_react@18.2.0
+  react-google-recaptcha: 2.1.0_react@18.2.0
   react-i18next: 12.1.1_ewanii43wbrufvukcu3uzq3hsy
   react-redux: 8.0.5_fzvwyuupj4d6el7pqkxm6fygb4
   react-refresh: 0.11.0
@@ -179,6 +182,7 @@ devDependencies:
   '@types/qs': 6.9.7
   '@types/react': 18.0.26
   '@types/react-dom': 18.0.6
+  '@types/react-google-recaptcha': 2.1.5
   '@types/uuid': 8.3.4
   '@types/webpack-env': 1.18.0
   '@typescript-eslint/eslint-plugin': 5.40.0_25sstg4uu2sk4pm7xcyzuov7xq
@@ -3304,6 +3308,12 @@ packages:
     dependencies:
       '@types/react': 18.0.26
 
+  /@types/react-google-recaptcha/2.1.5:
+    resolution: {integrity: sha512-iWTjmVttlNgp0teyh7eBXqNOQzVq2RWNiFROWjraOptRnb1OcHJehQnji0sjqIRAk9K0z8stjyhU+OLpPb0N6w==}
+    dependencies:
+      '@types/react': 18.0.26
+    dev: true
+
   /@types/react-is/17.0.3:
     resolution: {integrity: sha512-aBTIWg1emtu95bLTLx0cpkxwGW3ueZv71nE2YFBpL8k/z5czEW8yYpOo8Dp+UUAFAtKwNaOsh/ioSeQnWlZcfw==}
     dependencies:
@@ -6824,6 +6834,16 @@ packages:
       whatwg-fetch: 3.6.2
     dev: false
 
+  /react-async-script/1.2.0_react@18.2.0:
+    resolution: {integrity: sha512-bCpkbm9JiAuMGhkqoAiC0lLkb40DJ0HOEJIku+9JDjxX3Rcs+ztEOG13wbrOskt3n2DTrjshhaQ/iay+SnGg5Q==}
+    peerDependencies:
+      react: '>=16.4.1'
+    dependencies:
+      hoist-non-react-statics: 3.3.2
+      prop-types: 15.8.1
+      react: 18.2.0
+    dev: false
+
   /react-device-detect/2.2.2_biqbaboplfbrettd7655fr4n2y:
     resolution: {integrity: sha512-zSN1gIAztUekp5qUT/ybHwQ9fmOqVT1psxpSlTn1pe0CO+fnJHKRLOWWac5nKxOxvOpD/w84hk1I+EydrJp7SA==}
     peerDependencies:
@@ -6853,6 +6873,16 @@ packages:
     resolution: {integrity: sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==}
     dev: false
 
+  /react-google-recaptcha/2.1.0_react@18.2.0:
+    resolution: {integrity: sha512-K9jr7e0CWFigi8KxC3WPvNqZZ47df2RrMAta6KmRoE4RUi7Ys6NmNjytpXpg4HI/svmQJLKR+PncEPaNJ98DqQ==}
+    peerDependencies:
+      react: '>=16.4.1'
+    dependencies:
+      prop-types: 15.8.1
+      react: 18.2.0
+      react-async-script: 1.2.0_react@18.2.0
+    dev: false
+
   /react-i18next/12.1.1_ewanii43wbrufvukcu3uzq3hsy:
     resolution: {integrity: sha512-mFdieOI0LDy84q3JuZU6Aou1DoWW2fhapcTGeBS8+vWSJuViuoCLQAMYSb0QoHhXS8B0WKUOPpx4cffAP7r/aA==}
     peerDependencies:

+ 41 - 0
src/i18n/resources/zh-cn/common.json

@@ -50,7 +50,48 @@
     "invite_code_placeholder_required": "请输入邀请码",
     "submit": "注册"
   },
+  "forgot_password": {
+    "title": "忘记密码",
+    "back_to_login": "返回登录",
+    "email": "邮箱",
+    "email_placeholder": "请输入邮箱",
+    "email_required": "您必须输入邮箱",
+    "email_invalid": "您输入的邮箱无效",
+    "email_code": "邮箱验证码",
+    "email_code_placeholder": "请输入邮箱验证码",
+    "email_code_required": "您必须输入邮箱验证码",
+    "email_code_min": "邮箱验证码长度不能少于 {{count}} 个字符",
+    "email_code_max": "邮箱验证码长度不能超过 {{count}} 个字符",
+    "send_email_code": "验证",
+    "password": "密码",
+    "password_placeholder": "请输入密码",
+    "password_required": "您必须输入密码",
+    "password_min": "密码长度不能少于 {{count}} 个字符",
+    "password_max": "密码长度不能超过 {{count}} 个字符",
+    "password_confirmation": "确认密码",
+    "password_confirmation_placeholder": "请再次输入密码",
+    "password_confirmation_required": "您必须再次输入密码",
+    "password_confirmation_invalid": "您两次输入的密码不一致",
+    "password_strength": "密码强度",
+    "password_strength_poor": "弱",
+    "password_strength_weak": "中",
+    "password_strength_normal": "正常",
+    "password_strength_good": "好",
+    "password_strength_strong": "强",
+    "password_confirm": "确认密码",
+    "password_confirm_invalid": "您两次输入的密码不一致",
+    "password_confirm_required": "您必须再次输入密码",
+    "submit": "重置密码"
+  },
   "auth": {
+    "captcha": {
+      "title": "人机验证",
+      "description": "请完成下面的验证",
+      "submit": "提交",
+      "success": "邮件已发送,请注意查收",
+      "error": "验证失败,请重试",
+      "error_null_token": "出现错误,Token 为空,请重试"
+    },
     "footer": {
       "privacy": "<2>隐私政策</2> 指定我们如何收集、使用和保护您的信息。",
       "links": {

+ 4 - 0
src/model/send_mail.ts

@@ -0,0 +1,4 @@
+export default interface SendMailPayload {
+  email: string;
+  recaptcha_data?: string;
+}

+ 0 - 52
src/pages/auth/check-mail.tsx

@@ -1,52 +0,0 @@
-import { Link } from "react-router-dom";
-
-// material-ui
-import { useTheme } from "@mui/material/styles";
-import { Box, Button, Grid, Divider, Typography, useMediaQuery } from "@mui/material";
-
-// project import
-import AnimateButton from "@/components/@extended/AnimateButton";
-import AuthWrapper from "@/sections/auth/AuthWrapper";
-import { useSelector } from "@/store";
-
-// ================================|| CHECK MAIL ||================================ //
-
-const CheckMail = () => {
-  const theme = useTheme();
-  const matchDownSM = useMediaQuery(theme.breakpoints.down("sm"));
-
-  const { isLoggedIn } = useSelector((state) => state.auth);
-
-  return (
-    <AuthWrapper>
-      <Grid container spacing={3}>
-        <Grid item xs={12}>
-          <Box sx={{ mb: { xs: -0.5, sm: 0.5 } }}>
-            <Typography variant="h3">Hi, Check Your Mail</Typography>
-            <Typography color="secondary" sx={{ mb: 0.5, mt: 1.25 }}>
-              We have sent a password recover instructions to your email.
-            </Typography>
-          </Box>
-        </Grid>
-        <Grid item xs={12}>
-          <AnimateButton>
-            <Button
-              component={Link}
-              to={isLoggedIn ? "/auth/login" : "/login"}
-              disableElevation
-              fullWidth
-              size="large"
-              type="submit"
-              variant="contained"
-              color="primary"
-            >
-              Sign in
-            </Button>
-          </AnimateButton>
-        </Grid>
-      </Grid>
-    </AuthWrapper>
-  );
-};
-
-export default CheckMail;

+ 0 - 29
src/pages/auth/code-verification.tsx

@@ -1,29 +0,0 @@
-// material-ui
-import { Grid, Stack, Typography } from "@mui/material";
-
-// project import
-import AuthWrapper from "@/sections/auth/AuthWrapper";
-import AuthCodeVerification from "@/sections/auth/auth-forms/AuthCodeVerification";
-
-// ================================|| CODE VERIFICATION ||================================ //
-
-const CodeVerification = () => (
-  <AuthWrapper>
-    <Grid container spacing={3}>
-      <Grid item xs={12}>
-        <Stack spacing={1}>
-          <Typography variant="h3">Enter Verification Code</Typography>
-          <Typography color="secondary">We send you on mail.</Typography>
-        </Stack>
-      </Grid>
-      <Grid item xs={12}>
-        <Typography>We`ve send you code on jone. ****@company.com</Typography>
-      </Grid>
-      <Grid item xs={12}>
-        <AuthCodeVerification />
-      </Grid>
-    </Grid>
-  </AuthWrapper>
-);
-
-export default CodeVerification;

+ 19 - 32
src/pages/auth/forgot-password.tsx

@@ -6,41 +6,28 @@ import { Grid, Stack, Typography } from "@mui/material";
 // project import
 import AuthWrapper from "@/sections/auth/AuthWrapper";
 import AuthForgotPassword from "@/sections/auth/auth-forms/AuthForgotPassword";
-import { useSelector } from "@/store";
+import { Trans } from "react-i18next";
 
 // ================================|| FORGOT PASSWORD ||================================ //
 
-const ForgotPassword = () => {
-  const { isLoggedIn } = useSelector((state) => state.auth);
-
-  return (
-    <AuthWrapper>
-      <Grid container spacing={3}>
-        <Grid item xs={12}>
-          <Stack
-            direction="row"
-            justifyContent="space-between"
-            alignItems="baseline"
-            sx={{ mb: { xs: -0.5, sm: 0.5 } }}
-          >
-            <Typography variant="h3">Forgot Password</Typography>
-            <Typography
-              component={Link}
-              to={isLoggedIn ? "/auth/login" : "/login"}
-              variant="body1"
-              sx={{ textDecoration: "none" }}
-              color="primary"
-            >
-              Back to Login
-            </Typography>
-          </Stack>
-        </Grid>
-        <Grid item xs={12}>
-          <AuthForgotPassword />
-        </Grid>
+const ForgotPassword = () => (
+  <AuthWrapper>
+    <Grid container spacing={3}>
+      <Grid item xs={12}>
+        <Stack direction="row" justifyContent="space-between" alignItems="baseline" sx={{ mb: { xs: -0.5, sm: 0.5 } }}>
+          <Typography variant="h3">
+            <Trans i18nKey={"forgot_password.title"}>Forgot password</Trans>
+          </Typography>
+          <Typography component={Link} to={"/login"} variant="body1" sx={{ textDecoration: "none" }} color="primary">
+            <Trans i18nKey={"forgot_password.back_to_login"}>Back to login</Trans>
+          </Typography>
+        </Stack>
+      </Grid>
+      <Grid item xs={12}>
+        <AuthForgotPassword />
       </Grid>
-    </AuthWrapper>
-  );
-};
+    </Grid>
+  </AuthWrapper>
+);
 
 export default ForgotPassword;

+ 0 - 26
src/pages/auth/reset-password.tsx

@@ -1,26 +0,0 @@
-// material-ui
-import { Grid, Stack, Typography } from "@mui/material";
-
-// project import
-import AuthWrapper from "@/sections/auth/AuthWrapper";
-import AuthResetPassword from "@/sections/auth/auth-forms/AuthResetPassword";
-
-// ================================|| RESET PASSWORD ||================================ //
-
-const ResetPassword = () => (
-  <AuthWrapper>
-    <Grid container spacing={3}>
-      <Grid item xs={12}>
-        <Stack sx={{ mb: { xs: -0.5, sm: 0.5 } }} spacing={1}>
-          <Typography variant="h3">Reset Password</Typography>
-          <Typography color="secondary">Please choose your new password</Typography>
-        </Stack>
-      </Grid>
-      <Grid item xs={12}>
-        <AuthResetPassword />
-      </Grid>
-    </Grid>
-  </AuthWrapper>
-);
-
-export default ResetPassword;

+ 244 - 48
src/sections/auth/auth-forms/AuthForgotPassword.tsx

@@ -1,8 +1,22 @@
-import { useNavigate } from "react-router-dom";
-import { useDispatch } from "react-redux";
+import { Link as RouterLink, useNavigate } from "react-router-dom";
 
 // material-ui
-import { Button, FormHelperText, Grid, InputLabel, OutlinedInput, Stack, Typography } from "@mui/material";
+import {
+  Box,
+  Button,
+  Checkbox,
+  CircularProgress,
+  FormControl,
+  FormControlLabel,
+  FormHelperText,
+  Grid,
+  InputAdornment,
+  InputLabel,
+  Link,
+  OutlinedInput,
+  Stack,
+  Typography
+} from "@mui/material";
 import { useSnackbar } from "notistack";
 
 // third party
@@ -14,17 +28,47 @@ import useScriptRef from "@/hooks/useScriptRef";
 import AnimateButton from "@/components/@extended/AnimateButton";
 import { useSelector } from "@/store";
 import { useResetPasswordMutation } from "@/store/services/api";
+import { Trans, useTranslation } from "react-i18next";
+import SendMailButton from "@/sections/auth/auth-forms/SendMailButton";
+import OtpInput from "react18-input-otp";
+import IconButton from "@/components/@extended/IconButton";
+import { EyeInvisibleOutlined, EyeOutlined } from "@ant-design/icons";
+import lo from "lodash-es";
+import React, { SyntheticEvent, useEffect, useState } from "react";
+import { StringColorProps } from "@/types/password";
+import { strengthColor, strengthIndicator } from "@/utils/password-strength";
+import { useTheme } from "@mui/material/styles";
 
 // ============================|| FIREBASE - FORGOT PASSWORD ||============================ //
 
 const AuthForgotPassword = () => {
+  const theme = useTheme();
+  const { t } = useTranslation("common");
   const scriptedRef = useScriptRef();
-  const dispatch = useDispatch();
   const navigate = useNavigate();
   const { enqueueSnackbar } = useSnackbar();
 
   const { isLoggedIn } = useSelector((state) => state.auth);
-  const [resetPassword, {}] = useResetPasswordMutation();
+  const [resetPassword] = useResetPasswordMutation();
+
+  const [level, setLevel] = useState<StringColorProps>();
+  const [showPassword, setShowPassword] = useState(false);
+  const handleClickShowPassword = () => {
+    setShowPassword(!showPassword);
+  };
+
+  const handleMouseDownPassword = (event: SyntheticEvent) => {
+    event.preventDefault();
+  };
+
+  const handlePasswordChange = (value: string) => {
+    const temp = strengthIndicator(value);
+    setLevel(strengthColor(temp));
+  };
+
+  useEffect(() => {
+    handlePasswordChange("");
+  }, []);
 
   return (
     <>
@@ -32,82 +76,230 @@ const AuthForgotPassword = () => {
         initialValues={{
           email: "",
           password: "",
+          password_confirm: "",
           email_code: "",
           submit: null
         }}
         validationSchema={Yup.object().shape({
-          email: Yup.string().email("Must be a valid email").max(255).required("Email is required")
+          email: Yup.string()
+            .email(t("forgot_password.email_invalid").toString())
+            .max(255, t("forgot_password.email_max", { count: 255 }).toString())
+            .required(t("forgot_password.email_required").toString()),
+          password: Yup.string()
+            .max(255, t("forgot_password.password_max", { count: 255 }).toString())
+            .required(t("forgot_password.password_required").toString()),
+          password_confirm: Yup.string()
+            .oneOf([Yup.ref("password"), null], t("forgot_password.password_confirm_invalid").toString())
+            .required(t("forgot_password.password_confirm_required").toString()),
+          email_code: Yup.string()
+            .min(6, t("forgot_password.email_code_min", { count: 6 }).toString())
+            .max(6, t("forgot_password.email_code_max", { count: 6 }).toString())
+            .required(t("forgot_password.email_code_required").toString())
         })}
         onSubmit={async (values, { setErrors, setStatus, setSubmitting }) => {
-          try {
-            await resetPassword(values)
-              .unwrap()
-              .then(
-                () => {
-                  setStatus({ success: true });
-                  setSubmitting(false);
-                  // TODO: translate
-                  enqueueSnackbar("Check mail for reset password link", {
-                    variant: "success"
-                  });
-                  setTimeout(() => {
-                    navigate(isLoggedIn ? "/auth/check-mail" : "/check-mail", { replace: true });
-                  }, 1500);
-
-                  // WARNING: do not set any formik state here as formik might be already destroyed here. You may get following error by doing so.
-                  // Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application.
-                  // To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.
-                  // github issue: https://github.com/formium/formik/issues/2430
-                },
-                (err: any) => {
-                  setStatus({ success: false });
-                  setErrors({ submit: err.message });
-                  setSubmitting(false);
-                }
-              );
-          } catch (err: any) {
-            console.error(err);
-            if (scriptedRef.current) {
-              setStatus({ success: false });
-              setErrors({ submit: err.message });
-              setSubmitting(false);
-            }
+          if (values.email_code.length !== 6) {
+            setStatus({ success: false });
+            setErrors({ email_code: t("forgot_password.email_code_invalid").toString() });
+            return;
           }
+
+          await resetPassword({
+            email: values.email,
+            password: values.password,
+            email_code: values.email_code
+          })
+            .unwrap()
+            .then(() => {
+              if (scriptedRef.current) {
+                setStatus({ success: true });
+                setSubmitting(false);
+                enqueueSnackbar(t("notice::forgot_password.reset_success"), {
+                  variant: "success"
+                });
+                navigate("/login", { replace: true });
+              }
+            })
+            .catch((err: any) => {
+              console.error("Error in reset password", err);
+              if (scriptedRef.current) {
+                setStatus({ success: false });
+                setErrors(err.errors || { submit: err.message });
+                setSubmitting(false);
+              }
+            })
+            .finally(() => {
+              if (scriptedRef.current) {
+                setSubmitting(false);
+              }
+            });
         }}
       >
-        {({ errors, handleBlur, handleChange, handleSubmit, isSubmitting, touched, values }) => (
+        {({ errors, handleBlur, handleChange, handleSubmit, isSubmitting, touched, values, setValues }) => (
           <form noValidate onSubmit={handleSubmit}>
             <Grid container spacing={3}>
+              {/* Email */}
               <Grid item xs={12}>
                 <Stack spacing={1}>
-                  <InputLabel htmlFor="email-forgot">Email Address</InputLabel>
+                  <InputLabel htmlFor="email">
+                    <Trans>{"forgot_password.email"}</Trans>
+                  </InputLabel>
                   <OutlinedInput
                     fullWidth
                     error={Boolean(touched.email && errors.email)}
-                    id="email-forgot"
+                    id="email"
                     type="email"
                     value={values.email}
                     name="email"
                     onBlur={handleBlur}
                     onChange={handleChange}
-                    placeholder="Enter email address"
+                    placeholder="user@example.com"
                     inputProps={{}}
+                    endAdornment={<SendMailButton email={values.email} />}
                   />
                   {touched.email && errors.email && (
-                    <FormHelperText error id="helper-text-email-forgot">
+                    <FormHelperText error id="helper-text-email-signup">
                       {errors.email}
                     </FormHelperText>
                   )}
                 </Stack>
               </Grid>
+              {/* Email Code */}
+              <Grid item xs={12}>
+                <Stack spacing={1}>
+                  <InputLabel htmlFor="email-code-signup">
+                    <Trans>{"forgot_password.email_code"}</Trans>
+                  </InputLabel>
+                  <OtpInput
+                    value={values.email_code}
+                    onChange={(otp: string) => {
+                      setValues((prev) => ({
+                        ...prev,
+                        email_code: otp
+                      }));
+                    }}
+                    numInputs={6}
+                    containerStyle={{ justifyContent: "space-between" }}
+                    inputProps={{
+                      name: "email_code",
+                      id: "email-code-signup",
+                      onBlur: handleBlur
+                    }}
+                    inputStyle={{
+                      width: "100%",
+                      margin: "8px",
+                      padding: "10px",
+                      border: `1px solid ${
+                        theme.palette.mode === "dark" ? theme.palette.grey[200] : theme.palette.grey[300]
+                      }`,
+                      borderRadius: 4,
+                      ":hover": {
+                        borderColor: theme.palette.primary.main
+                      }
+                    }}
+                    focusStyle={{
+                      outline: "none",
+                      boxShadow: theme.customShadows.primary,
+                      border: `1px solid ${theme.palette.primary.main}`
+                    }}
+                  />
+                  {touched.email_code && errors.email_code && (
+                    <FormHelperText error id="helper-text-email-signup">
+                      {errors.email_code}
+                    </FormHelperText>
+                  )}
+                </Stack>
+              </Grid>
+
+              {/* Password */}
+              <Grid item xs={12}>
+                <Stack spacing={1}>
+                  <InputLabel htmlFor="password-signup">
+                    <Trans>{"forgot_password.password"}</Trans>
+                  </InputLabel>
+                  <OutlinedInput
+                    fullWidth
+                    error={Boolean(touched.password && errors.password)}
+                    id="password-signup"
+                    type={showPassword ? "text" : "password"}
+                    value={values.password}
+                    name="password"
+                    onBlur={handleBlur}
+                    onChange={(e) => {
+                      handleChange(e);
+                      handlePasswordChange(e.target.value);
+                    }}
+                    autoComplete={"new-password"}
+                    endAdornment={
+                      <InputAdornment position="end">
+                        <IconButton
+                          aria-label="toggle password visibility"
+                          onClick={handleClickShowPassword}
+                          onMouseDown={handleMouseDownPassword}
+                          edge="end"
+                          color="secondary"
+                        >
+                          {showPassword ? <EyeOutlined /> : <EyeInvisibleOutlined />}
+                        </IconButton>
+                      </InputAdornment>
+                    }
+                    placeholder="******"
+                    inputProps={{}}
+                  />
+                  {touched.password && errors.password && (
+                    <FormHelperText error id="helper-text-password-signup">
+                      {errors.password}
+                    </FormHelperText>
+                  )}
+                </Stack>
+                <FormControl fullWidth sx={{ mt: 2 }}>
+                  <Grid container spacing={2} alignItems="center">
+                    <Grid item>
+                      <Box sx={{ bgcolor: level?.color, width: 85, height: 8, borderRadius: "7px" }} />
+                    </Grid>
+                    <Grid item>
+                      <Typography variant="subtitle1" fontSize="0.75rem">
+                        {t("forgot_password.password_strength", {
+                          context: lo.lowerCase(level?.label)
+                        }).toString()}
+                      </Typography>
+                    </Grid>
+                  </Grid>
+                </FormControl>
+              </Grid>
+              {/* Password Confirm */}
+              <Grid item xs={12}>
+                <Stack spacing={1}>
+                  <InputLabel htmlFor="password-confirm">
+                    <Trans>{"forgot_password.password_confirm"}</Trans>
+                  </InputLabel>
+                  <OutlinedInput
+                    fullWidth
+                    error={Boolean(touched.password_confirm && errors.password_confirm)}
+                    id="password-confirm"
+                    type={showPassword ? "text" : "password"}
+                    value={values.password_confirm}
+                    name="password_confirm"
+                    onBlur={handleBlur}
+                    onChange={(e) => {
+                      handleChange(e);
+                      handlePasswordChange(e.target.value);
+                    }}
+                    autoComplete={"new-password"}
+                    placeholder="******"
+                    inputProps={{}}
+                  />
+                  {touched.password_confirm && errors.password_confirm && (
+                    <FormHelperText error id="helper-text-password-confirm">
+                      {errors.password_confirm}
+                    </FormHelperText>
+                  )}
+                </Stack>
+              </Grid>
               {errors.submit && (
                 <Grid item xs={12}>
                   <FormHelperText error>{errors.submit}</FormHelperText>
                 </Grid>
               )}
-              <Grid item xs={12} sx={{ mb: -2 }}>
-                <Typography variant="caption">Do not forgot to check SPAM box.</Typography>
-              </Grid>
               <Grid item xs={12}>
                 <AnimateButton>
                   <Button
@@ -119,7 +311,11 @@ const AuthForgotPassword = () => {
                     variant="contained"
                     color="primary"
                   >
-                    Send Password Reset Email
+                    {isSubmitting ? (
+                      <CircularProgress size={24} />
+                    ) : (
+                      <Trans i18nKey={"forgot_password.submit"}>重置密码</Trans>
+                    )}
                   </Button>
                 </AnimateButton>
               </Grid>

+ 6 - 9
src/sections/auth/auth-forms/AuthLogin.tsx

@@ -3,19 +3,17 @@ import { Link as RouterLink, useNavigate } from "react-router-dom";
 
 // material-ui
 import {
+  Box,
   Button,
-  Checkbox,
-  Divider,
-  FormControlLabel,
+  CircularProgress,
   FormHelperText,
   Grid,
-  Link,
   InputAdornment,
   InputLabel,
+  Link,
   OutlinedInput,
   Stack,
-  Typography,
-  Box
+  Typography
 } from "@mui/material";
 
 // third party
@@ -31,13 +29,12 @@ import IconButton from "@/components/@extended/IconButton";
 import AnimateButton from "@/components/@extended/AnimateButton";
 
 // assets
-import { EyeOutlined, EyeInvisibleOutlined } from "@ant-design/icons";
+import { EyeInvisibleOutlined, EyeOutlined } from "@ant-design/icons";
 import lo from "lodash-es";
 
 // ============================|| FIREBASE - LOGIN ||============================ //
 
 const AuthLogin = () => {
-  const [checked, setChecked] = React.useState(false);
   const [capsWarning, setCapsWarning] = React.useState(false);
 
   const { isLoggedIn } = useSelector((state) => state.auth);
@@ -212,7 +209,7 @@ const AuthLogin = () => {
                     variant="contained"
                     color="primary"
                   >
-                    {t("submit").toString()}
+                    {isSubmitting ? <CircularProgress size={24} /> : <Trans i18nKey={"login.submit"}>Submit</Trans>}
                   </Button>
                 </AnimateButton>
               </Grid>

+ 10 - 37
src/sections/auth/auth-forms/AuthRegister.tsx

@@ -1,4 +1,4 @@
-import React, { useEffect, useState, SyntheticEvent, useMemo } from "react";
+import React, { SyntheticEvent, useEffect, useMemo, useState } from "react";
 import lo from "lodash-es";
 import { Link as RouterLink, useNavigate } from "react-router-dom";
 
@@ -6,20 +6,18 @@ import { Link as RouterLink, useNavigate } from "react-router-dom";
 import {
   Box,
   Button,
-  Divider,
+  Checkbox,
+  CircularProgress,
   FormControl,
+  FormControlLabel,
   FormHelperText,
   Grid,
-  Link,
   InputAdornment,
   InputLabel,
+  Link,
   OutlinedInput,
   Stack,
-  Typography,
-  FormControlLabel,
-  Radio,
-  Checkbox,
-  CircularProgress
+  Typography
 } from "@mui/material";
 import { useTheme } from "@mui/material/styles";
 
@@ -33,7 +31,8 @@ import OtpInput from "react18-input-otp";
 import useScriptRef from "@/hooks/useScriptRef";
 import IconButton from "@/components/@extended/IconButton";
 import AnimateButton from "@/components/@extended/AnimateButton";
-import { useGetGuestConfigQuery, useRegisterMutation, useSendEmailVerifyMutation } from "@/store/services/api";
+import SendMailButton from "@/sections/auth/auth-forms/SendMailButton";
+import { useGetGuestConfigQuery, useRegisterMutation } from "@/store/services/api";
 import { strengthColor, strengthIndicator } from "@/utils/password-strength";
 
 // types
@@ -41,7 +40,7 @@ import { StringColorProps } from "@/types/password";
 import { RegisterPayload } from "@/model/register";
 
 // assets
-import { EyeOutlined, EyeInvisibleOutlined, SendOutlined } from "@ant-design/icons";
+import { EyeInvisibleOutlined, EyeOutlined } from "@ant-design/icons";
 import { useSnackbar } from "notistack";
 
 // ============================|| FIREBASE - REGISTER ||============================ //
@@ -54,7 +53,6 @@ const AuthRegister = () => {
   const { enqueueSnackbar } = useSnackbar();
 
   const [register] = useRegisterMutation();
-  const [sendEmailCode] = useSendEmailVerifyMutation();
   const { data: siteConfig } = useGetGuestConfigQuery();
 
   const [level, setLevel] = useState<StringColorProps>();
@@ -72,20 +70,6 @@ const AuthRegister = () => {
     setLevel(strengthColor(temp));
   };
 
-  const handleSendEmailCode = (email: string) => () => {
-    console.log(`send email code to ${email}`);
-    sendEmailCode(email)
-      .unwrap()
-      .then((res) => {
-        console.log(res);
-        enqueueSnackbar(t("notice::send_email_code_success"), { variant: "success" });
-      })
-      .catch((err) => {
-        console.error("send email code error", err);
-        enqueueSnackbar(t("notice::send_email_code_fail"), { variant: "error" });
-      });
-  };
-
   useEffect(() => {
     handlePasswordChange("");
   }, []);
@@ -191,18 +175,7 @@ const AuthRegister = () => {
                     placeholder="user@example.com"
                     inputProps={{}}
                     endAdornment={
-                      siteConfig?.is_email_verify === 1 ? (
-                        <InputAdornment position="end">
-                          <IconButton
-                            aria-label="send email code"
-                            onClick={handleSendEmailCode(values.email)}
-                            edge="end"
-                            color="secondary"
-                          >
-                            <SendOutlined />
-                          </IconButton>
-                        </InputAdornment>
-                      ) : undefined
+                      siteConfig?.is_email_verify === 1 ? <SendMailButton email={values.email} /> : undefined
                     }
                   />
                   {touched.email && errors.email && (

+ 117 - 0
src/sections/auth/auth-forms/SendMailButton.tsx

@@ -0,0 +1,117 @@
+import React from "react";
+import lo from "lodash-es";
+
+// material-ui
+import { Dialog, DialogContent, DialogTitle, InputAdornment } from "@mui/material";
+import { useSnackbar } from "notistack";
+
+// third party
+import ReCaptcha from "react-google-recaptcha";
+import { Trans, useTranslation } from "react-i18next";
+
+// project import
+import IconButton from "@/components/@extended/IconButton";
+import { useGetGuestConfigQuery, useSendEmailVerifyMutation } from "@/store/services/api";
+
+// assets
+import { SendOutlined } from "@ant-design/icons";
+
+// ============================|| AUTH - SEND EMAIL VERIFY ||============================ //
+
+export interface SendMailButtonProps {
+  email: string;
+}
+
+export const SendMailWithCaptchaButton: React.FC<SendMailButtonProps> = ({ email }) => {
+  const { data: guestConfig } = useGetGuestConfigQuery();
+  const [sendEmailVerify, { isLoading }] = useSendEmailVerifyMutation();
+  const { enqueueSnackbar } = useSnackbar();
+  const { t } = useTranslation();
+
+  const [open, setOpen] = React.useState(false);
+
+  return (
+    <>
+      <InputAdornment position="end">
+        <IconButton
+          aria-label="send email code"
+          onClick={() => setOpen(true)}
+          edge="end"
+          color="secondary"
+          disabled={isLoading}
+        >
+          <SendOutlined />
+        </IconButton>
+      </InputAdornment>
+      <Dialog open={open} onClose={() => setOpen(false)}>
+        <DialogTitle>
+          <Trans i18nKey={"auth.captcha.title"}>Captcha</Trans>
+        </DialogTitle>
+        <DialogContent>
+          <ReCaptcha
+            sitekey={guestConfig?.recaptcha_site_key!}
+            onChange={(token: string | null) => {
+              if (lo.isNull(token)) {
+                enqueueSnackbar(t("auth.captcha.error_null_token"), { variant: "error" });
+                return;
+              }
+
+              sendEmailVerify({ email, recaptcha_data: token! })
+                .unwrap()
+                .then(() => {
+                  enqueueSnackbar(t("auth.captcha.success"), { variant: "success" });
+                })
+                .catch((err) => {
+                  console.error(err);
+                  enqueueSnackbar(t("auth.captcha.error"), { variant: "error" });
+                })
+                .finally(() => {
+                  setOpen(false);
+                });
+            }}
+          />
+        </DialogContent>
+      </Dialog>
+    </>
+  );
+};
+
+const SendMailButton: React.FC<SendMailButtonProps> = ({ email }) => {
+  const [sendMail, { isLoading }] = useSendEmailVerifyMutation();
+  const { data: siteConfig } = useGetGuestConfigQuery();
+  const { enqueueSnackbar } = useSnackbar();
+  const { t } = useTranslation();
+
+  const handleSendEmailCode = () => {
+    console.log("send email code");
+    sendMail({ email })
+      .unwrap()
+      .then(() => {
+        enqueueSnackbar(t("auth.captcha.success"), { variant: "success" });
+      })
+      .catch((err) => {
+        console.error(err);
+        enqueueSnackbar(t("auth.captcha.error"), { variant: "error" });
+      });
+  };
+
+  if (siteConfig?.is_recaptcha === 1) {
+    return <SendMailWithCaptchaButton email={email} />;
+  } else {
+    return (
+      <InputAdornment position="end">
+        <IconButton
+          aria-label="send email code"
+          onClick={handleSendEmailCode}
+          edge="end"
+          color="secondary"
+          disabled={isLoading}
+        >
+          <SendOutlined />
+        </IconButton>
+      </InputAdornment>
+    );
+  }
+};
+
+export default SendMailButton;

+ 4 - 3
src/store/services/api.ts

@@ -16,6 +16,7 @@ import type Notice from "@/model/notice";
 import type { UserConfig, GuestConfig } from "@/model/config";
 import type { ResetPasswordPayload } from "@/model/reset_password";
 import type { RegisterPayload } from "@/model/register";
+import SendMailPayload from "@/model/send_mail";
 
 const axiosBaseQuery: () => BaseQueryFn =
   () =>
@@ -118,11 +119,11 @@ const api = createApi({
         method: "GET"
       })
     }),
-    sendEmailVerify: builder.mutation<boolean, string>({
-      query: (email) => ({
+    sendEmailVerify: builder.mutation<boolean, SendMailPayload>({
+      query: (body) => ({
         url: "/passport/comm/sendEmailVerify",
         method: "POST",
-        body: qs.stringify({ email }),
+        body: qs.stringify(body),
         headers: {
           "Content-Type": "application/x-www-form-urlencoded"
         }