Compare commits

...

4 Commits

Author SHA1 Message Date
3cbb51f8ee feat: add auth related utilities 2024-02-22 14:33:41 +08:00
f48aee80a5 fix: skeleton page on dark mode 2024-02-22 14:32:00 +08:00
56e1d659e2 feat: add login page 2024-02-22 14:22:36 +08:00
03ebc48fbf chore: adjust header and footer style 2024-02-22 12:12:50 +08:00
13 changed files with 326 additions and 25 deletions

View File

@ -14,7 +14,7 @@ const baseQuery = fetchBaseQuery({
});
export const api = createApi({
baseQuery: retry(baseQuery, { maxRetries: 6 }),
baseQuery: retry(baseQuery, { maxRetries: 2 }),
tagTypes: ["User", "Status", "Submission"],
endpoints: () => ({}),
});

View File

@ -6,8 +6,9 @@ import { userApi } from "./services/user";
import { counterSlice } from "../features/counter/counterSlice";
import { quotesApiSlice } from "../features/quotes/quotesApiSlice";
import { authSlice } from "../features/auth/authSlice";
const dataSlices = [counterSlice];
const dataSlices = [counterSlice, authSlice];
const middlewareSlices = [quotesApiSlice, userApi];
const slices = [...dataSlices, ...middlewareSlices];

View File

@ -2,10 +2,10 @@ import { Box, Flex, Text, useColorModeValue } from "@chakra-ui/react";
export default function Footer() {
return (
<Box w="100%" bg={useColorModeValue("gray.50", "gray.900")} color={useColorModeValue("gray.700", "gray.200")}>
<Box as="footer" role="contentinfo" w="100%" bg={useColorModeValue("gray.100", "gray.900")}>
<Flex
align="center"
pt="8"
py={8}
_before={{
content: '""',
borderBottom: "1px solid",

View File

@ -4,7 +4,7 @@ import {
Button,
Center,
Flex,
Link as ChakraLink,
Link,
Menu,
MenuButton,
MenuDivider,
@ -21,14 +21,14 @@ import { MoonIcon, SunIcon } from "@chakra-ui/icons";
export const Header = () => {
const { colorMode, toggleColorMode } = useColorMode();
return (
<Box width="100%" bg={useColorModeValue("gray.100", "gray.900")} px={4}>
<Box as="nav" role="navigation" w="100%" bg={useColorModeValue("gray.100", "gray.900")} px={4}>
<Flex h={16} alignItems="center" justifyContent="space-between">
<Box>
<ChakraLink as={ReactRouterLink} to="/home">
<Link as={ReactRouterLink} to="/home">
<Text as="b" fontSize="lg">
Woo Online Judge
</Text>
</ChakraLink>
</Link>
</Box>
<Flex alignItems="center">

View File

@ -0,0 +1,52 @@
import type { DetailedHTMLProps, InputHTMLAttributes } from "react";
import { useRef } from "react";
import type { InputProps } from "@chakra-ui/react";
import {
FormControl,
FormLabel,
IconButton,
Input,
InputGroup,
InputRightElement,
useDisclosure,
} from "@chakra-ui/react";
import { HiEye, HiEyeOff } from "react-icons/hi";
export const PasswordField = (
props: InputProps & DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
) => {
const { isOpen, onToggle } = useDisclosure();
const inputRef = useRef<HTMLInputElement>(null);
const onClickReveal = () => {
onToggle();
if (inputRef.current) {
inputRef.current.focus({ preventScroll: true });
}
};
return (
<FormControl>
<FormLabel htmlFor="password">Password</FormLabel>
<InputGroup>
<InputRightElement>
<IconButton
variant="text"
aria-label={isOpen ? "Mask password" : "Reveal password"}
icon={isOpen ? <HiEyeOff /> : <HiEye />}
onClick={onClickReveal}
/>
</InputRightElement>
<Input
id="password"
ref={inputRef}
name="password"
type={isOpen ? "text" : "password"}
autoComplete="current-password"
required
{...props}
/>
</InputGroup>
</FormControl>
);
};

View File

@ -0,0 +1,29 @@
import type React from "react";
import { Link as ReactRouterLink } from "react-router-dom";
import { Box, Heading, Link, Text } from "@chakra-ui/react";
import { WarningTwoIcon } from "@chakra-ui/icons";
import { useAuth } from "../hooks/useAuth";
export const RequireAuth = ({ children }: { children: React.ReactNode }) => {
const auth = useAuth();
if (!auth.token) {
return (
<Box textAlign="center" py={10} px={6}>
<WarningTwoIcon boxSize={"50px"} color={"orange.300"} />
<Heading as="h2" size="lg" mt={6} mb={2}>
Unauthorized :(
</Heading>
<Text color={"gray.500"}>
<Link as={ReactRouterLink} to="/login" color="teal">
Login
</Link>{" "}
to gain access
</Text>
</Box>
);
}
return children;
};

View File

@ -0,0 +1,53 @@
import { createSlice } from "@reduxjs/toolkit";
import type { UserProfile } from "../../app/services/user";
import { userApi } from "../../app/services/user";
import type { RootState } from "../../app/store";
type AuthState = {
profile: UserProfile | null;
token: string | null;
};
const initialState: AuthState = {
profile: null,
token: null,
};
export const authSlice = createSlice({
name: "auth",
initialState,
reducers: {
load: (_state) => {
const token = localStorage.getItem("token");
return { ...initialState, token: token };
},
},
extraReducers: (builder) => {
builder
.addMatcher(userApi.endpoints.login.matchFulfilled, (_state, action) => {
// Login Success
const { token } = action.payload.body;
localStorage.setItem("token", token);
return { profile: null, token: token };
})
.addMatcher(userApi.endpoints.profile.matchFulfilled, (state, action) => {
// Profile Success
return { ...state, profile: action.payload.body };
})
.addMatcher(userApi.endpoints.logout.matchFulfilled, (_state, _action) => {
// Logout Success
localStorage.removeItem("token");
return initialState;
})
.addMatcher(userApi.endpoints.login.matchRejected, (_state, action) => {
// Login Failed
console.error("Login Failed", action.payload);
localStorage.removeItem("token");
return initialState;
});
},
});
export const { load } = authSlice.actions;
export const selectCurrentUser = (state: RootState) => state.auth;

8
src/hooks/useAuth.ts Normal file
View File

@ -0,0 +1,8 @@
import { useMemo } from "react";
import { selectCurrentUser } from "../features/auth/authSlice";
import { useAppSelector } from "./store";
export const useAuth = () => {
const auth = useAppSelector(selectCurrentUser);
return useMemo(() => ({ token: auth.token, profile: auth.profile }), [auth]);
};

View File

@ -1,9 +1,13 @@
import { Container, Heading } from "@chakra-ui/react";
import { Heading, Link } from "@chakra-ui/react";
import { Link as ReactRouterLink } from "react-router-dom";
export const HomePage = () => {
export default function HomePage() {
return (
<Container maxW="container.lg">
<>
<Heading>Hello World!</Heading>
</Container>
<Link as={ReactRouterLink} to="/test/protected">
test protected middleware
</Link>
</>
);
};
}

147
src/pages/LoginPage.tsx Normal file
View File

@ -0,0 +1,147 @@
import type React from "react";
import { useState } from "react";
import {
Box,
Button,
Center,
Container,
Divider,
FormControl,
FormLabel,
Heading,
HStack,
Input,
Link,
Stack,
Text,
useColorModeValue,
useToast,
} from "@chakra-ui/react";
import { Link as ReactRouterLink, useNavigate } from "react-router-dom";
import { RiLoginCircleLine } from "react-icons/ri";
import KeyCloakSVG from "../resources/keycloak.svg";
import { PasswordField } from "../components/PasswordField";
import type { UserRequest } from "../app/services/user";
import { useLoginMutation } from "../app/services/user";
import type { Wrap } from "../app/services/base";
export default function LoginPage() {
const toast = useToast();
const navigate = useNavigate();
const bg = useColorModeValue("gray.50", "gray.900");
const fg = "teal";
const fg2 = "gray";
const [login, { isLoading }] = useLoginMutation();
const [formState, setFormState] = useState<UserRequest>({
email: "",
password: "",
});
const handleChange = ({ target: { name, value } }: React.ChangeEvent<HTMLInputElement>) =>
setFormState((prev) => ({ ...prev, [name]: value }));
const validate = () => {
const { email, password } = formState;
return email && password && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
};
const doLogin = async () => {
if (!validate()) {
toast({
title: "Invalid Input",
description: "Please fill in all fields and make sure your email is valid.",
status: "error",
duration: 3000,
isClosable: true,
});
return;
}
try {
// authSlice will automatically handle the response
const user = await login(formState).unwrap();
if (user.code === 0) navigate("/");
} catch (err) {
const errTyped = err as { status: number | string; data: Wrap<void> };
toast({
title: "Error",
description: errTyped.status === 200 ? errTyped.data.msg : "Oh no, there was an error!",
status: "error",
duration: 3000,
isClosable: true,
});
}
};
const formArea = (
<form>
<Stack spacing="5">
<FormControl>
<FormLabel htmlFor="email">Email</FormLabel>
<Input id="email" name="email" type="email" isDisabled={isLoading} onChange={handleChange} />
</FormControl>
<PasswordField isDisabled={isLoading} onChange={handleChange} />
{/* eslint-disable-next-line @typescript-eslint/no-misused-promises */}
<Button colorScheme={fg} isLoading={isLoading} onClick={doLogin}>
Sign in
</Button>
</Stack>
</form>
);
const ssoArea = (
<Stack spacing="6">
<HStack>
<Divider />
<Text textStyle="sm" whiteSpace="nowrap" color={fg}>
or continue with
</Text>
<Divider />
</HStack>
<Button colorScheme={fg2}>
<img src={KeyCloakSVG} alt="KeyCloak" width="24" height="24" />
<Text mx={2}>Sign in with SSO</Text>
</Button>
</Stack>
);
const header = (
<Stack spacing="6">
<Center>
<RiLoginCircleLine size={64} />
</Center>
<Stack spacing={{ base: "2", md: "3" }} textAlign="center">
<Heading>Log in to your account</Heading>
<Text>
Don&apos;t have an account?{" "}
<Link as={ReactRouterLink} to="/register" color={fg}>
Sign up
</Link>
</Text>
</Stack>
</Stack>
);
return (
<Container maxW="lg" py={12} px={{ base: "0", sm: "8" }}>
<Stack spacing="4">
{header}
<Box
py={{ base: "0", sm: "8" }}
px={{ base: "4", sm: "10" }}
bg={{ base: "transparent", sm: bg }}
boxShadow={{ base: "none", sm: "xl" }}
borderRadius={{ base: "none", sm: "xl" }}
>
<Stack spacing="6">
{formArea}
{ssoArea}
</Stack>
</Box>
</Stack>
</Container>
);
}

View File

@ -1,12 +1,12 @@
import React from "react";
import { Box, Flex, SkeletonCircle, SkeletonText } from "@chakra-ui/react";
import { Box, Container, Flex, SkeletonCircle, SkeletonText } from "@chakra-ui/react";
import { Outlet } from "react-router-dom";
import Footer from "../components/Footer";
import { Header } from "../components/Header";
const SkeletonPage = () => (
<Box padding="6" boxShadow="lg" bg="white">
<Box padding="6" boxShadow="lg" color="gray">
<SkeletonCircle size="10" />
<SkeletonText mt="4" noOfLines={6} spacing="4" skeletonHeight="2" />
</Box>
@ -16,11 +16,11 @@ export const Root = () => (
<Flex direction="column" align="center" maxW={{ xl: "1200px" }} m="0 auto">
<Header />
<Box width="100%" flex="1">
<Container w="100%" flex="1" maxW="container.lg" py={2}>
<React.Suspense fallback={<SkeletonPage />}>
<Outlet />
</React.Suspense>
</Box>
</Container>
<Footer />
</Flex>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<circle cx="512" cy="512" r="512" style="fill:#008aaa"/>
<path d="M786.2 395.5h-80.6c-1.5 0-3-.8-3.7-2.1l-64.7-112.2c-.8-1.3-2.2-2.1-3.8-2.1h-264c-1.5 0-3 .8-3.7 2.1l-67.3 116.4-64.8 112.2c-.7 1.3-.7 2.9 0 4.3l64.8 112.2 67.2 116.5c.7 1.3 2.2 2.2 3.7 2.1h264.1c1.5 0 3-.8 3.8-2.1L702 630.6c.7-1.3 2.2-2.2 3.7-2.1h80.6c2.7 0 4.8-2.2 4.8-4.8V400.4c-.1-2.7-2.3-4.9-4.9-4.9zM477.5 630.6l-20.3 35c-.3.5-.8 1-1.3 1.3-.6.3-1.2.5-1.9.5h-40.3c-1.4 0-2.7-.7-3.3-2l-60.1-104.3-5.9-10.3-21.6-36.9c-.3-.5-.5-1.1-.4-1.8 0-.6.2-1.3.5-1.8l21.7-37.6 65.9-114c.7-1.2 2-2 3.3-2H454c.7 0 1.4.2 2.1.5.5.3 1 .7 1.3 1.3l20.3 35.2c.6 1.2.5 2.7-.2 3.8l-65.1 112.8c-.3.5-.4 1.1-.4 1.6 0 .6.2 1.1.4 1.6l65.1 112.7c.9 1.5.8 3.1 0 4.4zm202.1-116.7L658 550.8l-5.9 10.3L592 665.4c-.7 1.2-1.9 2-3.3 2h-40.3c-.7 0-1.3-.2-1.9-.5-.5-.3-1-.7-1.3-1.3l-20.3-35c-.9-1.3-.9-2.9-.1-4.2l65.1-112.7c.3-.5.4-1.1.4-1.6 0-.6-.2-1.1-.4-1.6l-65.1-112.8c-.7-1.2-.8-2.6-.2-3.8l20.3-35.2c.3-.5.8-1 1.3-1.3.6-.4 1.3-.5 2.1-.5h40.4c1.4 0 2.7.7 3.3 2l65.9 114 21.7 37.6c.3.6.5 1.2.5 1.8 0 .4-.2 1-.5 1.6z" style="fill:#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1,8 +1,12 @@
import { lazy } from "react";
import type { RouteObject } from "react-router-dom";
import { Root } from "./pages/Root";
import { ErrorPage } from "./pages/ErrorPage";
import { HomePage } from "./pages/HomePage";
import { RequireAuth } from "./components/RequireAuth";
const HomePage = lazy(() => import("./pages/HomePage"));
const LoginPage = lazy(() => import("./pages/LoginPage"));
export const router: RouteObject[] = [
{
@ -10,14 +14,12 @@ export const router: RouteObject[] = [
element: <Root />,
errorElement: <ErrorPage />,
children: [
{ index: true, element: <HomePage /> },
{ path: "home", element: <HomePage /> },
{ path: "login", element: <LoginPage /> },
{
errorElement: <ErrorPage />,
children: [
{
index: true,
element: <HomePage />,
},
],
path: "/test",
children: [{ path: "protected", element: <RequireAuth>Protected</RequireAuth> }],
},
],
},