Merge pull request #3264 from bluewave-labs/feat/v2-set-new-password
feat: v2 set new password
This commit is contained in:
14 files changed
+205
-1056
No files matched your search
@@ -177,7 +177,7 @@ export const MonitorBasePageWithStates = ({
|
||||
error={error}
|
||||
{...props}
|
||||
>
|
||||
{priorityFallback}
|
||||
<Stack height={"100%"}>{priorityFallback}</Stack>
|
||||
</BasePage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export const BulletPointCheck = ({
|
||||
<Check
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
color={variant === "info" ? theme.palette.text.secondary : colors[variant]}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import {
|
||||
setNewPasswordSchema,
|
||||
type SetNewPasswordFormData,
|
||||
} from "@/Validation/setNewPassword";
|
||||
|
||||
export const useSetNewPasswordForm = () => {
|
||||
const defaults: SetNewPasswordFormData = {
|
||||
password: "",
|
||||
confirm: "",
|
||||
};
|
||||
|
||||
return {
|
||||
schema: setNewPasswordSchema,
|
||||
defaults,
|
||||
};
|
||||
};
|
||||
@@ -1,248 +0,0 @@
|
||||
import { Box, Button, Stack, Typography } from "@mui/material";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { createToast } from "../../Utils/toastUtils.jsx";
|
||||
import { forgotPassword } from "../../Features/Auth/authSlice.js";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import Background from "@/assets/Images/background-grid.svg?react";
|
||||
import Logo from "@/assets/icons/checkmate-icon.svg?react";
|
||||
import IconBox from "@/Components/v1/IconBox/index.jsx";
|
||||
import Icon from "@/Components/v1/Icon";
|
||||
import "./index.css";
|
||||
|
||||
const CheckEmail = () => {
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
|
||||
const [email, setEmail] = useState();
|
||||
const [disabled, setDisabled] = useState(false);
|
||||
useEffect(() => {
|
||||
setEmail(sessionStorage.getItem("email"));
|
||||
}, []);
|
||||
|
||||
// TODO - fix
|
||||
const openMail = () => {
|
||||
window.location.href = "mailto:";
|
||||
};
|
||||
|
||||
const toastFail = [
|
||||
{
|
||||
body: t("auth.forgotPassword.toasts.emailNotFound"),
|
||||
},
|
||||
{
|
||||
body: t("auth.forgotPassword.toasts.redirect").replace("<seconds/>", "3"),
|
||||
},
|
||||
{
|
||||
body: t("auth.forgotPassword.toasts.redirect").replace("<seconds/>", "2"),
|
||||
},
|
||||
{
|
||||
body: t("auth.forgotPassword.toasts.redirect").replace("<seconds/>", "1"),
|
||||
},
|
||||
];
|
||||
|
||||
const resendToken = async () => {
|
||||
setDisabled(true); // prevent resent button from being spammed
|
||||
if (!email) {
|
||||
let index = 0;
|
||||
const interval = setInterval(() => {
|
||||
if (index < toastFail.length) {
|
||||
createToast(toastFail[index]);
|
||||
index++;
|
||||
} else {
|
||||
clearInterval(interval);
|
||||
navigate("/forgot-password");
|
||||
}
|
||||
}, 1000);
|
||||
} else {
|
||||
const form = { email: email };
|
||||
const action = await dispatch(forgotPassword(form));
|
||||
if (action.payload.success) {
|
||||
createToast({
|
||||
body: t("auth.forgotPassword.toasts.sent").replace("<email/>", form.email),
|
||||
});
|
||||
setDisabled(false);
|
||||
} else {
|
||||
if (action.payload) {
|
||||
// dispatch errors
|
||||
createToast({
|
||||
body: action.payload.msg, // FIXME: Potential untranslated string
|
||||
});
|
||||
} else {
|
||||
// unknown errors
|
||||
createToast({
|
||||
body: t("common.toasts.unknownError"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleNavigate = () => {
|
||||
sessionStorage.removeItem("email");
|
||||
location.state?.from
|
||||
? navigate(location.state.from, { replace: true })
|
||||
: navigate("/login");
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
className="check-email-page auth"
|
||||
overflow="hidden"
|
||||
sx={{
|
||||
"& h1": {
|
||||
color: theme.palette.primary.main,
|
||||
fontWeight: 600,
|
||||
fontSize: 22,
|
||||
},
|
||||
/* TODO font size from theme */
|
||||
"& p": { color: theme.palette.primary.contrastTextSecondary, fontSize: 13.5 },
|
||||
"& span": { fontSize: "inherit" },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
className="background-pattern-svg"
|
||||
sx={{
|
||||
"& svg g g:last-of-type path": {
|
||||
stroke: theme.palette.primary.lowContrast,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Background style={{ width: "100%" }} />
|
||||
</Box>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
px={theme.spacing(12)}
|
||||
gap={theme.spacing(4)}
|
||||
>
|
||||
<Logo style={{ borderRadius: theme.shape.borderRadius }} />
|
||||
<Typography sx={{ userSelect: "none" }}>{t("common.appName")}</Typography>
|
||||
</Stack>
|
||||
<Stack
|
||||
width="100%"
|
||||
maxWidth={600}
|
||||
flex={1}
|
||||
justifyContent="center"
|
||||
px={{ xs: theme.spacing(12), lg: theme.spacing(20) }}
|
||||
pb={theme.spacing(20)}
|
||||
mx="auto"
|
||||
sx={{
|
||||
"& > .MuiStack-root": {
|
||||
border: 1,
|
||||
borderRadius: theme.spacing(5),
|
||||
borderColor: theme.palette.primary.lowContrast,
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
padding: {
|
||||
xs: theme.spacing(12),
|
||||
sm: theme.spacing(20),
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
gap={{ xs: theme.spacing(8), sm: theme.spacing(10) }}
|
||||
alignItems="center"
|
||||
textAlign="center"
|
||||
>
|
||||
<Box>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="center"
|
||||
>
|
||||
<IconBox
|
||||
height={48}
|
||||
width={48}
|
||||
minWidth={48}
|
||||
borderRadius={12}
|
||||
svgWidth={24}
|
||||
svgHeight={24}
|
||||
mb={theme.spacing(4)}
|
||||
>
|
||||
<Icon name="Mail" />
|
||||
</IconBox>
|
||||
</Stack>
|
||||
<Typography component="h1">{t("auth.forgotPassword.heading")}</Typography>
|
||||
<Typography>
|
||||
<Trans
|
||||
i18nKey="auth.forgotPassword.subheadings.stepTwo"
|
||||
components={{
|
||||
email: (
|
||||
<Typography
|
||||
className="email-sent-to"
|
||||
component="span"
|
||||
>
|
||||
{email || "username@email.com"}
|
||||
</Typography>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="accent"
|
||||
onClick={openMail}
|
||||
sx={{
|
||||
width: "100%",
|
||||
maxWidth: 400,
|
||||
}}
|
||||
>
|
||||
{t("auth.forgotPassword.buttons.openEmail")}
|
||||
</Button>
|
||||
<Typography sx={{ alignSelf: "center", mt: theme.spacing(6) }}>
|
||||
<Trans
|
||||
i18nKey="auth.forgotPassword.links.resend"
|
||||
components={{
|
||||
a: (
|
||||
<Typography
|
||||
component="span"
|
||||
onClick={resendToken}
|
||||
sx={{
|
||||
color: theme.palette.accent.main,
|
||||
userSelect: "none",
|
||||
pointerEvents: disabled ? "none" : "auto",
|
||||
cursor: disabled ? "default" : "pointer",
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Box
|
||||
textAlign="center"
|
||||
p={theme.spacing(12)}
|
||||
>
|
||||
<Typography display="inline-block">
|
||||
<Trans
|
||||
i18nKey={
|
||||
location.state?.from === "/account/password"
|
||||
? "Go back to <a>Account Password</a>"
|
||||
: "auth.forgotPassword.links.login"
|
||||
}
|
||||
components={{
|
||||
a: (
|
||||
<Typography
|
||||
component="span"
|
||||
color={theme.palette.accent.main}
|
||||
ml={theme.spacing(2)}
|
||||
onClick={handleNavigate}
|
||||
sx={{ userSelect: "none" }}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CheckEmail;
|
||||
@@ -1,142 +0,0 @@
|
||||
import { Box, Button, Stack, Typography } from "@mui/material";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { clearAuthState } from "../../Features/Auth/authSlice.js";
|
||||
import Background from "@/assets/Images/background-grid.svg?react";
|
||||
import Logo from "@/assets/icons/checkmate-icon.svg?react";
|
||||
import IconBox from "@/Components/v1/IconBox/index.jsx";
|
||||
import Icon from "@/Components/v1/Icon";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import "./index.css";
|
||||
|
||||
const NewPasswordConfirmed = () => {
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleNavigate = () => {
|
||||
dispatch(clearAuthState());
|
||||
navigate("/login");
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
className="password-confirmed-page auth"
|
||||
overflow="hidden"
|
||||
sx={{
|
||||
"& h1": {
|
||||
color: theme.palette.primary.main,
|
||||
fontWeight: 600,
|
||||
fontSize: 21,
|
||||
},
|
||||
/* TODO font size from theme*/
|
||||
"& p": { fontSize: 13.5, color: theme.palette.primary.contrastTextSecondary },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
className="background-pattern-svg"
|
||||
sx={{
|
||||
"& svg g g:last-of-type path": {
|
||||
stroke: theme.palette.primary.lowContrast,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Background style={{ width: "100%" }} />
|
||||
</Box>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
px={theme.spacing(12)}
|
||||
gap={theme.spacing(4)}
|
||||
>
|
||||
<Logo style={{ borderRadius: theme.shape.borderRadius }} />
|
||||
<Typography sx={{ userSelect: "none" }}>{t("common.appName")}</Typography>
|
||||
</Stack>
|
||||
<Stack
|
||||
width="100%"
|
||||
maxWidth={600}
|
||||
flex={1}
|
||||
justifyContent="center"
|
||||
px={{ xs: theme.spacing(12), lg: theme.spacing(20) }}
|
||||
pb={theme.spacing(20)}
|
||||
mx="auto"
|
||||
sx={{
|
||||
"& > .MuiStack-root": {
|
||||
border: 1,
|
||||
borderRadius: theme.spacing(5),
|
||||
borderColor: theme.palette.primary.lowContrast,
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
padding: {
|
||||
xs: theme.spacing(12),
|
||||
sm: theme.spacing(20),
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
gap={{ xs: theme.spacing(8), sm: theme.spacing(12) }}
|
||||
alignItems="center"
|
||||
textAlign="center"
|
||||
>
|
||||
<Box>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="center"
|
||||
>
|
||||
<IconBox
|
||||
height={48}
|
||||
width={48}
|
||||
minWidth={48}
|
||||
borderRadius={12}
|
||||
svgWidth={24}
|
||||
svgHeight={24}
|
||||
mb={theme.spacing(4)}
|
||||
>
|
||||
<Icon name="CheckCircle" />
|
||||
</IconBox>
|
||||
</Stack>
|
||||
<Typography component="h1">{t("auth.forgotPassword.heading")}</Typography>
|
||||
<Typography mb={theme.spacing(2)}>
|
||||
{t("auth.forgotPassword.subheadings.stepFour")}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => navigate("/uptime")}
|
||||
sx={{
|
||||
width: "100%",
|
||||
maxWidth: 400,
|
||||
}}
|
||||
>
|
||||
{t("auth.common.navigation.continue")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Box
|
||||
textAlign="center"
|
||||
p={theme.spacing(12)}
|
||||
>
|
||||
<Typography display="inline-block">
|
||||
<Trans
|
||||
i18nKey="auth.forgotPassword.links.login"
|
||||
components={{
|
||||
a: (
|
||||
<Typography
|
||||
component="span"
|
||||
color={theme.palette.accent.main}
|
||||
ml={theme.spacing(2)}
|
||||
onClick={handleNavigate}
|
||||
sx={{ userSelect: "none" }}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewPasswordConfirmed;
|
||||
@@ -1,283 +0,0 @@
|
||||
import { useId } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { Box, Stack, Typography, Button } from "@mui/material";
|
||||
import { setNewPassword } from "../../Features/Auth/authSlice.js";
|
||||
import { createToast } from "../../Utils/toastUtils.jsx";
|
||||
import { newOrChangedCredentials } from "../../Validation/validation.js";
|
||||
import Check from "@/Components/v1/Check/Check.jsx";
|
||||
import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx";
|
||||
import { PasswordEndAdornment } from "@/Components/v1/Inputs/TextInput/Adornments/index.jsx";
|
||||
import IconBox from "@/Components/v1/IconBox/index.jsx";
|
||||
import Logo from "@/assets/icons/checkmate-icon.svg?react";
|
||||
import Background from "@/assets/Images/background-grid.svg?react";
|
||||
import Icon from "@/Components/v1/Icon";
|
||||
import "./index.css";
|
||||
import { useValidatePassword } from "./hooks/useValidatePassword.jsx";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
const SetNewPassword = () => {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const passwordId = useId();
|
||||
const confirmPasswordId = useId();
|
||||
|
||||
const { form, errors, handleChange, feedbacks } = useValidatePassword();
|
||||
|
||||
const { isLoading } = useSelector((state) => state.auth);
|
||||
const { token } = useParams();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const { error } = newOrChangedCredentials.validate(form, {
|
||||
abortEarly: false,
|
||||
context: { password: form.password },
|
||||
});
|
||||
|
||||
if (error) {
|
||||
createToast({
|
||||
body:
|
||||
error.details && error.details.length > 0
|
||||
? error.details[0].message // FIXME: Potential untranslated string
|
||||
: t("auth.common.errors.validation"),
|
||||
});
|
||||
} else {
|
||||
const action = await dispatch(setNewPassword({ token, form }));
|
||||
if (action.payload.success) {
|
||||
navigate("/new-password-confirmed");
|
||||
createToast({
|
||||
body: t("auth.forgotPassword.toasts.success"),
|
||||
});
|
||||
} else {
|
||||
const errorMessage = action.payload
|
||||
? action.payload.msg // FIXME: Potential untranslated string
|
||||
: t("auth.forgotPassword.toasts.error");
|
||||
createToast({
|
||||
body: errorMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
className="set-new-password-page auth"
|
||||
overflow="hidden"
|
||||
sx={{
|
||||
"& h1": {
|
||||
color: theme.palette.primary.main,
|
||||
fontWeight: 600,
|
||||
fontSize: 24,
|
||||
},
|
||||
"& p": {
|
||||
/* TODO font size from theme */
|
||||
fontSize: 14,
|
||||
color: theme.palette.primary.contrastTextSecondary,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
className="background-pattern-svg"
|
||||
sx={{
|
||||
"& svg g g:last-of-type path": {
|
||||
stroke: theme.palette.primary.lowContrast,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Background style={{ width: "100%" }} />
|
||||
</Box>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
px={theme.spacing(12)}
|
||||
gap={theme.spacing(4)}
|
||||
>
|
||||
<Logo style={{ borderRadius: theme.shape.borderRadius }} />
|
||||
<Typography sx={{ userSelect: "none" }}>{t("common.appName")}</Typography>
|
||||
</Stack>
|
||||
<Stack
|
||||
width="100%"
|
||||
maxWidth={600}
|
||||
flex={1}
|
||||
justifyContent="center"
|
||||
px={{ xs: theme.spacing(12), lg: theme.spacing(20) }}
|
||||
pb={theme.spacing(12)}
|
||||
mx="auto"
|
||||
sx={{
|
||||
"& > .MuiStack-root": {
|
||||
border: 1,
|
||||
borderRadius: theme.spacing(5),
|
||||
borderColor: theme.palette.primary.lowContrast,
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
padding: {
|
||||
xs: theme.spacing(12),
|
||||
sm: theme.spacing(20),
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
gap={{ xs: theme.spacing(8), sm: theme.spacing(12) }}
|
||||
alignItems="center"
|
||||
textAlign="center"
|
||||
>
|
||||
<Box>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="center"
|
||||
>
|
||||
<IconBox
|
||||
height={48}
|
||||
width={48}
|
||||
minWidth={48}
|
||||
borderRadius={12}
|
||||
svgWidth={24}
|
||||
svgHeight={24}
|
||||
mb={theme.spacing(4)}
|
||||
>
|
||||
<Icon name="Lock" />
|
||||
</IconBox>
|
||||
</Stack>
|
||||
<Typography component="h1">{t("auth.forgotPassword.heading")}</Typography>
|
||||
<Typography>{t("auth.forgotPassword.subheadings.stepThree")}</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
width="100%"
|
||||
textAlign="left"
|
||||
sx={{
|
||||
"& .input-error": {
|
||||
display: "none",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="form"
|
||||
noValidate
|
||||
spellCheck={false}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<TextInput
|
||||
id={passwordId}
|
||||
type="password"
|
||||
name="password"
|
||||
label={t("auth.common.inputs.password.label")}
|
||||
isRequired={true}
|
||||
placeholder="••••••••"
|
||||
value={form.password}
|
||||
onChange={handleChange}
|
||||
error={errors.password ? true : false}
|
||||
helperText={
|
||||
errors.password === "auth.common.inputs.password.errors.empty"
|
||||
? t(errors.password)
|
||||
: ""
|
||||
} // Other errors are related to required password conditions and are visualized below the input
|
||||
endAdornment={<PasswordEndAdornment />}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
component="form"
|
||||
noValidate
|
||||
spellCheck={false}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<TextInput
|
||||
id={confirmPasswordId}
|
||||
type="password"
|
||||
name="confirm"
|
||||
label={t("auth.common.inputs.passwordConfirm.label")}
|
||||
isRequired={true}
|
||||
placeholder={t("auth.common.inputs.passwordConfirm.placeholder")}
|
||||
value={form.confirm}
|
||||
onChange={handleChange}
|
||||
error={errors.confirm ? true : false}
|
||||
helperText={t(errors.confirm)} // Localization keys are in validation.js
|
||||
endAdornment={<PasswordEndAdornment />}
|
||||
/>
|
||||
</Box>
|
||||
<Stack
|
||||
gap={theme.spacing(4)}
|
||||
mb={theme.spacing(12)}
|
||||
>
|
||||
<Check
|
||||
noHighlightText={t("auth.common.inputs.password.rules.length.beginning")}
|
||||
text={t("auth.common.inputs.password.rules.length.highlighted")}
|
||||
variant={feedbacks.length}
|
||||
/>
|
||||
<Check
|
||||
noHighlightText={t("auth.common.inputs.password.rules.special.beginning")}
|
||||
text={t("auth.common.inputs.password.rules.special.highlighted")}
|
||||
variant={feedbacks.special}
|
||||
/>
|
||||
<Check
|
||||
noHighlightText={t("auth.common.inputs.password.rules.number.beginning")}
|
||||
text={t("auth.common.inputs.password.rules.number.highlighted")}
|
||||
variant={feedbacks.number}
|
||||
/>
|
||||
<Check
|
||||
noHighlightText={t(
|
||||
"auth.common.inputs.password.rules.uppercase.beginning"
|
||||
)}
|
||||
text={t("auth.common.inputs.password.rules.uppercase.highlighted")}
|
||||
variant={feedbacks.uppercase}
|
||||
/>
|
||||
<Check
|
||||
noHighlightText={t(
|
||||
"auth.common.inputs.password.rules.lowercase.beginning"
|
||||
)}
|
||||
text={t("auth.common.inputs.password.rules.lowercase.highlighted")}
|
||||
variant={feedbacks.lowercase}
|
||||
/>
|
||||
<Check
|
||||
noHighlightText={t("auth.common.inputs.password.rules.match.beginning")}
|
||||
text={t("auth.common.inputs.password.rules.match.highlighted")}
|
||||
variant={feedbacks.confirm}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="accent"
|
||||
loading={isLoading}
|
||||
onClick={handleSubmit}
|
||||
disabled={
|
||||
form.password.length === 0 ||
|
||||
form.confirm.length === 0 ||
|
||||
Object.keys(errors).length !== 0
|
||||
}
|
||||
sx={{ width: "100%", maxWidth: 400 }}
|
||||
>
|
||||
{t("auth.forgotPassword.buttons.resetPassword")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Box
|
||||
textAlign="center"
|
||||
p={theme.spacing(12)}
|
||||
>
|
||||
<Typography display="inline-block">
|
||||
<Trans
|
||||
i18nKey="auth.forgotPassword.links.login"
|
||||
components={{
|
||||
a: (
|
||||
<Typography
|
||||
component="span"
|
||||
color={theme.palette.primary.main}
|
||||
ml={theme.spacing(2)}
|
||||
onClick={() => navigate("/login")}
|
||||
sx={{ userSelect: "none" }}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default SetNewPassword;
|
||||
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
BaseAuthPage,
|
||||
BulletPointCheck,
|
||||
TextLink,
|
||||
} from "@/Components/v2/design-elements";
|
||||
import { Button, TextField } from "@/Components/v2/inputs";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
|
||||
import { useSetNewPasswordForm } from "@/Hooks/useSetNewPasswordForm";
|
||||
import type { SetNewPasswordFormData } from "@/Validation/setNewPassword";
|
||||
import { specialCharPattern } from "@/Validation/patterns";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { usePost } from "@/Hooks/UseApi";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { setAuthState } from "@/Features/Auth/authSlice";
|
||||
|
||||
const SetNewPasswordPage = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const { post, loading } = usePost();
|
||||
|
||||
const { schema, defaults } = useSetNewPasswordForm();
|
||||
|
||||
const { control, handleSubmit, watch } = useForm<SetNewPasswordFormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: defaults,
|
||||
});
|
||||
|
||||
const password = watch("password");
|
||||
const confirm = watch("confirm");
|
||||
|
||||
const getVariant = (condition: boolean): "success" | "info" => {
|
||||
return condition ? "success" : "info";
|
||||
};
|
||||
|
||||
const hasLength = password.length >= 8;
|
||||
const hasSpecial = specialCharPattern.test(password);
|
||||
const hasNumber = /\d/.test(password);
|
||||
const hasUppercase = /[A-Z]/.test(password);
|
||||
const hasLowercase = /[a-z]/.test(password);
|
||||
const passwordsMatch = password.length > 0 && password === confirm;
|
||||
|
||||
const onSubmit = async (data: SetNewPasswordFormData) => {
|
||||
if (loading) return;
|
||||
|
||||
const result = await post("/auth/recovery/reset", {
|
||||
password: data.password,
|
||||
recoveryToken: token,
|
||||
});
|
||||
|
||||
if (result?.success) {
|
||||
dispatch(setAuthState(result));
|
||||
navigate("/uptime");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseAuthPage
|
||||
component="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
title={t("pages.auth.setNewPassword.title")}
|
||||
subtitle={t("pages.auth.setNewPassword.subtitle")}
|
||||
>
|
||||
<Controller
|
||||
name="password"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="password"
|
||||
fieldLabel={t("pages.auth.common.form.option.password.label")}
|
||||
placeholder={t("pages.auth.common.form.option.password.placeholder")}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="confirm"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="password"
|
||||
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
|
||||
placeholder={t("pages.auth.common.form.option.password.placeholder")}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Stack gap={theme.spacing(4)}>
|
||||
<BulletPointCheck
|
||||
text={t("auth.common.passwordRules.length")}
|
||||
variant={getVariant(hasLength)}
|
||||
/>
|
||||
<BulletPointCheck
|
||||
text={t("auth.common.passwordRules.special")}
|
||||
variant={getVariant(hasSpecial)}
|
||||
/>
|
||||
<BulletPointCheck
|
||||
text={t("auth.common.passwordRules.number")}
|
||||
variant={getVariant(hasNumber)}
|
||||
/>
|
||||
<BulletPointCheck
|
||||
text={t("auth.common.passwordRules.uppercase")}
|
||||
variant={getVariant(hasUppercase)}
|
||||
/>
|
||||
<BulletPointCheck
|
||||
text={t("auth.common.passwordRules.lowercase")}
|
||||
variant={getVariant(hasLowercase)}
|
||||
/>
|
||||
<BulletPointCheck
|
||||
text={t("auth.common.passwordRules.match")}
|
||||
variant={getVariant(passwordsMatch)}
|
||||
/>
|
||||
</Stack>
|
||||
<Button
|
||||
variant="contained"
|
||||
type="submit"
|
||||
fullWidth
|
||||
loading={loading}
|
||||
>
|
||||
{t("auth.forgotPassword.buttons.resetPassword")}
|
||||
</Button>
|
||||
<TextLink
|
||||
alignSelf="center"
|
||||
text={t("pages.auth.forgotPassword.links.login.text")}
|
||||
linkText={t("pages.auth.forgotPassword.links.login.linkText")}
|
||||
href="/login"
|
||||
/>
|
||||
</BaseAuthPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default SetNewPasswordPage;
|
||||
@@ -1,116 +0,0 @@
|
||||
import Check from "@/Components/v1/Check/Check.jsx";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { Tooltip, useTheme } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const PasswordTooltip = ({ feedback, form, children }) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const hasPassword = form.password.length > 0;
|
||||
const hasInvalidFeedback = Object.values(feedback).some(
|
||||
(status) => status !== "success"
|
||||
);
|
||||
const showPasswordTooltip = hasPassword && hasInvalidFeedback;
|
||||
return (
|
||||
<Tooltip
|
||||
placement="right"
|
||||
arrow
|
||||
open={showPasswordTooltip}
|
||||
title={
|
||||
<Stack
|
||||
gap={theme.spacing(4)}
|
||||
mb={{ xs: theme.spacing(6), sm: theme.spacing(8) }}
|
||||
>
|
||||
<Check
|
||||
noHighlightText={
|
||||
t("auth.common.inputs.password.rules.length.beginning") +
|
||||
" " +
|
||||
t("auth.common.inputs.password.rules.length.highlighted")
|
||||
}
|
||||
variant={feedback.length}
|
||||
/>
|
||||
<Check
|
||||
noHighlightText={
|
||||
t("auth.common.inputs.password.rules.special.beginning") +
|
||||
" " +
|
||||
t("auth.common.inputs.password.rules.special.highlighted")
|
||||
}
|
||||
variant={feedback.special}
|
||||
/>
|
||||
<Check
|
||||
noHighlightText={
|
||||
t("auth.common.inputs.password.rules.number.beginning") +
|
||||
" " +
|
||||
t("auth.common.inputs.password.rules.number.highlighted")
|
||||
}
|
||||
variant={feedback.number}
|
||||
/>
|
||||
<Check
|
||||
noHighlightText={
|
||||
t("auth.common.inputs.password.rules.uppercase.beginning") +
|
||||
" " +
|
||||
t("auth.common.inputs.password.rules.uppercase.highlighted")
|
||||
}
|
||||
variant={feedback.uppercase}
|
||||
/>
|
||||
<Check
|
||||
noHighlightText={
|
||||
t("auth.common.inputs.password.rules.lowercase.beginning") +
|
||||
" " +
|
||||
t("auth.common.inputs.password.rules.lowercase.highlighted")
|
||||
}
|
||||
variant={feedback.lowercase}
|
||||
/>
|
||||
<Check
|
||||
noHighlightText={
|
||||
t("auth.common.inputs.password.rules.match.beginning") +
|
||||
" " +
|
||||
t("auth.common.inputs.password.rules.match.highlighted")
|
||||
}
|
||||
variant={feedback.confirm}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
slotProps={{
|
||||
tooltip: {
|
||||
sx: {
|
||||
backgroundColor: theme.palette.tertiary.main,
|
||||
border: `0.5px solid ${theme.palette.primary.lowContrast}90`,
|
||||
borderRadius: theme.spacing(4),
|
||||
color: theme.palette.primary.contrastText,
|
||||
width: "auto",
|
||||
maxWidth: { xs: "25vw", md: "none" },
|
||||
whiteSpace: { xs: "normal", md: "nowrap" },
|
||||
paddingTop: theme.spacing(8),
|
||||
px: theme.spacing(8),
|
||||
},
|
||||
},
|
||||
arrow: {
|
||||
sx: {
|
||||
color: theme.palette.tertiary.main,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<span style={{ display: "inline-block", width: "100%" }}>{children}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
PasswordTooltip.propTypes = {
|
||||
feedback: PropTypes.shape({
|
||||
length: PropTypes.string,
|
||||
special: PropTypes.string,
|
||||
number: PropTypes.string,
|
||||
uppercase: PropTypes.string,
|
||||
lowercase: PropTypes.string,
|
||||
confirm: PropTypes.string,
|
||||
}),
|
||||
form: PropTypes.shape({
|
||||
password: PropTypes.string.isRequired,
|
||||
}),
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
export default PasswordTooltip;
|
||||
@@ -1,71 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { newOrChangedCredentials } from "../../../Validation/validation.js";
|
||||
|
||||
const usePasswordFeedback = () => {
|
||||
const [feedback, setFeedback] = useState({});
|
||||
const getFeedbackStatus = (form, errors, field, criteria) => {
|
||||
const fieldErrors = errors?.[field];
|
||||
const isFieldEmpty = form?.[field]?.length === 0;
|
||||
const hasError = fieldErrors?.includes(criteria) || fieldErrors?.includes("empty");
|
||||
const isCorrect = !isFieldEmpty && !hasError;
|
||||
|
||||
if (isCorrect) {
|
||||
return "success";
|
||||
} else if (hasError) {
|
||||
return "error";
|
||||
} else {
|
||||
return "info";
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordFeedback = (updatedForm, name, value, form, errors, setErrors) => {
|
||||
const validateValue = { [name]: value };
|
||||
const validateOptions = { abortEarly: false, context: { password: form.password } };
|
||||
if (name === "password" && form.confirm.length > 0) {
|
||||
validateValue.confirm = form.confirm;
|
||||
validateOptions.context = { password: value };
|
||||
} else if (name === "confirm") {
|
||||
validateValue.password = form.password;
|
||||
}
|
||||
const { error } = newOrChangedCredentials.validate(validateValue, validateOptions);
|
||||
|
||||
const pwdErrors = error?.details.map((error) => ({
|
||||
path: error.path[0],
|
||||
type: error.type,
|
||||
}));
|
||||
|
||||
const errorsByPath =
|
||||
pwdErrors &&
|
||||
pwdErrors.reduce((acc, { path, type }) => {
|
||||
if (!acc[path]) {
|
||||
acc[path] = [];
|
||||
}
|
||||
acc[path].push(type);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const oldErrors = { ...errors };
|
||||
if (name === "password") {
|
||||
oldErrors.password = undefined;
|
||||
} else if (name === "confirm") {
|
||||
oldErrors.confirm = undefined;
|
||||
}
|
||||
const newErrors = { ...oldErrors, ...errorsByPath };
|
||||
|
||||
setErrors(newErrors);
|
||||
|
||||
const newFeedback = {
|
||||
length: getFeedbackStatus(updatedForm, errorsByPath, "password", "string.min"),
|
||||
special: getFeedbackStatus(updatedForm, errorsByPath, "password", "special"),
|
||||
number: getFeedbackStatus(updatedForm, errorsByPath, "password", "number"),
|
||||
uppercase: getFeedbackStatus(updatedForm, errorsByPath, "password", "uppercase"),
|
||||
lowercase: getFeedbackStatus(updatedForm, errorsByPath, "password", "lowercase"),
|
||||
confirm: getFeedbackStatus(updatedForm, errorsByPath, "confirm", "different"),
|
||||
};
|
||||
|
||||
setFeedback(newFeedback);
|
||||
};
|
||||
return { feedback, handlePasswordFeedback, getFeedbackStatus };
|
||||
};
|
||||
|
||||
export default usePasswordFeedback;
|
||||
@@ -1,70 +0,0 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { newOrChangedCredentials } from "../../../Validation/validation.js";
|
||||
|
||||
const getFeedbackStatus = (form, errors, field, criteria) => {
|
||||
const fieldErrors = errors[field];
|
||||
const isFieldEmpty = form[field].length === 0;
|
||||
const hasError = fieldErrors?.includes(criteria) || fieldErrors?.includes("empty");
|
||||
const isCorrect = !isFieldEmpty && !hasError;
|
||||
|
||||
if (isCorrect) {
|
||||
return "success";
|
||||
} else if (hasError) {
|
||||
return "error";
|
||||
} else {
|
||||
return "info";
|
||||
}
|
||||
};
|
||||
|
||||
function useValidatePassword() {
|
||||
const [errors, setErrors] = useState({});
|
||||
const [form, setForm] = useState({
|
||||
password: "",
|
||||
confirm: "",
|
||||
});
|
||||
|
||||
const handleChange = (event) => {
|
||||
const { value, name } = event.target;
|
||||
setForm((prev) => ({ ...prev, [name]: value }));
|
||||
|
||||
const validateValue = { [name]: value };
|
||||
const validateOptions = { abortEarly: false, context: { password: form.password } };
|
||||
if (name === "password" && form.confirm.length > 0) {
|
||||
validateValue.confirm = form.confirm;
|
||||
validateOptions.context = { password: value };
|
||||
} else if (name === "confirm") {
|
||||
validateValue.password = form.password;
|
||||
}
|
||||
|
||||
const { error } = newOrChangedCredentials.validate(validateValue, validateOptions);
|
||||
const errors = error?.details.map((error) => ({
|
||||
path: error.path[0],
|
||||
type: error.type,
|
||||
}));
|
||||
const errorsByPath =
|
||||
errors &&
|
||||
errors.reduce((acc, { path, type }) => {
|
||||
if (!acc[path]) {
|
||||
acc[path] = [];
|
||||
}
|
||||
acc[path].push(type);
|
||||
return acc;
|
||||
}, {});
|
||||
setErrors(() => (errorsByPath ? { ...errorsByPath } : {}));
|
||||
};
|
||||
|
||||
const feedbacks = useMemo(
|
||||
() => ({
|
||||
length: getFeedbackStatus(form, errors, "password", "string.min"),
|
||||
special: getFeedbackStatus(form, errors, "password", "special"),
|
||||
number: getFeedbackStatus(form, errors, "password", "number"),
|
||||
uppercase: getFeedbackStatus(form, errors, "password", "uppercase"),
|
||||
lowercase: getFeedbackStatus(form, errors, "password", "lowercase"),
|
||||
confirm: getFeedbackStatus(form, errors, "confirm", "different"),
|
||||
}),
|
||||
[form, errors]
|
||||
);
|
||||
|
||||
return { handleChange, feedbacks, form, errors };
|
||||
}
|
||||
export { useValidatePassword };
|
||||
@@ -1,112 +0,0 @@
|
||||
/* AUTH */
|
||||
.auth {
|
||||
height: 100vh;
|
||||
}
|
||||
.auth h1 {
|
||||
font-size: var(--env-var-font-size-xlarge);
|
||||
font-weight: 600;
|
||||
}
|
||||
.auth button:not(.MuiIconButton-root) {
|
||||
font-size: var(--env-var-font-size-medium-plus);
|
||||
}
|
||||
|
||||
.auth p + span {
|
||||
opacity: 0.8;
|
||||
cursor: pointer;
|
||||
transition: opacity 300ms ease-in;
|
||||
}
|
||||
.auth p > span:not(.email-sent-to) {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
cursor: pointer;
|
||||
transition: all 200ms;
|
||||
}
|
||||
.auth p > span:not(.email-sent-to):hover {
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
.auth p + span:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.auth button:not(.MuiIconButton-root) {
|
||||
user-select: none;
|
||||
border-radius: var(--env-var-radius-2);
|
||||
line-height: 1;
|
||||
}
|
||||
.auth .field svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
.auth .field h3.MuiTypography-root,
|
||||
.auth .field .input-error,
|
||||
.auth .check span.MuiTypography-root {
|
||||
font-size: var(--env-var-font-size-medium);
|
||||
}
|
||||
.auth form + form {
|
||||
margin: var(--env-var-spacing-1-plus) 0;
|
||||
}
|
||||
.auth .email-sent-to {
|
||||
font-weight: 600;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.auth > .MuiStack-root:nth-of-type(2) {
|
||||
height: var(--env-var-nav-bar-height);
|
||||
}
|
||||
.auth .background-pattern-svg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -30%);
|
||||
z-index: -1;
|
||||
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
height: 100%;
|
||||
max-height: 800px;
|
||||
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.auth .field {
|
||||
position: relative;
|
||||
}
|
||||
.auth .input-error {
|
||||
position: absolute;
|
||||
top: calc(100% - 2px);
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.auth h1 {
|
||||
font-size: var(--env-var-font-size-large-plus);
|
||||
}
|
||||
.auth button:not(.MuiIconButton-root),
|
||||
.auth .field input,
|
||||
.auth p,
|
||||
.auth span {
|
||||
font-size: var(--env-var-font-size-medium);
|
||||
}
|
||||
.auth .check span.MuiTypography-root,
|
||||
.auth .field .input-error,
|
||||
.auth .field h3.MuiTypography-root {
|
||||
font-size: var(--env-var-font-size-small-plus);
|
||||
}
|
||||
.auth .check span > span {
|
||||
display: none;
|
||||
}
|
||||
.auth form + form {
|
||||
margin: var(--env-var-spacing-1-plus) 0;
|
||||
}
|
||||
.auth .background-pattern-svg {
|
||||
max-width: 750px;
|
||||
max-height: 750px;
|
||||
}
|
||||
|
||||
.forgot-password-page h1,
|
||||
.check-email-page h1,
|
||||
.password-confirmed-page h1,
|
||||
.set-new-password-page h1 {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,7 @@ import NotFound from "../Pages/NotFound/index.jsx";
|
||||
import AuthLogin from "../Pages/Auth/Login";
|
||||
import AuthRegister from "@/Pages/Auth/Register";
|
||||
import AuthForgotPassword from "@/Pages/Auth/Recovery";
|
||||
import AuthCheckEmail from "../Pages/Auth/CheckEmail.jsx";
|
||||
import AuthSetNewPassword from "../Pages/Auth/SetNewPassword.jsx";
|
||||
import AuthNewPasswordConfirmed from "../Pages/Auth/NewPasswordConfirmed.jsx";
|
||||
import AuthSetNewPassword from "../Pages/Auth/SetNewPassword";
|
||||
|
||||
// Uptime
|
||||
import Uptime from "../Pages/Uptime/Monitors";
|
||||
@@ -417,17 +415,15 @@ const Routes = () => {
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/check-email"
|
||||
element={<AuthCheckEmail />}
|
||||
/>
|
||||
<Route
|
||||
path="/set-new-password/:token"
|
||||
element={<AuthSetNewPassword />}
|
||||
/>
|
||||
<Route
|
||||
path="/new-password-confirmed"
|
||||
element={<AuthNewPasswordConfirmed />}
|
||||
element={
|
||||
<>
|
||||
<ThemeProvider theme={v2theme}>
|
||||
<AuthSetNewPassword />
|
||||
</ThemeProvider>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/status/public/:url"
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { z } from "zod";
|
||||
import { specialCharPattern } from "@/Validation/patterns";
|
||||
|
||||
export const setNewPasswordSchema = z
|
||||
.object({
|
||||
password: z
|
||||
.string()
|
||||
.min(1, "Password is required")
|
||||
.min(8, "Password must be at least 8 characters")
|
||||
.refine((val) => /[A-Z]/.test(val), "Password must contain an uppercase letter")
|
||||
.refine((val) => /[a-z]/.test(val), "Password must contain a lowercase letter")
|
||||
.refine((val) => /\d/.test(val), "Password must contain a number")
|
||||
.refine(
|
||||
(val) => specialCharPattern.test(val),
|
||||
"Password must contain a special character"
|
||||
),
|
||||
confirm: z.string().min(1, "Please confirm your password"),
|
||||
})
|
||||
.refine((data) => data.password === data.confirm, {
|
||||
message: "Passwords do not match",
|
||||
path: ["confirm"],
|
||||
});
|
||||
|
||||
export type SetNewPasswordFormData = z.infer<typeof setNewPasswordSchema>;
|
||||
@@ -93,6 +93,14 @@
|
||||
},
|
||||
"navigation": {
|
||||
"continue": "Continue"
|
||||
},
|
||||
"passwordRules": {
|
||||
"length": "Must be at least 8 characters long",
|
||||
"special": "Must contain at least one special character (!?@#$%^&*()-_=+[]{}|;:'\",./\\~`)",
|
||||
"number": "Must contain at least one number",
|
||||
"uppercase": "Must contain at least one upper character",
|
||||
"lowercase": "Must contain at least one lower character",
|
||||
"match": "Passwords must match"
|
||||
}
|
||||
},
|
||||
"forgotPassword": {
|
||||
@@ -619,6 +627,7 @@
|
||||
},
|
||||
"auth": {
|
||||
"common": {
|
||||
"passwordRules": {},
|
||||
"form": {
|
||||
"option": {
|
||||
"email": {
|
||||
@@ -665,6 +674,10 @@
|
||||
"linkText": "login"
|
||||
}
|
||||
}
|
||||
},
|
||||
"setNewPassword": {
|
||||
"title": "Reset your password",
|
||||
"subtitle": "Your new password must be different from previously used passwords."
|
||||
}
|
||||
},
|
||||
"checks": {
|
||||
@@ -1064,7 +1077,6 @@
|
||||
"duration": {
|
||||
"label": "Duration"
|
||||
},
|
||||
|
||||
"startTime": {
|
||||
"label": "Start time"
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user