Skip to content

Commit

Permalink
feat: auth two electric boogaloo (#16)
Browse files Browse the repository at this point in the history
* feat: auth middleware

* feat: flag middleware behind prod node_env

* chore: fix comment

* fix: misc.

* refactor: misc.

* feat: implement verifyRoute middleware

* feat: role context, protected routes by route

* feat: simplify App

* feat: add example usage of protected route

* feat: better validation for signup/login

* docs: misc.

* feat: create db user on signin/login

* feat: extract redirect handling to hook

* feat: use backend context in role context
  • Loading branch information
KevinWu098 authored Sep 2, 2024
1 parent 10a24d5 commit e3ff3f9
Show file tree
Hide file tree
Showing 23 changed files with 476 additions and 227 deletions.
77 changes: 40 additions & 37 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,53 +1,56 @@
import { Flex } from "@chakra-ui/react";

import { CookiesProvider } from "react-cookie";
import { Route, BrowserRouter as Router, Routes } from "react-router-dom";

import { Admin } from "./components/admin/Admin";
import { CatchAll } from "./components/CatchAll";
import { Dashboard } from "./components/dashboard/Dashboard";
import { Login } from "./components/login/Login";
import { ProtectedRoute } from "./components/ProtectedRoute";
import { Signup } from "./components/signup/Signup";
import { AuthProvider } from "./contexts/AuthContext";
import { BackendProvider } from "./contexts/BackendContext";
import { RoleProvider } from "./contexts/RoleContext";

const App = () => {
return (
<BackendProvider>
<AuthProvider>
<Flex
sx={{
flexDirection: "column",
backgroundColor: "#F9F8F7",
padding: 4,
minHeight: "100vh",
flexGrow: 1,
}}
>
<Router>
<Routes>
<Route
path="/login"
element={<Login />}
/>
<Route
path="/signup"
element={<Signup />}
/>
<Route
path="/dashboard"
element={<ProtectedRoute element={<Dashboard />} />}
/>
<CookiesProvider>
<BackendProvider>
<AuthProvider>
<RoleProvider>
<Router>
<Routes>
<Route
path="/login"
element={<Login />}
/>
<Route
path="/signup"
element={<Signup />}
/>
<Route
path="/dashboard"
element={<ProtectedRoute element={<Dashboard />} />}
/>
<Route
path="/admin"
element={
<ProtectedRoute
element={<Admin />}
allowedRoles={["admin"]}
/>
}
/>

{/* Catch-all route */}
<Route
path="*"
element={<ProtectedRoute element={<CatchAll />} />}
/>
</Routes>
</Router>
</Flex>
</AuthProvider>
</BackendProvider>
<Route
path="*"
element={<ProtectedRoute element={<CatchAll />} />}
/>
</Routes>
</Router>
</RoleProvider>
</AuthProvider>
</BackendProvider>
</CookiesProvider>
);
};

Expand Down
38 changes: 25 additions & 13 deletions client/src/components/ProtectedRoute.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,36 @@
// import { useEffect, useState } from "react";

import { Navigate } from "react-router-dom";

import { useAuthContext } from "../contexts/hooks/useAuthContext";
import { useRoleContext } from "../contexts/hooks/useRoleContext";

interface ProtectedRouteProps {
element: JSX.Element;
allowedRoles?: string | string[];
}

export const ProtectedRoute = ({ element }: ProtectedRouteProps) => {
export const ProtectedRoute = ({
element,
allowedRoles = [],
}: ProtectedRouteProps) => {
const { currentUser } = useAuthContext();
// const [isLoading, setIsLoading] = useState(true);

// useEffect(() => {
// setIsLoading(false);
// }, []);

// if (isLoading) {
// return <h1>Loading...</h1>;
// }
const { role } = useRoleContext();

return currentUser ? element : <Navigate to={"/login"} />;
const roles = Array.isArray(allowedRoles) ? allowedRoles : [allowedRoles];
const isValidRole = getIsValidRole(roles, role);
return currentUser && isValidRole ? element : <Navigate to={"/login"} />;
};

/**
* Helper function for determining if a user may access a route based on their role.
* If no allowed roles are specified, or if the user is an admin, they are authorized. Otherwise, their role must be within the list of allowed roles.
*
* @param roles a list of roles which may access this route
* @param role the current user's role
*/
function getIsValidRole(roles: string[], role: string | undefined) {
return (
roles.length === 0 ||
(role !== undefined && roles.includes(role)) ||
role === "admin"
);
}
9 changes: 9 additions & 0 deletions client/src/components/admin/Admin.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Box, Text } from "@chakra-ui/react";

export const Admin = () => {
return (
<Box>
<Text>This is a page only admins are able to see!</Text>
</Box>
);
};
20 changes: 17 additions & 3 deletions client/src/components/dashboard/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
TableContainer,
Tbody,
Td,
Text,
Th,
Thead,
Tr,
Expand All @@ -16,11 +17,14 @@ import {

import { useAuthContext } from "../../contexts/hooks/useAuthContext";
import { useBackendContext } from "../../contexts/hooks/useBackendContext";
import { User } from "../../types/users";
import { useRoleContext } from "../../contexts/hooks/useRoleContext";
import { User } from "../../types/user";
import { RoleSelect } from "./RoleSelect";

export const Dashboard = () => {
const { logout } = useAuthContext();
const { logout, currentUser } = useAuthContext();
const { backend } = useBackendContext();
const { role } = useRoleContext();

const [users, setUsers] = useState<User[] | undefined>();

Expand All @@ -44,7 +48,10 @@ export const Dashboard = () => {
>
<Heading>Dashboard</Heading>

<Button onClick={logout}>Sign out</Button>
<VStack>
<Text> Signed in as {currentUser?.email}</Text>
<Button onClick={logout}>Sign out</Button>
</VStack>

<TableContainer
sx={{
Expand All @@ -58,6 +65,7 @@ export const Dashboard = () => {
<Th>Id</Th>
<Th>Email</Th>
<Th>FirebaseUid</Th>
<Th>Role</Th>
</Tr>
</Thead>
<Tbody>
Expand All @@ -67,6 +75,12 @@ export const Dashboard = () => {
<Td>{user.id}</Td>
<Td>{user.email}</Td>
<Td>{user.firebaseUid}</Td>
<Td>
<RoleSelect
user={user}
disabled={role !== "admin"}
/>
</Td>
</Tr>
))
: null}
Expand Down
70 changes: 70 additions & 0 deletions client/src/components/dashboard/RoleSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { ChangeEvent, useCallback, useState } from "react";

import { Select, Spinner, useToast } from "@chakra-ui/react";

import { useBackendContext } from "../../contexts/hooks/useBackendContext";
import { User } from "../../types/user";

interface RoleSelectProps {
user: User;
disabled?: boolean;
}

export const RoleSelect = ({ user, disabled = true }: RoleSelectProps) => {
const { backend } = useBackendContext();
const toast = useToast();

const [role, setRole] = useState(user.role);
const [loading, setLoading] = useState(false);

const handleChangeRole = useCallback(
async (e: ChangeEvent<HTMLSelectElement>) => {
const previousRole = role;
const updatedRole = e.currentTarget.value;
setLoading(true);

try {
await backend.put("/users/update/set-role", {
role: updatedRole,
firebaseUid: user.firebaseUid,
});

if (updatedRole !== "user" && updatedRole !== "admin") {
throw Error("Role is not valid");
}

setRole(updatedRole);

toast({
title: "Role Updated",
description: `Updated role from ${previousRole} to ${updatedRole}`,
status: "success",
});
} catch (error) {
console.error("Error updating user role:", error);

toast({
title: "An Error Occurred",
description: `Role was not updated`,
status: "error",
});
} finally {
setLoading(false);
}
},
[backend, role, toast, user.firebaseUid]
);

return (
<Select
placeholder="Select role"
value={role}
onChange={handleChangeRole}
disabled={loading || disabled}
icon={loading ? <Spinner size={"xs"} /> : undefined}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</Select>
);
};
Loading

0 comments on commit e3ff3f9

Please sign in to comment.