Compare commits
4 Commits
4876decb26
...
3cbb51f8ee
Author | SHA1 | Date | |
---|---|---|---|
3cbb51f8ee | |||
f48aee80a5 | |||
56e1d659e2 | |||
03ebc48fbf |
@ -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: () => ({}),
|
||||
});
|
||||
|
@ -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];
|
||||
|
||||
|
@ -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",
|
||||
|
@ -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">
|
||||
|
52
src/components/PasswordField.tsx
Normal file
52
src/components/PasswordField.tsx
Normal 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>
|
||||
);
|
||||
};
|
29
src/components/RequireAuth.tsx
Normal file
29
src/components/RequireAuth.tsx
Normal 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;
|
||||
};
|
53
src/features/auth/authSlice.ts
Normal file
53
src/features/auth/authSlice.ts
Normal 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
8
src/hooks/useAuth.ts
Normal 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]);
|
||||
};
|
@ -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
147
src/pages/LoginPage.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
|
5
src/resources/keycloak.svg
Normal file
5
src/resources/keycloak.svg
Normal 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 |
@ -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> }],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user