Merge pull request #3014 from bluewave-labs/fix/v2/auth-pages

fix/v2: add missing auth elements
This commit is contained in:
Alexander Holliday
2025-10-09 11:07:43 -07:00
committed by GitHub
12 changed files with 550 additions and 186 deletions
@@ -0,0 +1,48 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import { HeaderAuth } from "@/Components/v2/Auth";
import Logo from "@/assets/icons/checkmate-icon.svg?react";
import type { StackProps } from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles";
import { Typography } from "@mui/material";
interface AuthBasePageProps extends StackProps {
title?: string;
subtitle?: string;
children: React.ReactNode;
}
export const AuthBasePage: React.FC<AuthBasePageProps> = ({
children,
title,
subtitle,
...props
}) => {
const theme = useTheme();
return (
<Stack
gap={theme.spacing(10)}
minHeight="100vh"
{...props}
>
<HeaderAuth />
<Stack
alignItems="center"
margin="auto"
width="100%"
gap={theme.spacing(4)}
>
<Box
width={{ xs: 60, sm: 70, md: 80 }}
mb={theme.spacing(10)}
>
<Logo style={{ width: "100%", height: "100%" }} />
</Box>
<Typography variant="h1">{title}</Typography>
<Typography variant="h1">{subtitle}</Typography>
{children}
</Stack>
</Stack>
);
};
@@ -0,0 +1,26 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Logo from "@/assets/icons/checkmate-icon.svg?react";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { LanguageSelector, ThemeSwitch } from "@/Components/v2/Inputs";
export const HeaderAuth = () => {
const theme = useTheme();
const { t } = useTranslation();
return (
<Stack
width={"100%"}
direction="row"
alignItems="center"
justifyContent="flex-end"
py={theme.spacing(4)}
px={theme.spacing(12)}
gap={theme.spacing(4)}
>
<LanguageSelector />
<ThemeSwitch color="red" />
</Stack>
);
};
+2
View File
@@ -0,0 +1,2 @@
export { HeaderAuth } from "./HeaderAuth";
export { AuthBasePage } from "./AuthBasePage";
@@ -0,0 +1,64 @@
import "flag-icons/css/flag-icons.min.css";
import { Select } from "@/Components/v2/Inputs";
import MenuItem from "@mui/material/MenuItem";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material/styles";
import { useDispatch } from "react-redux";
import { useSelector } from "react-redux";
import { setLanguage } from "@/Features/UI/uiSlice";
import type { SelectChangeEvent } from "@mui/material/Select";
export const LanguageSelector = () => {
const { i18n } = useTranslation();
const theme = useTheme();
const dispatch = useDispatch();
const language = useSelector((state: any) => state.ui.language);
const languages = Object.keys(i18n.options.resources || {});
const languageMap: Record<string, string> = {
cs: "cz",
ja: "jp",
uk: "ua",
vi: "vn",
};
const handleChange = (event: SelectChangeEvent<unknown>) => {
const newLang = event.target.value;
dispatch(setLanguage(newLang));
};
const languagesForDisplay = languages.map((l) => {
let formattedLanguage = l === "en" ? "gb" : l;
formattedLanguage = formattedLanguage.includes("-")
? formattedLanguage.split("-")[1].toLowerCase()
: formattedLanguage;
formattedLanguage = languageMap[formattedLanguage] || formattedLanguage;
const flag = formattedLanguage ? `fi fi-${formattedLanguage}` : null;
return (
<MenuItem
key={l}
value={l}
>
<Stack
direction="row"
gap={theme.spacing(4)}
>
{flag && <span className={flag} />}
<Typography textTransform={"uppercase"}>{l}</Typography>
</Stack>
</MenuItem>
);
});
return (
<Select
value={language}
onChange={handleChange}
>
{languagesForDisplay}
</Select>
);
};
+17 -1
View File
@@ -1,6 +1,22 @@
import Select from "@mui/material/Select";
import type { SelectProps } from "@mui/material/Select";
import { useTheme } from "@mui/material/styles";
export const SelectInput: React.FC<SelectProps> = ({ ...props }) => {
return <Select {...props} />;
const theme = useTheme();
return (
<Select
{...props}
sx={{
height: "34px",
"& .MuiOutlinedInput-notchedOutline": {
borderRadius: theme.shape.borderRadius,
borderColor: theme.palette.primary.lowContrast,
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.primary.lowContrast,
},
}}
/>
);
};
@@ -0,0 +1,37 @@
import Typography from "@mui/material/Typography";
import Stack from "@mui/material/Stack";
import Link from "@mui/material/Link";
import { Link as RouterLink } from "react-router-dom";
import { useTheme } from "@mui/material/styles";
export const TextLink = ({
text,
linkText,
href,
target = "_self",
}: {
text: string;
linkText: string;
href: string;
target?: string;
}) => {
const theme = useTheme();
return (
<Stack
direction="row"
gap={theme.spacing(4)}
>
<Typography>{text}</Typography>
<Link
color="accent"
to={href}
component={RouterLink}
target={target}
>
{linkText}
</Link>
</Stack>
);
};
@@ -0,0 +1,135 @@
import { useTheme } from "@mui/material/styles";
import { useDispatch, useSelector } from "react-redux";
import { setMode } from "@/Features/UI/uiSlice.js";
import { useTranslation } from "react-i18next";
import IconButton from "@mui/material/IconButton";
const SunAndMoonIcon = () => {
const theme = useTheme();
return (
<svg
className="sun-and-moon"
aria-hidden="true"
width="24"
height="24"
viewBox="0 0 24 24"
>
<mask
className="moon"
id="moon-mask"
>
<rect
x="0"
y="0"
width="100%"
height="100%"
fill="#fff"
/>
<circle
cx="24"
cy="10"
r="6"
fill="#000"
/>
</mask>
<circle
className="sun"
cx="12"
cy="12"
r="6"
fill={theme.palette.primary.contrastTextSecondary}
mask="url(#moon-mask)"
/>
<g
className="sun-beams"
stroke={theme.palette.primary.contrastTextSecondary}
>
<line
x1="12"
y1="1"
x2="12"
y2="3"
/>
<line
x1="12"
y1="21"
x2="12"
y2="23"
/>
<line
x1="4.22"
y1="4.22"
x2="5.64"
y2="5.64"
/>
<line
x1="18.36"
y1="18.36"
x2="19.78"
y2="19.78"
/>
<line
x1="1"
y1="12"
x2="3"
y2="12"
/>
<line
x1="21"
y1="12"
x2="23"
y2="12"
/>
<line
x1="4.22"
y1="19.78"
x2="5.64"
y2="18.36"
/>
<line
x1="18.36"
y1="5.64"
x2="19.78"
y2="4.22"
/>
</g>
</svg>
);
};
export const ThemeSwitch = ({
width = 48,
height = 48,
}: {
width?: number;
height?: number;
}) => {
const mode = useSelector((state: any) => state.ui.mode);
const dispatch = useDispatch();
const { t } = useTranslation();
const handleChange = () => {
dispatch(setMode(mode === "light" ? "dark" : "light"));
};
return (
<IconButton
id="theme-toggle"
title={t("common.buttons.toggleTheme")}
className={`theme-${mode}`}
aria-label="auto"
aria-live="polite"
onClick={handleChange}
sx={{
width,
height,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<SunAndMoonIcon />
</IconButton>
);
};
@@ -1,2 +1,7 @@
export { ButtonInput as Button } from "./Button";
export { ButtonGroupInput as ButtonGroup } from "./ButtonGroup";
export { TextInput } from "./TextInput";
export { SelectInput as Select } from "./Select";
export { LanguageSelector } from "./LanguageSelector";
export { ThemeSwitch } from "./ThemeSwitch";
export { TextLink } from "./TextLink";
+6 -2
View File
@@ -35,11 +35,15 @@ export const useGet = <T,>(
};
};
export const usePost = <B = any, R = any>(endpoint: string) => {
export const usePost = <B = any, R = any>() => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const postFn = async (body: B, config?: AxiosRequestConfig): Promise<R | null> => {
const postFn = async (
endpoint: string,
body: B,
config?: AxiosRequestConfig
): Promise<R | null> => {
setLoading(true);
setError(null);
+87 -68
View File
@@ -1,16 +1,19 @@
import { AuthBasePage } from "@/Components/v2/Auth";
import { Button } from "@/Components/v2/Inputs";
import Stack from "@mui/material/Stack";
import { TextInput, TextLink } from "@/Components/v2/Inputs";
import type { ApiResponse } from "@/Hooks/v2/UseApi";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useTranslation } from "react-i18next";
import { useForm, Controller } from "react-hook-form";
import { usePost } from "@/Hooks/v2/UseApi";
import { useNavigate } from "react-router";
import { useTheme } from "@mui/material/styles";
import { useDispatch } from "react-redux";
import { setIsAuthenticated } from "@/Features/Auth/v2AuthSlice";
import { useNavigate } from "react-router";
import { TextInput } from "@/Components/v2/Inputs/TextInput";
import Button from "@mui/material/Button";
import Stack from "@mui/material/Stack";
import { usePost } from "@/Hooks/v2/UseApi";
import type { ApiResponse } from "@/Hooks/v2/UseApi";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { useForm, Controller } from "react-hook-form";
const schema = z.object({
email: z.email("Invalid email address"),
@@ -23,7 +26,7 @@ const Login = () => {
const { t } = useTranslation();
const dispatch = useDispatch();
const theme = useTheme();
const { post, loading } = usePost<FormData, ApiResponse>("/auth/login");
const { post, loading } = usePost<FormData, ApiResponse>();
const navigate = useNavigate();
const {
@@ -39,7 +42,7 @@ const Login = () => {
});
const onSubmit = async (data: FormData) => {
const result = await post(data);
const result = await post("/auth/login", data);
if (result) {
dispatch(setIsAuthenticated({ authenticated: true }));
navigate("/v2/uptime");
@@ -49,68 +52,84 @@ const Login = () => {
};
return (
<Stack
alignItems={"center"}
justifyContent={"center"}
minHeight="100vh"
<AuthBasePage
title={t("auth.login.welcome")}
subtitle={t("auth.login.heading")}
>
<Stack
component="form"
padding={theme.spacing(8)}
gap={theme.spacing(12)}
onSubmit={handleSubmit(onSubmit)}
maxWidth={400}
sx={{
width: {
sm: "80%",
md: "70%",
lg: "65%",
xl: "65%",
},
}}
width={"100%"}
alignItems={"center"}
justifyContent={"center"}
gap={theme.spacing(8)}
>
<Controller
name="email"
control={control}
defaultValue=""
render={({ field }) => (
<TextInput
{...field}
label={t("auth.common.inputs.email.label")}
fullWidth
placeholder={t("auth.common.inputs.email.placeholder")}
error={!!errors.email}
helperText={errors.email ? errors.email.message : ""}
/>
)}
/>
<Controller
name="password"
control={control}
defaultValue=""
render={({ field }) => (
<TextInput
{...field}
type="password"
label={t("auth.common.inputs.password.label")}
fullWidth
placeholder="••••••••••"
error={!!errors.password}
helperText={errors.password ? errors.password.message : ""}
/>
)}
/>
<Button
variant="contained"
loading={loading}
color="accent"
type="submit"
sx={{ width: "100%", alignSelf: "center", fontWeight: 700 }}
<Stack
component="form"
padding={theme.spacing(8)}
gap={theme.spacing(12)}
onSubmit={handleSubmit(onSubmit)}
maxWidth={400}
sx={{
width: {
sm: "80%",
md: "70%",
lg: "65%",
xl: "65%",
},
}}
>
Login
</Button>
<Controller
name="email"
control={control}
defaultValue=""
render={({ field }) => (
<TextInput
{...field}
label={t("auth.common.inputs.email.label")}
fullWidth
placeholder={t("auth.common.inputs.email.placeholder")}
error={!!errors.email}
helperText={errors.email ? errors.email.message : ""}
/>
)}
/>
<Controller
name="password"
control={control}
defaultValue=""
render={({ field }) => (
<TextInput
{...field}
type="password"
label={t("auth.common.inputs.password.label")}
fullWidth
placeholder="••••••••••"
error={!!errors.password}
helperText={errors.password ? errors.password.message : ""}
/>
)}
/>
<Button
variant="contained"
loading={loading}
color="accent"
type="submit"
sx={{ width: "100%", alignSelf: "center", fontWeight: 700 }}
>
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>
</AuthBasePage>
);
};
+121 -113
View File
@@ -1,14 +1,17 @@
import { AuthBasePage } from "@/Components/v2/Auth";
import { TextInput } from "@/Components/v2/Inputs";
import { Button } from "@/Components/v2/Inputs";
import Typography from "@mui/material/Typography";
import Stack from "@mui/material/Stack";
import type { ApiResponse } from "@/Hooks/v2/UseApi";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useTranslation } from "react-i18next";
import { useForm, Controller } from "react-hook-form";
import { useTheme } from "@mui/material/styles";
import Typography from "@mui/material/Typography";
import { TextInput } from "@/Components/v2/Inputs/TextInput";
import Button from "@mui/material/Button";
import Stack from "@mui/material/Stack";
import { usePost } from "@/Hooks/v2/UseApi";
import { useNavigate } from "react-router";
const schema = z
.object({
@@ -30,14 +33,15 @@ type FormData = z.infer<typeof schema>;
const Register = () => {
const { t } = useTranslation();
const theme = useTheme();
const { post, loading, error } = usePost<FormData>("/auth/register");
const navigate = useNavigate();
const { post, loading, error } = usePost<FormData, ApiResponse>();
const {
handleSubmit,
control,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema), // ⬅️ connect Zod
resolver: zodResolver(schema),
defaultValues: {
email: "",
password: "",
@@ -45,124 +49,128 @@ const Register = () => {
});
const onSubmit = async (data: FormData) => {
const result = await post(data);
const result = await post("/auth/register", data);
if (result) {
console.log(result);
navigate("/v2/uptime");
} else {
console.error("Login failed:", error);
}
};
return (
<Stack
alignItems={"center"}
justifyContent={"center"}
minHeight="100vh"
<AuthBasePage
title={t("auth.registration.welcome")}
subtitle={t("auth.registration.heading.user")}
>
<Stack
component="form"
padding={theme.spacing(8)}
gap={theme.spacing(12)}
onSubmit={handleSubmit(onSubmit)}
maxWidth={400}
sx={{
width: {
sm: "80%",
md: "70%",
lg: "65%",
xl: "65%",
},
}}
alignItems={"center"}
width={"100%"}
>
<Controller
name="email"
control={control}
defaultValue=""
render={({ field }) => (
<TextInput
{...field}
label={t("auth.common.inputs.email.label")}
fullWidth
placeholder={t("auth.common.inputs.email.placeholder")}
error={!!errors.email}
helperText={errors.email ? errors.email.message : ""}
/>
)}
/>
<Controller
name="firstName"
control={control}
defaultValue=""
render={({ field }) => (
<TextInput
{...field}
label={t("auth.common.inputs.firstName.label")}
fullWidth
placeholder={t("auth.common.inputs.firstName.placeholder")}
error={!!errors.firstName}
helperText={errors.firstName ? errors.firstName.message : ""}
/>
)}
/>
<Controller
name="lastName"
control={control}
defaultValue=""
render={({ field }) => (
<TextInput
{...field}
label={t("auth.common.inputs.lastName.label")}
fullWidth
placeholder={t("auth.common.inputs.lastName.placeholder")}
error={!!errors.lastName}
helperText={errors.lastName ? errors.lastName.message : ""}
/>
)}
/>
<Controller
name="password"
control={control}
defaultValue=""
render={({ field }) => (
<TextInput
{...field}
type="password"
label={t("auth.common.inputs.password.label")}
fullWidth
placeholder="••••••••••"
error={!!errors.password}
helperText={errors.password ? errors.password.message : ""}
/>
)}
/>
<Controller
name="confirmPassword"
control={control}
defaultValue=""
render={({ field }) => (
<TextInput
{...field}
type="password"
label={t("auth.common.inputs.passwordConfirm.label")}
fullWidth
placeholder={t("auth.common.inputs.passwordConfirm.placeholder")}
error={!!errors.confirmPassword}
helperText={errors.confirmPassword ? errors.confirmPassword.message : ""}
/>
)}
/>
<Button
variant="contained"
loading={loading}
color="accent"
type="submit"
sx={{ width: "100%", alignSelf: "center", fontWeight: 700 }}
<Stack
component="form"
padding={theme.spacing(8)}
gap={theme.spacing(12)}
onSubmit={handleSubmit(onSubmit)}
maxWidth={400}
sx={{
width: {
sm: "80%",
md: "70%",
lg: "65%",
xl: "65%",
},
}}
>
Login
</Button>
{error && <Typography color="error">{error}</Typography>}
<Controller
name="email"
control={control}
defaultValue=""
render={({ field }) => (
<TextInput
{...field}
label={t("auth.common.inputs.email.label")}
fullWidth
placeholder={t("auth.common.inputs.email.placeholder")}
error={!!errors.email}
helperText={errors.email ? errors.email.message : ""}
/>
)}
/>
<Controller
name="firstName"
control={control}
defaultValue=""
render={({ field }) => (
<TextInput
{...field}
label={t("auth.common.inputs.firstName.label")}
fullWidth
placeholder={t("auth.common.inputs.firstName.placeholder")}
error={!!errors.firstName}
helperText={errors.firstName ? errors.firstName.message : ""}
/>
)}
/>
<Controller
name="lastName"
control={control}
defaultValue=""
render={({ field }) => (
<TextInput
{...field}
label={t("auth.common.inputs.lastName.label")}
fullWidth
placeholder={t("auth.common.inputs.lastName.placeholder")}
error={!!errors.lastName}
helperText={errors.lastName ? errors.lastName.message : ""}
/>
)}
/>
<Controller
name="password"
control={control}
defaultValue=""
render={({ field }) => (
<TextInput
{...field}
type="password"
label={t("auth.common.inputs.password.label")}
fullWidth
placeholder="••••••••••"
error={!!errors.password}
helperText={errors.password ? errors.password.message : ""}
/>
)}
/>
<Controller
name="confirmPassword"
control={control}
defaultValue=""
render={({ field }) => (
<TextInput
{...field}
type="password"
label={t("auth.common.inputs.passwordConfirm.label")}
fullWidth
placeholder={t("auth.common.inputs.passwordConfirm.placeholder")}
error={!!errors.confirmPassword}
helperText={errors.confirmPassword ? errors.confirmPassword.message : ""}
/>
)}
/>
<Button
variant="contained"
loading={loading}
color="accent"
type="submit"
sx={{ width: "100%", alignSelf: "center", fontWeight: 700 }}
>
Register
</Button>
{error && <Typography color="error">{error}</Typography>}
</Stack>
</Stack>
</Stack>
</AuthBasePage>
);
};
+2 -2
View File
@@ -44,7 +44,7 @@ const UptimeCreatePage = () => {
mode: "onChange",
});
const { response } = useGet<ApiResponse>("/notification-channels");
const { post, loading, error } = usePost<SubmitValues, ApiResponse>("/monitors");
const { post, loading, error } = usePost<SubmitValues>();
const selectedType = useWatch({
control,
name: "type",
@@ -58,7 +58,7 @@ const UptimeCreatePage = () => {
let interval = humanInterval(data.interval);
if (!interval) interval = 60000;
const submitData = { ...data, interval };
const result = await post(submitData);
const result = await post("/monitors", submitData);
if (result) {
console.log(result);
} else {