Add loading animations and authentication handling

This commit is contained in:
pmoharana-cmd 2024-04-24 17:24:54 -04:00
parent e13100e0f7
commit f0fabead01
15 changed files with 337 additions and 128 deletions

View File

@ -15,7 +15,7 @@ openapi_tags = {
# TODO: Add security using HTTP Bearer Tokens
# TODO: Enable authorization by passing user uuid to API
# TODO: Create custom exceptions
@api.get("all", response_model=List[User], tags=["Users"])
@api.get("/all", response_model=List[User], tags=["Users"])
def get_all(user_id: str, user_svc: UserService = Depends()):
subject = user_svc.get_user_by_uuid(user_id)

View File

@ -3,6 +3,11 @@
import Sidebar from "@/components/resource/Sidebar";
import React, { useState } from "react";
import { ChevronDoubleRightIcon } from "@heroicons/react/24/outline";
import { createClient } from "@/utils/supabase/client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import User, { Role } from "@/utils/models/User";
import Loading from "@/components/auth/Loading";
export default function RootLayout({
children,
@ -10,33 +15,86 @@ export default function RootLayout({
children: React.ReactNode;
}) {
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const router = useRouter();
const [user, setUser] = useState<User>();
useEffect(() => {
async function getUser() {
const supabase = createClient();
const { data, error } = await supabase.auth.getUser();
console.log(data, error);
if (error) {
console.log("Accessed admin page but not logged in");
router.push("auth/login");
return;
}
const userData = await fetch(
`${process.env.NEXT_PUBLIC_HOST}/api/user?uuid=${data.user.id}`
);
const user: User = await userData.json();
if (user.role !== Role.ADMIN) {
console.log(
`Accessed admin page but incorrect permissions: ${user.username} ${user.role}`
);
router.push("auth/login");
return;
}
setUser(user);
}
getUser();
}, [router]);
return (
<div className="flex-row">
{/* button to open sidebar */}
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className={`fixed z-20 p-2 text-gray-500 hover:text-gray-800 left-0`}
aria-label={"Open sidebar"}
>
{
!isSidebarOpen && (
<ChevronDoubleRightIcon className="h-5 w-5" />
) // Icon for closing the sidebar
}
</button>
{/* sidebar */}
<div
className={`absolute inset-y-0 left-0 transform ${isSidebarOpen ? "translate-x-0" : "-translate-x-full"} w-64 transition duration-300 ease-in-out`}
>
<Sidebar setIsSidebarOpen={setIsSidebarOpen} />
</div>
{/* page ui */}
<div
className={`flex-1 transition duration-300 ease-in-out ${isSidebarOpen ? "ml-64" : "ml-0"}`}
>
{children}
</div>
{user ? (
<div>
{/* button to open sidebar */}
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className={`fixed z-20 p-2 text-gray-500 hover:text-gray-800 left-0`}
aria-label={"Open sidebar"}
>
{
!isSidebarOpen && (
<ChevronDoubleRightIcon className="h-5 w-5" />
) // Icon for closing the sidebar
}
</button>
{/* sidebar */}
<div
className={`absolute inset-y-0 left-0 transform ${
isSidebarOpen
? "translate-x-0"
: "-translate-x-full"
} w-64 transition duration-300 ease-in-out`}
>
<Sidebar
setIsSidebarOpen={setIsSidebarOpen}
name={user.username}
email={user.email}
admin={user.role === Role.ADMIN}
/>
</div>
{/* page ui */}
<div
className={`flex-1 transition duration-300 ease-in-out ${
isSidebarOpen ? "ml-64" : "ml-0"
}`}
>
{children}
</div>
</div>
) : (
<Loading />
)}
</div>
);
}

View File

@ -2,15 +2,43 @@
import { PageLayout } from "@/components/PageLayout";
import { Table } from "@/components/Table/Index";
import User from "@/utils/models/User";
import { createClient } from "@/utils/supabase/client";
import { UsersIcon } from "@heroicons/react/24/solid";
import { useEffect, useState } from "react";
export default function Page() {
const [users, setUsers] = useState<User[]>([]);
useEffect(() => {
async function getUser() {
const supabase = createClient();
const { data, error } = await supabase.auth.getUser();
if (error) {
console.log("Accessed admin page but not logged in");
return;
}
const userListData = await fetch(
`${process.env.NEXT_PUBLIC_HOST}/api/user/all?uuid=${data.user.id}`
);
const users: User[] = await userListData.json();
setUsers(users);
}
getUser();
}, []);
return (
<div className="min-h-screen flex flex-col">
{/* icon + title */}
<PageLayout title="Users" icon={<UsersIcon />}>
<Table />
<Table users={users} />
</PageLayout>
</div>
);

View File

@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
export async function GET(request: Request) {
const apiEndpoint = `${process.env.NEXT_PUBLIC_API_HOST}/api/user/all`;
console.log(apiEndpoint);
const { searchParams } = new URL(request.url);
const uuid = searchParams.get("uuid");
const data = await fetch(`${apiEndpoint}?user_id=${uuid}`);
return NextResponse.json(await data.json(), { status: data.status });
}

View File

@ -1,6 +1,5 @@
// pages/index.tsx
"use client";
import Button from "@/components/Button";
import Input from "@/components/Input";
import InlineLink from "@/components/InlineLink";
@ -14,30 +13,26 @@ import { useRouter } from "next/navigation";
export default function Page() {
const router = useRouter();
useEffect(() => {
const supabase = createClient();
async function checkUser() {
const { data } = await supabase.auth.getUser();
if (data.user) {
router.push("/resource");
}
}
checkUser();
}, [router]);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [emailError, setEmailError] = useState("");
const [passwordError, setPasswordError] = useState("");
const [loginError, setLoginError] = useState("");
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const supabase = createClient();
async function checkUser() {
const { data } = await supabase.auth.getUser();
if (data.user) {
router.push("/resource");
}
}
checkUser();
}, [router]);
const handleEmailChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setEmail(event.currentTarget.value);
setEmail;
};
const handlePasswordChange = (
@ -51,28 +46,28 @@ export default function Page() {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (email.trim().length === 0) {
console.log(email);
setEmailError("Please enter your email.");
return;
}
if (!emailRegex.test(email)) {
setEmailError("Please enter a valid email address.");
return;
}
setEmailError("");
if (password.trim().length === 0) {
console.log(password);
setPasswordError("Please enter your password.");
return;
}
setPasswordError("");
setIsLoading(true);
const error = await login(email, password);
setLoginError(error);
setIsLoading(false);
if (error) {
setLoginError(error);
}
};
return (
@ -83,13 +78,11 @@ export default function Page() {
width={100}
height={91}
/>
<h1 className="font-bold text-2xl text-purple-800">Login</h1>
<div className="mb-6">
<Input
type="email"
valid={emailError == ""}
valid={emailError === ""}
title="Email"
placeholder="Enter Email"
onChange={handleEmailChange}
@ -97,24 +90,28 @@ export default function Page() {
/>
</div>
{emailError && <ErrorBanner heading={emailError} />}
<div className="mb-6">
<PasswordInput
title="Password"
placeholder="Enter Password"
valid={passwordError == ""}
valid={passwordError === ""}
onChange={handlePasswordChange}
/>
</div>
{passwordError && <ErrorBanner heading={passwordError} />}
<div className="flex flex-col items-left space-y-4">
<InlineLink href="/auth/forgot_password">
Forgot password?
</InlineLink>
<Button onClick={handleClick}>Login</Button>
<Button onClick={handleClick} disabled={isLoading}>
<div className="flex items-center justify-center">
{isLoading && (
<div className="w-4 h-4 border-2 border-white border-t-purple-500 rounded-full animate-spin mr-2"></div>
)}
{isLoading ? "Logging in..." : "Login"}
</div>
</Button>
</div>
{loginError && <ErrorBanner heading={loginError} />}
</>
);

View File

@ -1,4 +1,9 @@
"use client";
import "../styles/globals.css";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { createClient } from "@/utils/supabase/client";
export default function RootLayout({
// Layouts must accept a children prop.

View File

@ -6,4 +6,6 @@ export default function Page() {
const router = useRouter();
router.push("/auth/login");
return <h1>GO TO LOGIN PAGE (/auth/login)</h1>;
}

View File

@ -4,8 +4,10 @@ import React, { useState } from "react";
import { ChevronDoubleRightIcon } from "@heroicons/react/24/outline";
import { createClient } from "@/utils/supabase/client";
import { useEffect } from "react";
import { User } from "@supabase/supabase-js";
import { useRouter } from "next/navigation";
import User, { Role } from "@/utils/models/User";
import Loading from "@/components/auth/Loading";
export default function RootLayout({
children,
}: {
@ -14,9 +16,11 @@ export default function RootLayout({
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [user, setUser] = useState<User>();
const router = useRouter();
useEffect(() => {
const supabase = createClient();
async function getUser() {
const supabase = createClient();
const { data, error } = await supabase.auth.getUser();
console.log(data, error);
@ -26,48 +30,60 @@ export default function RootLayout({
router.push("auth/login");
return;
}
setUser(data.user);
const userData = await fetch(
`${process.env.NEXT_PUBLIC_HOST}/api/user?uuid=${data.user.id}`
);
setUser(await userData.json());
}
getUser();
}, [router]);
return (
<div className="flex-row">
{/* button to open sidebar */}
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className={`fixed z-20 p-2 text-gray-500 hover:text-gray-800 left-0`}
aria-label={"Open sidebar"}
>
{
!isSidebarOpen && (
<ChevronDoubleRightIcon className="h-5 w-5" />
) // Icon for closing the sidebar
}
</button>
{/* sidebar */}
<div
className={`absolute inset-y-0 left-0 transform ${
isSidebarOpen ? "translate-x-0" : "-translate-x-full"
} w-64 transition duration-300 ease-in-out`}
>
<Sidebar
name={""}
email={(user && user.email) ?? "No email found!"}
setIsSidebarOpen={setIsSidebarOpen}
/>
</div>
{/* page ui */}
<div
className={`flex-1 transition duration-300 ease-in-out ${
isSidebarOpen ? "ml-64" : "ml-0"
}`}
>
{children}
</div>
{user ? (
<div>
{/* button to open sidebar */}
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className={`fixed z-20 p-2 text-gray-500 hover:text-gray-800 left-0`}
aria-label={"Open sidebar"}
>
{
!isSidebarOpen && (
<ChevronDoubleRightIcon className="h-5 w-5" />
) // Icon for closing the sidebar
}
</button>
{/* sidebar */}
<div
className={`absolute inset-y-0 left-0 transform ${
isSidebarOpen
? "translate-x-0"
: "-translate-x-full"
} w-64 transition duration-300 ease-in-out`}
>
<Sidebar
name={user.username}
email={user.email}
setIsSidebarOpen={setIsSidebarOpen}
admin={user.role === Role.ADMIN}
/>
</div>
{/* page ui */}
<div
className={`flex-1 transition duration-300 ease-in-out ${
isSidebarOpen ? "ml-64" : "ml-0"
}`}
>
{children}
</div>
</div>
) : (
<Loading />
)}
</div>
);
}

View File

@ -3,7 +3,7 @@ import { FunctionComponent, ReactNode } from "react";
type ButtonProps = {
children: ReactNode;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
type?: "button" | "submit" | "reset"; // specify possible values for type
type?: "button" | "submit" | "reset";
disabled?: boolean;
};
@ -13,11 +13,11 @@ const Button: FunctionComponent<ButtonProps> = ({
disabled,
onClick,
}) => {
const buttonClassName = `inline-block rounded border ${
const buttonClassName = `inline-flex items-center justify-center rounded border ${
disabled
? "bg-gray-400 text-gray-600 cursor-not-allowed"
: "border-purple-600 bg-purple-600 text-white hover:bg-transparent hover:text-purple-600 focus:outline-none focus:ring active:text-purple-500"
} px-4 py-1 text-md font-semibold w-20 h-10 text-center`;
} px-4 py-2 text-md font-semibold w-full sm:w-auto`;
return (
<button
@ -26,7 +26,9 @@ const Button: FunctionComponent<ButtonProps> = ({
type={type}
disabled={disabled}
>
{children}
<div className="flex items-center justify-center space-x-2">
{children}
</div>
</button>
);
};

View File

@ -1,6 +1,6 @@
// for showcasing to compass
import usersImport from "./users.json";
import users from "./users.json";
import {
Cell,
ColumnDef,
@ -32,22 +32,7 @@ import {
} from "@heroicons/react/24/solid";
import TagsInput from "../TagsInput/Index";
import { rankItem } from "@tanstack/match-sorter-utils";
import { TableCell } from "./TableCell";
import { PrimaryTableCell } from "./PrimaryTableCell";
const usersExample = usersImport as unknown as User[];
type User = {
id: number;
created_at: any;
username: string;
role: "administrator" | "employee" | "volunteer";
email: string;
program: "domestic" | "economic" | "community";
experience: number;
group?: string;
visible: boolean;
};
import User from "@/utils/models/User";
// For search
const fuzzyFilter = (
@ -66,17 +51,17 @@ const fuzzyFilter = (
return itemRank.passed;
};
export const Table = () => {
export const Table = ({ users }: { users: User[] }) => {
const columnHelper = createColumnHelper<User>();
useEffect(() => {
const sortedUsers = [...usersExample].sort((a, b) =>
const sortedUsers = [...users].sort((a, b) =>
a.visible === b.visible ? 0 : a.visible ? -1 : 1
);
setData(sortedUsers);
}, []);
}, [users]);
const deleteUser = (userId) => {
const deleteUser = (userId: number) => {
console.log(data);
setData((currentData) =>
currentData.filter((user) => user.id !== userId)
@ -188,10 +173,10 @@ export const Table = () => {
}),
];
const [data, setData] = useState<User[]>([...usersExample]);
const [data, setData] = useState<User[]>([...users]);
const addUser = () => {
setData([...data, {}]);
setData([...data]);
};
// Searching

View File

@ -0,0 +1,43 @@
/* components/Loading.module.css */
.loadingOverlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
}
.loadingContent {
text-align: center;
}
.loadingTitle {
font-size: 2rem;
font-weight: bold;
color: #5b21b6;
margin-top: 1rem;
}
.loadingSpinner {
width: 50px;
height: 50px;
border: 4px solid #5b21b6;
border-top: 4px solid #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 2rem auto;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@ -0,0 +1,22 @@
// components/Loading.js
import styles from "./Loading.module.css";
import Image from "next/image";
const Loading = () => {
return (
<div className={styles.loadingOverlay}>
<div className={styles.loadingContent}>
<Image
src="/logo.png"
alt="Compass Center logo."
width={100}
height={91}
/>
<h1 className={styles.loadingTitle}>Loading...</h1>
<div className={styles.loadingSpinner}></div>
</div>
</div>
);
};
export default Loading;

View File

@ -13,9 +13,15 @@ interface SidebarProps {
setIsSidebarOpen: React.Dispatch<React.SetStateAction<boolean>>;
name: string;
email: string;
admin: boolean;
}
const Sidebar: React.FC<SidebarProps> = ({ setIsSidebarOpen, name, email }) => {
const Sidebar: React.FC<SidebarProps> = ({
setIsSidebarOpen,
name,
email,
admin,
}) => {
return (
<div className="w-64 h-full border border-gray-200 bg-gray-50 px-4">
{/* button to close sidebar */}
@ -39,12 +45,38 @@ const Sidebar: React.FC<SidebarProps> = ({ setIsSidebarOpen, name, email }) => {
Pages
</h4>
<nav className="flex flex-col">
<SidebarItem icon={<HomeIcon />} text="Home" />
<SidebarItem icon={<BookmarkIcon />} text="Resources" />
<SidebarItem icon={<ClipboardIcon />} text="Services" />
{admin && (
<SidebarItem
icon={<HomeIcon />}
text="Admin"
active={true}
redirect="/admin"
/>
)}
<SidebarItem
icon={<HomeIcon />}
text="Home"
active={true}
redirect="/resource"
/>
<SidebarItem
icon={<BookmarkIcon />}
text="Resources"
active={true}
redirect="/resource"
/>
<SidebarItem
icon={<ClipboardIcon />}
text="Services"
active={true}
redirect="/service"
/>
<SidebarItem
icon={<BookOpenIcon />}
text="Training Manuals"
active={true}
redirect="/training-manuals"
/>
</nav>
</div>

View File

@ -1,27 +1,31 @@
import Link from "next/link";
interface SidebarItemProps {
icon: React.ReactElement;
text: string;
active: boolean;
redirect: string;
}
export const SidebarItem: React.FC<SidebarItemProps> = ({
icon,
text,
active,
redirect,
}) => {
return (
<a
href="#"
<Link
href={redirect}
className={
active
? "flex items-center p-2 space-x-2 bg-gray-200 rounded-md"
: "flex items-center p-2 space-x-2 hover:bg-gray-200 rounded-md"
? "flex items-center p-2 my-1 space-x-2 bg-gray-200 rounded-md"
: "flex items-center p-2 my-1 space-x-2 hover:bg-gray-200 rounded-md"
}
>
<span className="h-5 text-gray-500 w-5">{icon}</span>
<span className="flex-grow font-medium text-xs text-gray-500">
{text}
</span>
</a>
</Link>
);
};

View File

@ -20,4 +20,5 @@ export default interface User {
program: Program[];
role: Role;
created_at: Date;
visible: boolean;
}