Merge pull request #3251 from bluewave-labs/feat/v2-auth-login

feat: v2 auth login
This commit is contained in:
Alexander Holliday authored and GitHub committed 2026-02-06 13:36:27 -08:00
commit 31b09c316a
20 files changed
+465 -236

No files matched your search

@@ -1,4 +1,6 @@
import Logo from "@/assets/icons/checkmate-icon.svg?react";
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Alert from "@mui/material/Alert";
import Link from "@mui/material/Link";
import {
@@ -9,6 +11,7 @@ import {
} from "@/Components/v2/design-elements/Fallback";
import { Breadcrumb } from "@/Components/v2/design-elements/Breadcrumb";
import CircularProgress from "@mui/material/CircularProgress";
import { HeaderAuthControls } from "@/Pages/Auth/components/HeaderAuthControls";
import type { StackProps } from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles";
@@ -201,3 +204,61 @@ export const MonitorBasePageWithStates = ({
</BasePage>
);
};
interface BaseAuthPageProps extends React.PropsWithChildren, StackProps {
title: string;
subtitle: string;
}
export const BaseAuthPage = ({
title,
subtitle,
children,
...props
}: BaseAuthPageProps) => {
const theme = useTheme();
return (
<BasePage
breadcrumbOverride={[]}
gap={theme.spacing(8)}
alignItems={"center"}
justifyContent={"center"}
minHeight="100vh"
position={"relative"}
{...props}
>
<HeaderAuthControls
hideLogo
py={theme.spacing(4)}
position={"absolute"}
top={0}
left={0}
/>
<Box width={{ xs: 60, sm: 70, md: 80 }}>
<Logo
width={"100%"}
height={"100%"}
/>
</Box>
<Stack alignItems={"center"}>
<Typography
variant="h1"
mb={theme.spacing(2)}
>
{title}
</Typography>
<Typography variant="h1">{subtitle}</Typography>
</Stack>
<Stack
gap={theme.spacing(8)}
width={{
xs: "80%",
md: "25%",
lg: "15%",
}}
>
{children}
</Stack>
</BasePage>
);
};
@@ -0,0 +1,42 @@
import Typography from "@mui/material/Typography";
import Stack from "@mui/material/Stack";
import type { StackProps } from "@mui/material/Stack";
import Link from "@mui/material/Link";
import { Link as RouterLink } from "react-router-dom";
import { useTheme } from "@mui/material/styles";
interface TextLinkProps extends StackProps {
text: string;
linkText: string;
href: string;
target?: string;
}
export const TextLink = ({
text,
linkText,
href,
target = "_self",
...props
}: TextLinkProps) => {
const theme = useTheme();
return (
<Stack
direction="row"
gap={theme.spacing(4)}
{...props}
>
<Typography>{text}</Typography>
<Link
color="primary"
to={href}
component={RouterLink}
target={target}
>
{linkText}
</Link>
</Stack>
);
};
@@ -18,3 +18,4 @@ export * from "./BaseChart";
export * from "./Gauge";
export * from "./Tabs";
export * from "./SplitBox";
export * from "./TextLink";
@@ -0,0 +1,100 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import MenuItem from "@mui/material/MenuItem";
import { Select } from "@/Components/v2/inputs";
import "flag-icons/css/flag-icons.min.css";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material";
import { useSelector } from "react-redux";
import { useDispatch } from "react-redux";
import { setLanguage } from "@/Features/UI/uiSlice";
import type { RootState } from "@/Types/state";
const langMap: Record<string, string> = {
cs: "cz",
ja: "jp",
uk: "ua",
vi: "vn",
};
export const LanguageSelector = () => {
const { i18n } = useTranslation();
const theme = useTheme();
const { language = "en" } = useSelector((state: RootState) => state.ui);
const dispatch = useDispatch();
const handleChange = (event: any) => {
const newLang = event.target.value;
dispatch(setLanguage(newLang));
};
const languages = Object.keys(i18n.options.resources || {});
return (
<Select
value={language}
onChange={handleChange}
size="small"
// sx={{
// minWidth: 80,
// "& .MuiSelect-select": {
// display: "flex",
// alignItems: "center",
// justifyContent: "center",
// },
// "& .MuiSelect-icon": {
// alignSelf: "center",
// },
// }}
>
{languages.map((lang) => {
let parsedLang = lang === "en" ? "gb" : lang;
if (parsedLang.includes("-")) {
parsedLang = parsedLang.split("-")[1].toLowerCase();
}
parsedLang = langMap[parsedLang] || parsedLang;
const flag = parsedLang ? `fi fi-${parsedLang}` : null;
return (
<MenuItem
key={lang}
value={lang}
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<Stack
direction="row"
spacing={theme.spacing(2)}
alignItems="center"
justifyContent="center"
>
<Box
component="span"
sx={{
display: "flex",
alignItems: "center",
}}
>
{flag && <span className={flag} />}
</Box>
<Box
component="span"
sx={{ textTransform: "uppercase" }}
>
{lang}
</Box>
</Stack>
</MenuItem>
);
})}
</Select>
);
};
export default LanguageSelector;
@@ -0,0 +1,48 @@
import IconButton from "@mui/material/IconButton";
import { Moon, Sun } from "lucide-react";
import { setMode } from "@/Features/UI/uiSlice.js";
import { useTheme } from "@mui/material";
import { useDispatch, useSelector } from "react-redux";
import type { RootState } from "@/Types/state";
export const SwitchTheme = () => {
const mode = useSelector((state: RootState) => state.ui.mode);
const dispatch = useDispatch();
const theme = useTheme();
const handleChange = () => {
dispatch(setMode(mode === "light" ? "dark" : "light"));
};
return (
<IconButton
id="theme-toggle"
onClick={handleChange}
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
"& svg": {
transition: "stroke 0.2s ease",
},
"&:hover svg path, &:hover svg line, &:hover svg polyline, &:hover svg rect, &:hover svg circle":
{
stroke: theme.palette.primary.main,
},
}}
>
{mode === "light" ? (
<Moon
size={16}
strokeWidth={1.5}
/>
) : (
<Sun
size={16}
strokeWidth={1.5}
/>
)}
</IconButton>
);
};
@@ -16,3 +16,5 @@ export * from "./ImageUpload";
export * from "./ColorPicker";
export { DatePickerComponent as DatePicker } from "./DatePicker";
export { TimePickerComponent as TimePicker } from "./TimePicker";
export * from "./LanguageSelector";
export * from "./SwitchTheme";
+8 -1
View File
@@ -196,6 +196,13 @@ const authSlice = createSlice({
state.success = true;
state.msg = "Logged out successfully";
},
setAuthState: (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
state.authToken = action.payload.data.token;
state.user = action.payload.data.user;
},
},
extraReducers: (builder) => {
// Register thunk
@@ -249,4 +256,4 @@ const authSlice = createSlice({
});
export default authSlice.reducer;
export const { clearAuthState } = authSlice.actions;
export const { clearAuthState, setAuthState } = authSlice.actions;
+13
View File
@@ -0,0 +1,13 @@
import { useMemo } from "react";
import { loginSchema, type LoginFormData } from "@/Validation/login";
export const useLoginForm = () => {
return useMemo(() => {
const defaults: LoginFormData = {
email: "",
password: "",
};
return { schema: loginSchema, defaults };
}, []);
};
-18
View File
@@ -1,18 +0,0 @@
import React from "react";
import AppBar from "@/Components/v1/Common/AppBar.jsx";
import Footer from "@/Components/v1/Common/Footer.jsx";
import { useTranslation } from "react-i18next";
const AboutUs = () => {
const { t } = useTranslation();
return (
<div>
<AppBar />
<h1>{t("aboutus")}</h1>
<Footer />
</div>
);
};
export default AboutUs;
@@ -1,22 +0,0 @@
// Hook to avoid double submits and manage loading state
import { useState, useCallback } from "react";
const useLoadingSubmit = () => {
const [submitting, setSubmitting] = useState(false);
const executeSubmit = useCallback(
async (submitFunction) => {
if (submitting) return;
setSubmitting(true);
try {
return await submitFunction();
} finally {
setSubmitting(false);
}
},
[submitting]
);
return { submitting, executeSubmit };
};
export default useLoadingSubmit;
@@ -1,19 +0,0 @@
import { useState } from "react";
const useLoginForm = () => {
const [form, setForm] = useState({
email: "",
password: "",
});
const onChange = (e) => {
let { name, value } = e.target;
if (name === "email") {
value = value.toLowerCase();
}
const updatedForm = { ...form, [name]: value };
setForm(updatedForm);
};
return [form, onChange];
};
export default useLoginForm;
@@ -1,40 +0,0 @@
import { useDispatch } from "react-redux";
import { login } from "../../../../Features/Auth/authSlice.js";
import { useNavigate } from "react-router-dom";
import { createToast } from "../../../../Utils/toastUtils.jsx";
import { useTranslation } from "react-i18next";
const useLoginSubmit = () => {
const dispatch = useDispatch();
const navigate = useNavigate();
const { t } = useTranslation();
const handleLoginSubmit = async (form, setErrors) => {
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 [handleLoginSubmit];
};
export default useLoginSubmit;
@@ -1,31 +0,0 @@
import { useState } from "react";
import { loginCredentials } from "../../../../Validation/validation.js";
const useValidateLoginForm = () => {
const [errors, setErrors] = useState({
email: "",
password: "",
});
const validateField = (name, value) => {
const { error } = loginCredentials.validate({ [name]: value });
setErrors((prev) => ({
...prev,
[name]: error?.details?.[0]?.message || "",
}));
};
const validateForm = (form) => {
const { error } = loginCredentials.validate(form, { abortEarly: false });
if (error) {
const formErrors = {};
for (const err of error.details) {
formErrors[err.path[0]] = err.message;
}
setErrors(formErrors);
return false;
}
return true;
};
return [errors, setErrors, validateField, validateForm];
};
export default useValidateLoginForm;
-102
View File
@@ -1,102 +0,0 @@
// Components
import Stack from "@mui/material/Stack";
import Button from "@mui/material/Button";
import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx";
import { PasswordEndAdornment } from "@/Components/v1/Inputs/TextInput/Adornments/index.jsx";
import TextLink from "@/Components/v1/TextLink/index.jsx";
import AuthPageWrapper from "../components/AuthPageWrapper.jsx";
// Utils
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import useLoginForm from "./hooks/useLoginForm.jsx";
import useValidateLoginForm from "./hooks/useValidateLoginForm.jsx";
import useLoginSubmit from "./hooks/useLoginSubmit.jsx";
import useLoadingSubmit from "./hooks/useLoadingSubmit.jsx";
const Login = () => {
// Hooks
const { t } = useTranslation();
const theme = useTheme();
const [form, onChange] = useLoginForm();
const [errors, setErrors, validateField, validateForm] = useValidateLoginForm();
const [handleLoginSubmit] = useLoginSubmit();
const { submitting, executeSubmit } = useLoadingSubmit();
// Handlers
const handleChange = (e) => {
onChange(e);
validateField(e.target.name, e.target.value);
};
const onSubmit = async (e) => {
e.preventDefault();
const isValid = validateForm(form);
if (!isValid) return;
await executeSubmit(() => handleLoginSubmit(form, setErrors));
};
return (
<AuthPageWrapper
welcome={t("auth.login.welcome")}
heading={t("auth.login.heading")}
>
<Stack
component="form"
width="100%"
padding={theme.spacing(8)}
gap={theme.spacing(12)}
onSubmit={onSubmit}
sx={{
width: {
sm: "80%",
md: "70%",
lg: "65%",
xl: "65%",
},
}}
>
<TextInput
type="email"
name="email"
label={t("auth.common.inputs.email.label")}
placeholder={t("auth.common.inputs.email.placeholder")}
autoComplete="email"
value={form.email}
onChange={handleChange}
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")}
placeholder="••••••••••"
autoComplete="current-password"
value={form.password}
onChange={handleChange}
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: "100%", alignSelf: "center", fontWeight: 700 }}
loading={submitting}
>
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"
/>
</AuthPageWrapper>
);
};
export default Login;
+83
View File
@@ -0,0 +1,83 @@
import { BaseAuthPage } from "@/Components/v2/design-elements";
import { Button, TextField } from "@/Components/v2/inputs";
import { useTranslation } from "react-i18next";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { useLoginForm } from "@/Hooks/useLoginForm";
import type { LoginFormData } from "@/Validation/login";
import { useDispatch } from "react-redux";
import { useNavigate } from "react-router-dom";
import { setAuthState } from "@/Features/Auth/authSlice";
import { usePost } from "@/Hooks/UseApi";
const LoginPage = () => {
const { t } = useTranslation();
const dispatch = useDispatch();
const navigate = useNavigate();
const { post, loading } = usePost();
const { schema, defaults } = useLoginForm();
const { control, handleSubmit } = useForm<LoginFormData>({
resolver: zodResolver(schema),
defaultValues: defaults,
});
const onSubmit = async (data: LoginFormData) => {
if (loading) return;
const result = await post("/auth/login", data);
if (result?.success) {
dispatch(setAuthState(result));
navigate("/uptime");
}
};
return (
<BaseAuthPage
component="form"
onSubmit={handleSubmit(onSubmit)}
title={t("pages.auth.login.title")}
subtitle={t("pages.auth.login.subtitle")}
>
<Controller
name="email"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.auth.common.form.option.email.label")}
placeholder={t("pages.auth.common.form.option.email.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error ? t(fieldState.error.message ?? "") : ""}
/>
)}
/>
<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 ? t(fieldState.error.message ?? "") : ""}
/>
)}
/>
<Button
variant="contained"
type="submit"
loading={loading}
>
{t("pages.auth.login.submit")}
</Button>
</BaseAuthPage>
);
};
export default LoginPage;
@@ -0,0 +1,53 @@
import Stack, { type StackProps } from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Logo from "@/assets/icons/checkmate-icon.svg?react";
import { LanguageSelector, SwitchTheme } from "@/Components/v2/inputs";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
interface HeaderAuthControlsProps extends StackProps {
hideLogo?: boolean;
}
export const HeaderAuthControls = ({
hideLogo = false,
...stackProps
}: HeaderAuthControlsProps) => {
// Hooks
const theme = useTheme();
const { t } = useTranslation();
return (
<Stack
width={"100%"}
direction="row"
alignItems="center"
justifyContent="space-between"
px={theme.spacing(12)}
gap={theme.spacing(4)}
{...stackProps}
>
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(4)}
>
{!hideLogo && (
<>
<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 />
<SwitchTheme />
</Stack>
</Stack>
);
};
+8 -2
View File
@@ -7,7 +7,7 @@ import { Navigate, Route, Routes as LibRoutes } from "react-router";
import HomeLayout from "@/Components/v1/Layouts/HomeLayout";
import NotFound from "../Pages/NotFound/index.jsx";
// Auth
import AuthLogin from "../Pages/Auth/Login/index.jsx";
import AuthLogin from "../Pages/Auth/Login";
import AuthRegister from "../Pages/Auth/Register/index.jsx";
import AuthForgotPassword from "../Pages/Auth/ForgotPassword.jsx";
import AuthCheckEmail from "../Pages/Auth/CheckEmail.jsx";
@@ -360,7 +360,13 @@ const Routes = () => {
<Route
path="/login"
element={<AuthLogin />}
element={
<>
<ThemeProvider theme={v2theme}>
<AuthLogin />
</ThemeProvider>
</>
}
/>
<Route
+3 -1
View File
@@ -42,7 +42,9 @@ export const initApiClient = (store: StoreType): void => {
const onSuccess = (response: AxiosResponse) => response;
const onError = (error: AxiosError) => {
if (error.response?.status === 401) {
window.location.href = "/login";
if (window.location.pathname !== "/login") {
window.location.href = "/login";
}
}
return Promise.reject(error);
};
+12
View File
@@ -0,0 +1,12 @@
import { z } from "zod";
export const loginSchema = z.object({
email: z
.string()
.min(1, "auth.common.inputs.email.errors.empty")
.email("auth.common.inputs.email.errors.invalid")
.transform((val) => val.toLowerCase().trim()),
password: z.string().min(1, "auth.common.inputs.password.errors.empty"),
});
export type LoginFormData = z.infer<typeof loginSchema>;
+31
View File
@@ -498,6 +498,37 @@
"now": "Now",
"os": "OS",
"pages": {
"auth": {
"common": {
"form": {
"option": {
"email": {
"label": "Email",
"placeholder": "me@example.com"
},
"password": {
"label": "Password",
"placeholder": "********"
}
}
}
},
"login": {
"title": "Welcome back to Checkmate!",
"subtitle": "Log in to continue",
"submit": "Login",
"links": {
"forgotPassword": {
"text": "Forgot password?",
"linkText": "Reset password"
},
"register": {
"text": "Don't have an account?",
"linkText": "Register here"
}
}
}
},
"checks": {
"selects": {
"monitor": {