Authorization working

This commit is contained in:
2025-02-24 21:53:20 -05:00
parent 2186889b11
commit 5bb6e0a37f
37 changed files with 5723 additions and 0 deletions

10
src/App.css Normal file
View File

@@ -0,0 +1,10 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
@theme {
}

115
src/App.tsx Normal file
View File

@@ -0,0 +1,115 @@
import "@/App.css";
import { BrowserRouter, Route, Routes } from "react-router";
import NavBar from "./components/nav/NavBar";
import AdminPage from "./pages/protected/AdminPage";
import GamePage from "./pages/protected/GamePage";
import GamesPage from "./pages/protected/GamesPage";
import LogoutPage from "./pages/protected/LogoutPage";
import PersonPage from "./pages/protected/PersonPage";
import RaidGroupPage from "./pages/protected/RaidGroupPage";
import RaidGroupsPage from "./pages/protected/RaidGroupsPage";
import RaidInstancePage from "./pages/protected/RaidInstancePage";
import RaidLayoutPage from "./pages/protected/RaidLayoutPage";
import HomePage from "./pages/public/HomePage";
import LoginPage from "./pages/public/LoginPage";
import SignupPage from "./pages/public/SignupPage";
import { ProtectedRoute } from "./providers/AuthProvider";
const publicRoutes: { path: string; element: React.ReactElement; }[] = [
{
path: "/",
element: <HomePage/>
},
{
path: "/login",
element: <LoginPage/>
},
{
path: "/signup",
element: <SignupPage/>
}
];
const protectedRoutes: { path: string; element: React.ReactElement; }[] = [
{
path: "/logout",
element: <LogoutPage/>
},
{
path: "/admin",
element: <AdminPage/>
},
{
path: "/game",
element: <GamesPage/>
},
{
path: "/game/:gameId",
element: <GamePage/>
},
{
path: "/raidGroup",
element: <RaidGroupsPage/>
},
{
path: "/raidGroup/:raidGroupId",
element: <RaidGroupPage/>
},
{
path: "/raidGroup/:raidGroupId/person/:personId",
element: <PersonPage/>
},
{
path: "/raidGroup/:raidGroupId/raidLayout/:raidLayoutId",
element: <RaidLayoutPage/>
},
{
path: "/raidGroup/:raidGroupId/raidInstance",
element: <RaidInstancePage/>
},
{
path: "/raidGroup/:raidGroupId/raidInstance/:raidInstanceId",
element: <RaidInstancePage/>
}
];
export default function App(){
return (
<div
className="mt-20"
>
<BrowserRouter>
{/* Nav Bar */}
<NavBar/>
{/* Routing */}
<Routes>
{/* Public Routes */}
{
publicRoutes.map((route) => (
<Route
key={route.path}
path={route.path}
element={route.element}
/>
))
}
{/* Protected Routes */}
<Route element={<ProtectedRoute/>}>
{
protectedRoutes.map((route) => (
<Route
key={route.path}
path={route.path}
element={route.element}
/>
))
}
</Route>
</Routes>
</BrowserRouter>
</div>
);
}

View File

@@ -0,0 +1,18 @@
import clsx from "clsx";
import { ComponentProps } from "react";
export default function PrimaryButton(props: ComponentProps<"button">){
return (
<button
{...props}
className={clsx(
props.className,
"rounded-lg py-2 px-4",
"bg-blue-500 dark:bg-blue-600 text-white"
)}
>
{props.children}
</button>
);
}

View File

@@ -0,0 +1,63 @@
import clsx from "clsx";
import { ComponentProps } from "react";
interface PasswordInputProps extends ComponentProps<"input">{
id: string;
inputClasses?: string;
labelClasses?: string;
accepted?: boolean;
}
export default function PasswordInput(props: PasswordInputProps){
const { id, placeholder, inputClasses, labelClasses, accepted } = props;
return (
<div
className="flex flex-row justify-center w-full rounded-sm bg-inherit"
>
<div
className="relative bg-inherit w-full"
>
<input
{...props}
type="password"
className={clsx(
"peer bg-transparent w-full min-w-72 px-2 py-1 rounded-lg",
"ring-2 focus:ring-sky-600 placeholder-transparent outline-hidden",
inputClasses,
{
"ring-gray-500": accepted === undefined,
"ring-red-600": accepted === false,
"ring-green-600": accepted === true
}
)}
/>
<label
htmlFor={id}
id={`${id}Label`}
className={clsx(
"absolute cursor-text left-0 -top-3 mx-1 px-1",
"bg-white dark:bg-neutral-825 text-sm",
"peer-placeholder-shown:text-base peer-placeholder-shown:text-gray-500 peer-placeholder-shown:top-1 peer-focus:-top-3 peer-focus:text-sky-600 peer-focus:text-sm",
labelClasses,
{
"text-gray-500": accepted === undefined,
"text-red-600": accepted === false,
"text-green-600": accepted === true
}
)}
style={{transitionProperty: "top, font-size, line-height",
transitionTimingFunction: "cubic-bezier(0.4, 0, 0.2, 1)",
transitionDuration: "150ms"
}}
>
{placeholder}
</label>
</div>
</div>
);
}

View File

@@ -0,0 +1,64 @@
import clsx from "clsx";
import { ComponentProps } from "react";
interface TextInputProps extends ComponentProps<"input">{
id: string;
inputClasses?: string;
labelClasses?: string;
accepted?: boolean;
}
export default function TextInput(props: TextInputProps){
const { id, placeholder, name, inputClasses, labelClasses, accepted } = props;
return (
<div
className="flex flex-row justify-center w-full rounded-sm bg-inherit"
>
<div
className="relative bg-inherit w-full"
>
<input
{...props}
type="text"
className={clsx(
"peer bg-transparent w-full min-w-72 px-2 py-1 rounded-lg",
"ring-2 focus:ring-sky-600 placeholder-transparent outline-hidden",
inputClasses,
{
"ring-gray-500": accepted === undefined,
"ring-red-600": accepted === false,
"ring-green-600": accepted === true
}
)}
name={name}
/>
<label
htmlFor={id}
id={`${id}Label`}
className={clsx(
"absolute cursor-text left-0 -top-3 mx-1 px-1",
"bg-white dark:bg-neutral-825 text-sm",
"peer-placeholder-shown:text-base peer-placeholder-shown:text-gray-500 peer-placeholder-shown:top-1 peer-focus:-top-3 peer-focus:text-sky-600 peer-focus:text-sm",
labelClasses,
{
"text-gray-500": accepted === undefined,
"text-red-600": accepted === false,
"text-green-600": accepted === true
}
)}
style={{transitionProperty: "top, font-size, line-height",
transitionTimingFunction: "cubic-bezier(0.4, 0, 0.2, 1)",
transitionDuration: "150ms"
}}
>
{placeholder}
</label>
</div>
</div>
);
}

View File

@@ -0,0 +1,32 @@
import { useTheme } from "@/providers/ThemeProvider";
import { BsLightbulb, BsLightbulbFill } from "react-icons/bs";
export default function DarkModeToggle(){
const { theme, setTheme } = useTheme();
return (
<div>
<input
id="darkModeCheckbox"
type="checkbox"
className="peer hidden"
onChange={() => setTheme(theme === "dark" ? "light" : "dark")}
defaultChecked={theme === "dark"}
/>
<label
htmlFor="darkModeCheckbox"
className="block peer-checked:hidden"
>
<BsLightbulbFill/>
</label>
<label
htmlFor="darkModeCheckbox"
className="hidden peer-checked:block"
>
<BsLightbulb/>
</label>
</div>
);
}

View File

@@ -0,0 +1,56 @@
import clsx from "clsx";
import { BsList } from "react-icons/bs";
import { Link } from "react-router";
import DarkModeToggle from "./DarkModeToggle";
import ProtectedNavLinks from "./ProtectedNavLinks";
import PublicNavLinks from "./PublicNavLinks";
import raidBuilderIcon from "/raidBuilderIcon.svg";
export default function NavBar(){
return (
<nav
className={clsx(
"fixed w-full top-0 left-0 border-b-2 z-40",
"bg-gray-700 border-gray-600 dark:bg-zinc-900 dark:border-neutral-850 text-white"
)}
>
<div
className="max-w-(--breakpoint-xl) flex flex-nowrap flex-row items-center justify-between mx-auto p-4"
>
<Link
to="/"
className="flex items-center space-x-3 rtl:space-x-reverse"
>
<img
src={raidBuilderIcon}
alt="Raid Builder Logo"
width={30}
height={30}
fetchPriority="high"
/>
<span
className="self-center text-2xl font-semibold whitespace-nowrap"
>
Raid Builder
</span>
</Link>
<div
className="peer md:hidden text-3xl"
>
<BsList/>
</div>
<div
className={clsx(
"relative top-0 left-0 flex flex-row items-center rounded-lg space-x-4",
"bg-gray-700 dark:bg-zinc-900"
)}
>
<PublicNavLinks/>
<ProtectedNavLinks/>
<DarkModeToggle/>
</div>
</div>
</nav>
);
}

View File

@@ -0,0 +1,48 @@
import { useAuth } from "@/providers/AuthProvider";
import { NavLink } from "react-router";
const protectedLinks = [
{
name: "Games",
path: "/game"
},
{
name: "Groups",
path: "/raidGroup"
},
{
name: "Admin",
path: "/admin"
},
{
name: "Logout",
path: "/logout"
}
];
export default function ProtectedNavLinks(){
const { jwt } = useAuth();
if(!jwt){
return <></>;
}
return (
<>
{
protectedLinks.map((link) => (
<NavLink
key={link.name}
to={link.path}
>
{link.name}
</NavLink>
))
}
</>
);
}

View File

@@ -0,0 +1,39 @@
import { useAuth } from "@/providers/AuthProvider";
import { NavLink } from "react-router";
const publicLinks = [
{
name: "Home",
path: "/"
}
];
export default function PublicNavLinks(){
const { jwt } = useAuth();
return (
<>
{
publicLinks.map((link) => (
<NavLink
key={link.name}
to={link.path}
>
{link.name}
</NavLink>
))
}
{
!jwt &&
<NavLink
to="/login"
>
Login
</NavLink>
}
</>
);
}

3
src/hooks/AuthHooks.ts Normal file
View File

@@ -0,0 +1,3 @@

54
src/index.css Normal file
View File

@@ -0,0 +1,54 @@
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--color-neutral-825: oklch(0.253 0 0);
--color-neutral-850: oklch(0.237 0 0);
}
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: #213547;
background-color: #ffffff;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
:root.dark {
color: rgba(255, 255, 255, 0.87);
background-color: var(--color-neutral-825);
}
a {
text-decoration: inherit;
}
a.active {
color: var(--color-blue-400);
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
font-family: inherit;
cursor: pointer;
}

23
src/main.tsx Normal file
View File

@@ -0,0 +1,23 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
import './index.css'
import { AuthProvider } from './providers/AuthProvider.tsx'
import { ThemeProvider } from './providers/ThemeProvider.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ThemeProvider
defaultTheme="dark"
storageKey="vite-ui-theme"
>
<AuthProvider
jwtStorageKey="jwt"
refreshTokenStorageKey="refreshToken"
>
<App />
</AuthProvider>
</ThemeProvider>
</StrictMode>
);

View File

@@ -0,0 +1,10 @@
export default function AdminPage(){
//TODO:
return (
<div>
Admin Page
</div>
);
}

View File

@@ -0,0 +1,10 @@
export default function GamePage(){
//TODO:
return (
<div>
Game Page
</div>
);
}

View File

@@ -0,0 +1,10 @@
export default function GamesPage(){
//TODO:
return (
<div>
Games Page
</div>
);
}

View File

@@ -0,0 +1,32 @@
import { useAuth } from "@/providers/AuthProvider";
import { api } from "@/util/AxiosUtil";
import { useNavigate } from "react-router";
export default function LogoutPage(){
const { setJwt } = useAuth();
const navigate = useNavigate();
const logout = async () => {
const response = await api.get("/auth/logout");
if(response.status === 200){
setJwt(null);
navigate("/");
}
else{
//TODO: Handle error
}
}
return (
<div>
<button
onClick={logout}
>
Logout
</button>
</div>
);
}

View File

@@ -0,0 +1,10 @@
export default function PersonPage(){
//TODO:
return (
<div>
Person Page
</div>
);
}

View File

@@ -0,0 +1,10 @@
export default function RaidGroupPage(){
//TODO:
return (
<div>
Raid Group Page
</div>
);
}

View File

@@ -0,0 +1,10 @@
export default function RaidGroupsPage(){
//TODO:
return (
<div>
Raid Groups Page
</div>
);
}

View File

@@ -0,0 +1,10 @@
export default function RaidInstancePage(){
//TODO:
return (
<div>
Raid Instance Page
</div>
);
}

View File

@@ -0,0 +1,10 @@
export default function RaidLayoutPage(){
//TODO:
return (
<div>
Raid Layout Page
</div>
);
}

View File

@@ -0,0 +1,7 @@
export default function HomePage(){
return (
<main>
Home Page
</main>
);
}

View File

@@ -0,0 +1,65 @@
import PrimaryButton from "@/components/button/PrimaryButton";
import PasswordInput from "@/components/input/PasswordInput";
import TextInput from "@/components/input/TextInput";
import { useAuth } from "@/providers/AuthProvider";
import { Navigate, useNavigate } from "react-router";
export default function LoginPage(){
const { jwt, setJwt } = useAuth();
const navigate = useNavigate();
const login = async (formData: FormData) => {
const username = formData.get("username") as string;
const password = formData.get("password") as string;
const response = await fetch(`${import.meta.env.VITE_API_URL}/auth/token`, {
method: "GET",
headers: {
Authorization: "Basic " + btoa(`${username}:${password}`)
},
credentials: "include"
});
if(response.status === 200){
const json = await response.json();
setJwt(json.token);
navigate("/raidGroup");
}
else{
//TODO: Handle error
}
}
if(jwt){
return <Navigate to="/"/>;
}
return (
<form
action={login}
className="flex flex-col justify-center space-y-8"
>
<TextInput
id="username"
name="username"
placeholder="Username"
/>
<PasswordInput
id="password"
name="password"
placeholder="Password"
/>
<PrimaryButton
className="mx-auto"
type="submit"
>
Login
</PrimaryButton>
</form>
);
}

View File

@@ -0,0 +1,7 @@
export default function SignupPage(){
return (
<div>
Signup Page
</div>
);
}

View File

@@ -0,0 +1,112 @@
import { api } from "@/util/AxiosUtil";
import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useState } from "react";
import { Navigate, Outlet } from "react-router";
type AuthProviderProps = {
children: React.ReactNode;
jwtStorageKey?: string;
refreshTokenStorageKey?: string;
}
type AuthProviderState = {
jwt: string | null;
setJwt: (token: string | null) => void;
expiration: Date | null;
setExpiration: (expiration: Date | null) => void;
}
const initialState: AuthProviderState = {
jwt: null,
setJwt: () => null,
expiration: null,
setExpiration: () => null
}
const AuthContext = createContext<AuthProviderState>(initialState);
export function AuthProvider({
children
}: AuthProviderProps){
const [ jwt, setJwt ] = useState<string | null>(null);
const [ expiration, setExpiration ] = useState<Date | null>(null)
const fetchToken = useCallback(async () => {
try{
const response = await api.get("/auth/refresh");
//If the token is retrieved
if((response.status === 200) && (!response.data.errors)){
setJwt(response.data.token);
setExpiration(new Date(atob(response.data.token.split(".")[1])));
}
//If the token cannot be retrieved
else{
setJwt(null);
setExpiration(null);
}
}
//If the token cannot be retrieved
catch{
setJwt(null);
setExpiration(null);
}
}, [ setJwt, setExpiration ]);
//Add the token to all queries
useLayoutEffect(() => {
if((expiration) && (expiration < new Date())){
fetchToken();
}
const authInterceptor = api.interceptors.request.use(config => {
config.headers.Authorization = jwt ? `Bearer ${jwt}` : config.headers.Authorization;
return config;
});
return () => { api.interceptors.request.eject(authInterceptor); };
}, [ jwt, expiration, fetchToken ]);
//Try to get the token on page load
useEffect(() => {
fetchToken();
}, [ fetchToken ]);
const currentTokens = useMemo(() => ({
jwt,
setJwt,
expiration,
setExpiration
}), [ jwt, setJwt, expiration, setExpiration ]);
return (
<AuthContext.Provider value={currentTokens}>
{children}
</AuthContext.Provider>
);
}
export function ProtectedRoute(){
const { jwt } = useAuth();
if(!jwt){
return <Navigate to="/login"/>;
}
return <Outlet/>;
}
export const useAuth = () => {
const context = useContext(AuthContext);
if(context === undefined){
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}

View File

@@ -0,0 +1,70 @@
import { createContext, useContext, useEffect, useMemo, useState } from "react";
type Theme = "dark" | "light" | "system";
type ThemeProviderProps = {
children: React.ReactNode;
defaultTheme?: Theme;
storageKey?: string;
}
type ThemeProviderState = {
theme: Theme;
setTheme: (theme: Theme) => void;
}
const initialState: ThemeProviderState = {
theme: "system",
setTheme: () => null
}
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "vite-ui-theme"
}: ThemeProviderProps){
const [ theme, setTheme ] = useState<Theme>(localStorage.getItem(storageKey) as Theme || defaultTheme);
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove("light", "dark");
if(theme === "system"){
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
root.classList.add(systemTheme);
}
else{
root.classList.add(theme);
}
}, [ theme ]);
const currentTheme = useMemo(() => ({
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme);
setTheme(theme);
}
}), [ theme, setTheme, storageKey ]);
return (
<ThemeProviderContext.Provider value={currentTheme}>
{children}
</ThemeProviderContext.Provider>
);
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext);
if(context === undefined){
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}

8
src/util/AxiosUtil.ts Normal file
View File

@@ -0,0 +1,8 @@
import axios from "axios";
export const api = axios.create({
baseURL: `${import.meta.env.VITE_API_URL}`,
timeout: 10000,
withCredentials: true
});

10
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}