diff --git a/client/src/Components/v2/design-elements/BasePage.tsx b/client/src/Components/v2/design-elements/BasePage.tsx
index 22330f46d..65e74b553 100644
--- a/client/src/Components/v2/design-elements/BasePage.tsx
+++ b/client/src/Components/v2/design-elements/BasePage.tsx
@@ -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 = ({
);
};
+
+interface BaseAuthPageProps extends React.PropsWithChildren, StackProps {
+ title: string;
+ subtitle: string;
+}
+
+export const BaseAuthPage = ({
+ title,
+ subtitle,
+ children,
+ ...props
+}: BaseAuthPageProps) => {
+ const theme = useTheme();
+ return (
+
+
+
+
+
+
+
+ {title}
+
+ {subtitle}
+
+
+ {children}
+
+
+ );
+};
diff --git a/client/src/Components/v2/design-elements/TextLink.tsx b/client/src/Components/v2/design-elements/TextLink.tsx
new file mode 100644
index 000000000..704b4b841
--- /dev/null
+++ b/client/src/Components/v2/design-elements/TextLink.tsx
@@ -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 (
+
+ {text}
+
+ {linkText}
+
+
+ );
+};
diff --git a/client/src/Components/v2/design-elements/index.tsx b/client/src/Components/v2/design-elements/index.tsx
index a96fea052..b7b1d484e 100644
--- a/client/src/Components/v2/design-elements/index.tsx
+++ b/client/src/Components/v2/design-elements/index.tsx
@@ -18,3 +18,4 @@ export * from "./BaseChart";
export * from "./Gauge";
export * from "./Tabs";
export * from "./SplitBox";
+export * from "./TextLink";
diff --git a/client/src/Components/v2/inputs/LanguageSelector.tsx b/client/src/Components/v2/inputs/LanguageSelector.tsx
new file mode 100644
index 000000000..2a1274ef2
--- /dev/null
+++ b/client/src/Components/v2/inputs/LanguageSelector.tsx
@@ -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 = {
+ 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 (
+
+ );
+};
+
+export default LanguageSelector;
diff --git a/client/src/Components/v2/inputs/SwitchTheme.tsx b/client/src/Components/v2/inputs/SwitchTheme.tsx
new file mode 100644
index 000000000..e609f50fb
--- /dev/null
+++ b/client/src/Components/v2/inputs/SwitchTheme.tsx
@@ -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 (
+
+ {mode === "light" ? (
+
+ ) : (
+
+ )}
+
+ );
+};
diff --git a/client/src/Components/v2/inputs/index.tsx b/client/src/Components/v2/inputs/index.tsx
index 25d9febe3..78f1a51cf 100644
--- a/client/src/Components/v2/inputs/index.tsx
+++ b/client/src/Components/v2/inputs/index.tsx
@@ -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";
diff --git a/client/src/Features/Auth/authSlice.js b/client/src/Features/Auth/authSlice.js
index b78a8b47e..1d93ce3fa 100644
--- a/client/src/Features/Auth/authSlice.js
+++ b/client/src/Features/Auth/authSlice.js
@@ -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;
diff --git a/client/src/Hooks/useLoginForm.ts b/client/src/Hooks/useLoginForm.ts
new file mode 100644
index 000000000..d69acd0ab
--- /dev/null
+++ b/client/src/Hooks/useLoginForm.ts
@@ -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 };
+ }, []);
+};
diff --git a/client/src/Pages/About/index.jsx b/client/src/Pages/About/index.jsx
deleted file mode 100644
index ff7bb6c6b..000000000
--- a/client/src/Pages/About/index.jsx
+++ /dev/null
@@ -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 (
-
- );
-};
-
-export default AboutUs;
diff --git a/client/src/Pages/Auth/Login/hooks/useLoadingSubmit.jsx b/client/src/Pages/Auth/Login/hooks/useLoadingSubmit.jsx
deleted file mode 100644
index a779165b6..000000000
--- a/client/src/Pages/Auth/Login/hooks/useLoadingSubmit.jsx
+++ /dev/null
@@ -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;
diff --git a/client/src/Pages/Auth/Login/hooks/useLoginForm.jsx b/client/src/Pages/Auth/Login/hooks/useLoginForm.jsx
deleted file mode 100644
index 61814d723..000000000
--- a/client/src/Pages/Auth/Login/hooks/useLoginForm.jsx
+++ /dev/null
@@ -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;
diff --git a/client/src/Pages/Auth/Login/hooks/useLoginSubmit.jsx b/client/src/Pages/Auth/Login/hooks/useLoginSubmit.jsx
deleted file mode 100644
index b009793d5..000000000
--- a/client/src/Pages/Auth/Login/hooks/useLoginSubmit.jsx
+++ /dev/null
@@ -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;
diff --git a/client/src/Pages/Auth/Login/hooks/useValidateLoginForm.jsx b/client/src/Pages/Auth/Login/hooks/useValidateLoginForm.jsx
deleted file mode 100644
index 0872213ad..000000000
--- a/client/src/Pages/Auth/Login/hooks/useValidateLoginForm.jsx
+++ /dev/null
@@ -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;
diff --git a/client/src/Pages/Auth/Login/index.jsx b/client/src/Pages/Auth/Login/index.jsx
deleted file mode 100644
index 7f5dcb5e4..000000000
--- a/client/src/Pages/Auth/Login/index.jsx
+++ /dev/null
@@ -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 (
-
-
-
- }
- />
-
-
-
-
-
- );
-};
-export default Login;
diff --git a/client/src/Pages/Auth/Login/index.tsx b/client/src/Pages/Auth/Login/index.tsx
new file mode 100644
index 000000000..1e4b48aaf
--- /dev/null
+++ b/client/src/Pages/Auth/Login/index.tsx
@@ -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({
+ 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 (
+
+ (
+
+ )}
+ />
+ (
+
+ )}
+ />
+
+
+ );
+};
+
+export default LoginPage;
diff --git a/client/src/Pages/Auth/components/HeaderAuthControls.tsx b/client/src/Pages/Auth/components/HeaderAuthControls.tsx
new file mode 100644
index 000000000..a62048372
--- /dev/null
+++ b/client/src/Pages/Auth/components/HeaderAuthControls.tsx
@@ -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 (
+
+
+ {!hideLogo && (
+ <>
+
+ {t("common.appName")}
+ >
+ )}
+
+
+
+
+
+
+ );
+};
diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx
index 22b5b446a..b281b359e 100644
--- a/client/src/Routes/index.jsx
+++ b/client/src/Routes/index.jsx
@@ -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 = () => {
}
+ element={
+ <>
+
+
+
+ >
+ }
/>
{
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);
};
diff --git a/client/src/Validation/login.ts b/client/src/Validation/login.ts
new file mode 100644
index 000000000..03eb8d4e2
--- /dev/null
+++ b/client/src/Validation/login.ts
@@ -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;
diff --git a/client/src/locales/en.json b/client/src/locales/en.json
index 4bd280dcf..514ac3db4 100644
--- a/client/src/locales/en.json
+++ b/client/src/locales/en.json
@@ -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": {