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 ( -
- -

{t("aboutus")}

-
-
- ); -}; - -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": {