Merge pull request #2425 from bluewave-labs/feat/login

feat: login
This commit is contained in:
Alexander Holliday
2025-06-12 09:01:58 +08:00
committed by GitHub
10 changed files with 253 additions and 596 deletions
@@ -1,30 +0,0 @@
import { Navigate } from "react-router-dom";
import { useSelector } from "react-redux";
import PropTypes from "prop-types";
/**
* @param {Object} props - The props passed to the ProtectedDistributedUptimeRoute component.
* @param {React.ReactNode} props.children - The children to render if the user is authenticated.
* @returns {React.ReactElement} The children wrapped in a protected route or a redirect to the login page.
*/
const ProtectedDistributedUptimeRoute = ({ children }) => {
const distributedUptimeEnabled = useSelector(
(state) => state.ui.distributedUptimeEnabled
);
return distributedUptimeEnabled === true ? (
children
) : (
<Navigate
to="/uptime"
replace
/>
);
};
ProtectedDistributedUptimeRoute.propTypes = {
children: PropTypes.node.isRequired,
};
export default ProtectedDistributedUptimeRoute;
@@ -1,106 +0,0 @@
import { useRef, useEffect } from "react";
import { Box, Button, Stack, Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
import TextInput from "../../../../Components/Inputs/TextInput";
import PropTypes from "prop-types";
import { useTranslation } from "react-i18next";
/**
* Renders the email step of the login process which includes an email field.
*
* @param {Object} props
* @param {Object} props.form - Form state object.
* @param {Object} props.errors - Object containing form validation errors.
* @param {Function} props.onSubmit - Callback function to handle form submission.
* @param {Function} props.onChange - Callback function to handle form input changes.
* @param {Function} props.onBack - Callback function to handle "Back" button click.
* @returns {JSX.Element}
*/
const EmailStep = ({ form, errors, onSubmit, onChange }) => {
const theme = useTheme();
const inputRef = useRef(null);
const { t } = useTranslation();
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, []);
return (
<>
<Stack
gap={{ xs: theme.spacing(12), sm: theme.spacing(16) }}
textAlign="center"
position="relative"
>
<Box>
<Typography component="h1">{t("auth.login.heading")}</Typography>
<Typography>{t("auth.login.subheadings.stepOne")}</Typography>
</Box>
<Box
textAlign="left"
component="form"
noValidate
spellCheck={false}
onSubmit={onSubmit}
display="grid"
gap={{ xs: theme.spacing(12), sm: theme.spacing(16) }}
>
<TextInput
type="email"
id="login-email-input"
label={t("auth.common.inputs.email.label")}
isRequired={true}
placeholder={t("auth.common.inputs.email.placeholder")}
autoComplete="email"
value={form.email}
onChange={onChange}
error={errors.email ? true : false}
helperText={errors.email ? t(errors.email) : ""} // Localization keys are in validation.js
ref={inputRef}
/>
<Stack
direction="row"
justifyContent="flex-end"
>
<Button
variant="contained"
color="accent"
type="submit"
disabled={errors.email && true}
className="dashboard-style-button"
sx={{
width: "30%",
px: theme.spacing(6),
borderRadius: `${theme.shape.borderRadius}px !important`,
"&.MuiButtonBase-root": {
borderRadius: `${theme.shape.borderRadius}px !important`,
},
"&.MuiButton-root": {
borderRadius: `${theme.shape.borderRadius}px !important`,
},
"&.Mui-focusVisible": {
outline: `2px solid ${theme.palette.primary.main}`,
outlineOffset: `2px`,
boxShadow: `none`,
},
}}
>
{t("auth.common.navigation.continue")}
</Button>
</Stack>
</Box>
</Stack>
</>
);
};
EmailStep.propTypes = {
form: PropTypes.object.isRequired,
errors: PropTypes.object.isRequired,
onSubmit: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
};
export default EmailStep;
@@ -1,49 +0,0 @@
import { Box, Typography, useTheme } from "@mui/material";
import PropTypes from "prop-types";
import { useNavigate } from "react-router";
import { Trans, useTranslation } from "react-i18next";
const ForgotPasswordLabel = ({ email, errorEmail }) => {
const theme = useTheme();
const navigate = useNavigate();
const { t } = useTranslation();
const handleNavigate = () => {
if (email !== "" && !errorEmail) {
sessionStorage.setItem("email", email);
}
navigate("/forgot-password");
};
return (
<Box textAlign="center">
<Typography
className="forgot-p"
display="inline-block"
color={theme.palette.primary.main}
>
<Trans
i18nKey="auth.login.links.forgotPassword"
components={{
a: (
<Typography
component="span"
color={theme.palette.accent.main}
ml={theme.spacing(2)}
sx={{ userSelect: "none" }}
onClick={handleNavigate}
/>
),
}}
/>
</Typography>
</Box>
);
};
ForgotPasswordLabel.propTypes = {
email: PropTypes.string,
errorEmail: PropTypes.string,
};
export default ForgotPasswordLabel;
@@ -1,141 +0,0 @@
import { useRef, useEffect } from "react";
import { Box, Button, Stack, Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
import { useSelector } from "react-redux";
import TextInput from "../../../../Components/Inputs/TextInput";
import { PasswordEndAdornment } from "../../../../Components/Inputs/TextInput/Adornments";
import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
import PropTypes from "prop-types";
import { useTranslation } from "react-i18next";
/**
* Renders the password step of the login process, including a password input field.
*
* @param {Object} props
* @param {Object} props.form - Form state object.
* @param {Object} props.errors - Object containing form validation errors.
* @param {Function} props.onSubmit - Callback function to handle form submission.
* @param {Function} props.onChange - Callback function to handle form input changes.
* @param {Function} props.onBack - Callback function to handle "Back" button click.
* @returns {JSX.Element}
*/
const PasswordStep = ({ form, errors, onSubmit, onChange, onBack }) => {
const theme = useTheme();
const inputRef = useRef(null);
const authState = useSelector((state) => state.auth);
const { t } = useTranslation();
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, []);
return (
<>
<Stack
gap={{ xs: theme.spacing(12), sm: theme.spacing(16) }}
position="relative"
textAlign="center"
>
<Box>
<Typography component="h1">{t("auth.login.heading")}</Typography>
<Typography>{t("auth.login.subheadings.stepTwo")}</Typography>
</Box>
<Box
component="form"
noValidate
spellCheck={false}
onSubmit={onSubmit}
textAlign="left"
mb={theme.spacing(5)}
sx={{
display: "grid",
gap: { xs: theme.spacing(12), sm: theme.spacing(16) },
}}
>
<TextInput
type="password"
id="login-password-input"
label={t("auth.common.inputs.password.label")}
isRequired={true}
placeholder="••••••••••"
autoComplete="current-password"
value={form.password}
onChange={onChange}
error={errors.password ? true : false}
helperText={errors.password ? t(errors.password) : ""} // Localization keys are in validation.js
ref={inputRef}
endAdornment={<PasswordEndAdornment />}
/>
<Stack
direction="row"
justifyContent="space-between"
>
<Button
variant="outlined"
color="info"
onClick={onBack}
className="dashboard-style-button"
sx={{
px: theme.spacing(5),
borderRadius: `${theme.shape.borderRadius}px !important`,
"&.MuiButtonBase-root": {
borderRadius: `${theme.shape.borderRadius}px !important`,
},
"&.MuiButton-root": {
borderRadius: `${theme.shape.borderRadius}px !important`,
},
"& svg.MuiSvgIcon-root": {
mr: theme.spacing(3),
},
"&:focus-visible": {
outline: `2px solid ${theme.palette.primary.main}`,
outlineOffset: `2px`,
},
}}
>
<ArrowBackRoundedIcon />
{t("auth.common.navigation.back")}{" "}
</Button>
<Button
variant="contained"
color="accent"
type="submit"
loading={authState.isLoading}
disabled={errors.password && true}
className="dashboard-style-button"
sx={{
width: "30%",
px: theme.spacing(4),
borderRadius: `${theme.shape.borderRadius}px !important`,
"&.MuiButtonBase-root": {
borderRadius: `${theme.shape.borderRadius}px !important`,
},
"&.MuiButton-root": {
borderRadius: `${theme.shape.borderRadius}px !important`,
},
"&.Mui-focusVisible": {
outline: `2px solid ${theme.palette.primary.main}`,
outlineOffset: `2px`,
},
boxShadow: `none`,
}}
>
{t("auth.common.navigation.continue")}
</Button>
</Stack>
</Box>
</Stack>
</>
);
};
PasswordStep.propTypes = {
form: PropTypes.object.isRequired,
errors: PropTypes.object.isRequired,
onSubmit: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onBack: PropTypes.func.isRequired,
};
export default PasswordStep;
-266
View File
@@ -1,266 +0,0 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { Box, Stack, Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
import { loginCredentials } from "../../../Validation/validation";
import { login } from "../../../Features/Auth/authSlice";
import { useDispatch, useSelector } from "react-redux";
import { createToast } from "../../../Utils/toastUtils";
import { networkService } from "../../../main";
import Background from "../../../assets/Images/background-grid.svg?react";
import Logo from "../../../assets/icons/checkmate-icon.svg?react";
import "../index.css";
import EmailStep from "./Components/EmailStep";
import PasswordStep from "./Components/PasswordStep";
import ThemeSwitch from "../../../Components/ThemeSwitch";
import ForgotPasswordLabel from "./Components/ForgotPasswordLabel";
import LanguageSelector from "../../../Components/LanguageSelector";
import { Trans, useTranslation } from "react-i18next";
const DEMO = import.meta.env.VITE_APP_DEMO;
/**
* Displays the login page.
*/
const Login = () => {
const theme = useTheme();
const dispatch = useDispatch();
const { t } = useTranslation();
const navigate = useNavigate();
const authState = useSelector((state) => state.auth);
const { authToken } = authState;
const idMap = {
"login-email-input": "email",
"login-password-input": "password",
};
const [form, setForm] = useState({
email: DEMO !== undefined ? "uptimedemo@demo.com" : "",
password: DEMO !== undefined ? "Demouser1!" : "",
});
const [errors, setErrors] = useState({});
const [step, setStep] = useState(0);
useEffect(() => {
if (authToken) {
navigate("/uptime");
}
}, [authToken, navigate]);
const handleChange = (event) => {
const { value, id } = event.target;
const name = idMap[id];
const lowerCasedValue =
name === idMap["login-email-input"] ? value?.toLowerCase() || value : value;
setForm((prev) => ({
...prev,
[name]: lowerCasedValue,
}));
const { error } = loginCredentials.validate(
{ [name]: lowerCasedValue },
{ abortEarly: false }
);
setErrors((prev) => {
const prevErrors = { ...prev };
if (error) prevErrors[name] = error.details[0].message;
else delete prevErrors[name];
return prevErrors;
});
};
const handleSubmit = async (event) => {
event.preventDefault();
if (step === 0) {
const { error } = loginCredentials.validate(
{ email: form.email },
{ abortEarly: false }
);
if (error) {
const errorMessage = error.details[0].message;
const translatedMessage = errorMessage.startsWith("auth")
? t(errorMessage) // Localization keys are in validation.js
: errorMessage; // FIXME: Potential untranslated string
setErrors((prev) => ({ ...prev, email: translatedMessage }));
createToast({ body: translatedMessage });
} else {
setStep(1);
}
} else if (step === 1) {
const { error } = loginCredentials.validate(form, { abortEarly: false });
if (error) {
// validation errors
const newErrors = {};
error.details.forEach((err) => {
newErrors[err.path[0]] = err.message;
});
setErrors(newErrors);
createToast({
body:
error.details && error.details.length > 0
? error.details[0].message.startsWith("auth")
? t(error.details[0].message) // Localization keys are in validation.js
: error.details[0].message // FIXME: Potential untranslated string
: t("auth.common.errors.validation"),
});
} else {
const action = await dispatch(login(form));
if (action.payload.success) {
navigate("/uptime");
createToast({
body: t("auth.login.toasts.success"),
});
} else {
if (action.payload) {
if (action.payload.msg === "Incorrect password")
setErrors({
password: t("auth.common.fields.password.errors.incorrect"),
});
// dispatch errors
createToast({
body: t("auth.login.toasts.incorrectPassword"),
});
} else {
// unknown errors
createToast({
body: t("common.toasts.unknownError"),
});
}
}
}
}
};
return (
<Stack
className="login-page auth"
overflow="hidden"
sx={{
"& h1": {
color: theme.palette.primary.contrastText,
fontWeight: 600,
fontSize: 28,
},
/* TODO set font size from theme */
"& p": { fontSize: 14, color: theme.palette.primary.contrastTextSecondary },
"& 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"
justifyContent="space-between"
px={theme.spacing(12)}
gap={theme.spacing(4)}
>
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(4)}
>
<Logo style={{ borderRadius: theme.shape.borderRadius }} />
<Typography sx={{ userSelect: "none" }}>{t("common.appName")}</Typography>
</Stack>
<Stack
direction="row"
spacing={2}
alignItems="center"
>
<LanguageSelector />
<ThemeSwitch />
</Stack>
</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"
rowGap={theme.spacing(8)}
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),
},
},
}}
>
{step === 0 ? (
<EmailStep
form={form}
errors={errors}
onSubmit={handleSubmit}
onChange={handleChange}
/>
) : (
step === 1 && (
<PasswordStep
form={form}
errors={errors}
onSubmit={handleSubmit}
onChange={handleChange}
onBack={() => setStep(0)}
/>
)
)}
<ForgotPasswordLabel
email={form.email}
errorEmail={errors.email}
/>
{/* Registration link */}
<Box textAlign="center">
<Typography
className="forgot-p"
display="inline-block"
color={theme.palette.primary.main}
>
<Trans
i18nKey="auth.login.links.register"
components={{
a: (
<Typography
component="span"
color={theme.palette.accent.main}
ml={theme.spacing(2)}
sx={{
cursor: "pointer",
"&:hover": {
color: theme.palette.accent.darker,
},
}}
onClick={() => navigate("/register")}
/>
),
}}
/>
</Typography>
</Box>
</Stack>
</Stack>
);
};
export default Login;
+164
View File
@@ -0,0 +1,164 @@
// Components
import Stack from "@mui/material/Stack";
import AuthHeader from "../components/AuthHeader";
import Button from "@mui/material/Button";
import TextInput from "../../../Components/Inputs/TextInput";
import { PasswordEndAdornment } from "../../../Components/Inputs/TextInput/Adornments";
import { loginCredentials } from "../../../Validation/validation";
import TextLink from "../components/TextLink";
import Typography from "@mui/material/Typography";
// Utils
import { login } from "../../../Features/Auth/authSlice";
import { useNavigate } from "react-router-dom";
import { useDispatch } from "react-redux";
import { useState } from "react";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { createToast } from "../../../Utils/toastUtils";
const Login = () => {
// Local state
const [form, setForm] = useState({
email: "",
password: "",
});
const [errors, setErrors] = useState({
email: "",
password: "",
});
// Hooks
const { t } = useTranslation();
const theme = useTheme();
const dispatch = useDispatch();
const navigate = useNavigate();
// Handlers
const onChange = (e) => {
const { name, value } = e.target;
const updatedForm = { ...form, [name]: value };
const { error } = loginCredentials.validate({ [name]: value });
setForm(updatedForm);
setErrors((prev) => ({
...prev,
[name]: error?.details?.[0]?.message || "",
}));
};
const onSubmit = async (e) => {
e.preventDefault();
const toSubmit = { ...form };
const { error } = loginCredentials.validate(toSubmit, { abortEarly: false });
if (error) {
const formErrors = {};
for (const err of error.details) {
formErrors[err.path[0]] = err.message;
}
setErrors(formErrors);
return;
}
const action = await dispatch(login(form));
if (action.payload.success) {
navigate("/uptime");
createToast({
body: t("auth.login.toasts.success"),
});
} else {
if (action.payload) {
if (action.payload.msg === "Incorrect password")
setErrors({
password: t("auth.login.errors.password.incorrect"),
});
// dispatch errors
createToast({
body: t("auth.login.toasts.incorrectPassword"),
});
} else {
// unknown errors
createToast({
body: t("common.toasts.unknownError"),
});
}
}
};
return (
<Stack
gap={theme.spacing(10)}
height="100vh"
>
<AuthHeader />
<Stack
margin="auto"
width="100%"
alignItems="center"
gap={theme.spacing(10)}
>
<Typography variant="h1">{t("auth.login.heading")}</Typography>
<Stack
component="form"
width="100%"
maxWidth={600}
alignSelf="center"
justifyContent="center"
borderRadius={theme.spacing(5)}
borderColor={theme.palette.primary.lowContrast}
backgroundColor={theme.palette.primary.main}
padding={theme.spacing(12)}
gap={theme.spacing(12)}
onSubmit={onSubmit}
>
<TextInput
type="email"
name="email"
label={t("auth.common.inputs.email.label")}
isRequired={true}
placeholder={t("auth.common.inputs.email.placeholder")}
autoComplete="email"
value={form.email}
onChange={onChange}
error={errors.email ? true : false}
helperText={errors.email ? t(errors.email) : ""} // Localization keys are in validation.js
/>
<TextInput
type="password"
name="password"
label={t("auth.common.inputs.password.label")}
isRequired={true}
placeholder="••••••••••"
autoComplete="current-password"
value={form.password}
onChange={onChange}
error={errors.password ? true : false}
helperText={errors.password ? t(errors.password) : ""} // Localization keys are in validation.js
endAdornment={<PasswordEndAdornment />}
/>
<Button
variant="contained"
color="accent"
type="submit"
sx={{ width: "30%", alignSelf: "flex-end" }}
>
Login
</Button>
</Stack>
<TextLink
text={t("auth.login.links.forgotPassword")}
linkText={t("auth.login.links.forgotPasswordLink")}
href="/forgot-password"
/>
<TextLink
text={t("auth.login.links.register")}
linkText={t("auth.login.links.registerLink")}
href="/register"
/>
</Stack>
</Stack>
);
};
export default Login;
@@ -0,0 +1,45 @@
// Components
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Logo from "../../../assets/icons/checkmate-icon.svg?react";
import LanguageSelector from "../../../Components/LanguageSelector";
import ThemeSwitch from "../../../Components/ThemeSwitch";
// Utils
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
const AuthHeader = () => {
// Hooks
const theme = useTheme();
const { t } = useTranslation();
return (
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
px={theme.spacing(12)}
gap={theme.spacing(4)}
>
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(4)}
>
<Logo style={{ borderRadius: theme.shape.borderRadius }} />
<Typography sx={{ userSelect: "none" }}>{t("common.appName")}</Typography>
</Stack>
<Stack
direction="row"
spacing={theme.spacing(2)}
alignItems="center"
>
<LanguageSelector />
<ThemeSwitch />
</Stack>
</Stack>
);
};
export default AuthHeader;
@@ -0,0 +1,34 @@
import Typography from "@mui/material/Typography";
import Stack from "@mui/material/Stack";
import Link from "@mui/material/Link";
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
import { Link as RouterLink } from "react-router-dom";
const TextLink = ({ text, linkText, href }) => {
const theme = useTheme();
return (
<Stack
direction="row"
gap={theme.spacing(4)}
>
<Typography>{text}</Typography>
<Link
color="accent"
to={href}
component={RouterLink}
>
{linkText}
</Link>
</Stack>
);
};
TextLink.propTypes = {
text: PropTypes.string,
linkText: PropTypes.string,
href: PropTypes.string,
};
export default TextLink;
+1 -2
View File
@@ -3,7 +3,7 @@ import HomeLayout from "../Components/Layouts/HomeLayout";
import NotFound from "../Pages/NotFound";
// Auth
import AuthLogin from "../Pages/Auth/Login/Login";
import AuthLogin from "../Pages/Auth/Login";
import AuthRegister from "../Pages/Auth/Register/Register";
import AuthForgotPassword from "../Pages/Auth/ForgotPassword";
import AuthCheckEmail from "../Pages/Auth/CheckEmail";
@@ -48,7 +48,6 @@ import Settings from "../Pages/Settings";
import Maintenance from "../Pages/Maintenance";
import ProtectedRoute from "../Components/ProtectedRoute";
import ProtectedDistributedUptimeRoute from "../Components/ProtectedDistributedUptimeRoute";
import CreateNewMaintenanceWindow from "../Pages/Maintenance/CreateMaintenance";
import withAdminCheck from "../Components/HOC/withAdminCheck";
import BulkImport from "../Pages/Uptime/BulkImport";
+9 -2
View File
@@ -102,12 +102,19 @@
"stepTwo": "Enter your password"
},
"links": {
"forgotPassword": "Forgot password? <a>Reset password</a>",
"register": "Do not have an account? <a>Register here</a>"
"forgotPassword": "Forgot password?",
"forgotPasswordLink": "Reset password",
"register": "Do not have an account?",
"registerLink": "Register here"
},
"toasts": {
"success": "Welcome back! You're successfully logged in.",
"incorrectPassword": "Incorrect password"
},
"errors": {
"password": {
"incorrect": "The password you provided does not match our records"
}
}
},
"registration": {