Compare commits

...

59 Commits

Author SHA1 Message Date
90fd37fde1 refactor: remove debug option from authOptions for production readiness 2025-01-08 00:42:10 +01:00
0cc0d18558 feat: implement ClassList component to display classes; update UserList to show associated classes and handle empty states 2025-01-08 00:20:56 +01:00
52274f204c feat: replace moment with moment-timezone for improved time handling; set default timezone to America/New_York 2025-01-07 23:31:49 +01:00
4606be56a0 feat: update Sidebar component to handle mobile state; modify SidebarItem to close on mobile navigation 2025-01-07 19:11:32 +01:00
a23b58e05c refactor: remove console log for sidebarIsOpen to clean up code 2025-01-07 18:56:20 +01:00
1a3bc63d2e feat: integrate sidebar state management with user store; replace local open state with sidebarIsOpen from useUserStore 2025-01-07 17:18:20 +01:00
cf9fd767f3 feat: update Sidebar component for improved responsiveness and menu interaction; add showTitle prop to SidebarItem 2025-01-07 16:58:34 +01:00
1e9d5c2742 feat: add setOpen functionality to SidebarItem for improved menu interaction 2025-01-07 14:51:14 +01:00
2f304684f3 feat: enhance Sidebar component with responsive menu and swipe functionality; update UserList component for full-width display 2025-01-07 14:37:21 +01:00
bb751c1476 feat: update admin pages to display "Hello world!" and enhance layout with dynamic titles and dividers 2025-01-07 12:37:50 +01:00
c100c71fea fix: adjust margin for Divider component in Sidebar for consistent spacing 2025-01-07 12:24:05 +01:00
3bb97abdf2 feat: update Sidebar component layout and styling for improved user experience 2025-01-07 12:21:19 +01:00
919630524b feat: enhance sidebar component with dynamic item rendering and improved layout 2025-01-07 12:13:15 +01:00
3ac29ae909 feat: add OAUTH_SCOPES to environment types and update authOptions for dynamic scope handling 2025-01-07 10:49:08 +01:00
1c897648f7 feat: add Class interface import to classes service for type safety 2025-01-06 23:51:33 +01:00
5bfc969f6a feat: refactor user and room interfaces, update imports, and enhance user list component 2025-01-06 23:48:52 +01:00
b1f59249c0 feat: improve session management by handling session errors and enhancing token refresh logic 2025-01-06 21:48:31 +01:00
b467ae704c feat: enhance authentication flow by redirecting unauthenticated users and updating token scope 2025-01-06 17:39:27 +01:00
fce459679c feat: update session handling by changing import source and enhancing token scope 2025-01-06 16:51:30 +01:00
845381e84d feat: enhance token refresh logic with request queue and improved error handling 2025-01-06 16:05:25 +01:00
b3c6ae2460 feat: move class selection to profile dropdown in header for improved user experience 2025-01-06 11:49:51 +01:00
de8742437f feat: refactor room-related services and update API URLs for improved structure and clarity 2025-01-06 11:21:05 +01:00
dc858227fa feat: integrate session management with AppWrapper and update axios interceptor for token handling 2025-01-06 03:15:01 +01:00
9f2ae08f1a feat: add admin pages for classes, rooms, settings, and users with updated layout and sidebar navigation 2025-01-06 02:56:51 +01:00
1ff5126e98 chore: update base image to Node.js 20-alpine in Dockerfile 2025-01-06 00:37:53 +01:00
bf94e84c65 chore: remove SWR and related dependencies from package.json and yarn.lock 2025-01-06 00:35:02 +01:00
1684ac2743 feat: refactor class selection handling by introducing user store and updating related components 2025-01-06 00:31:25 +01:00
da74d1bf82 feat: implement class selection in header and add API service for fetching classes 2025-01-05 23:22:52 +01:00
16a191341b feat: add SWR and related dependencies to improve data fetching capabilities 2025-01-05 22:53:57 +01:00
946bc68946 feat: enhance class and room handling by updating fetchClass return type and improving null checks in components 2025-01-05 22:53:51 +01:00
96a0c5c4b0 fix: update dependencies in useEffect hooks and improve router usage in SidebarItem 2025-01-05 03:19:22 +01:00
ff82486134 feat: add class selection functionality to header and room table components 2025-01-05 03:10:45 +01:00
434414d99d feat: implement sidebar component and replace admin header with sidebar in admin layout
implement zustand as store for rooms
2025-01-05 02:38:13 +01:00
d45f79a3d9 feat: protect admin routes with OIDC roles 2025-01-05 02:10:54 +01:00
f0b498ca18 feat: change package name 2025-01-05 01:18:12 +01:00
26e9462dac refactor: remove unused session retrieval in AdminPage component 2025-01-05 01:11:52 +01:00
a9d4fbedcf fix: auth ssr to rendering admin btn and username 2025-01-05 01:09:03 +01:00
ddd333e64a feat: disable button in RoomCard based on start time and refine date comparison in RoomTable 2025-01-04 18:53:51 +01:00
38fa50e0c8 feat: add ProvidersList component for authentication and update LoginPage 2025-01-04 18:17:06 +01:00
096a10b9c1 refactor: improve component formatting in Header, RoomCard, and ThemeSwitcher 2025-01-04 17:58:23 +01:00
e6f84ad95e feat: use SSR On Pages 2025-01-04 16:36:57 +01:00
b81a058a1c ref: format 2025-01-04 16:11:22 +01:00
f54a8ccc0a feat: refactor RoomCard and RoomList components, add SkeletonRoomCard for loading state 2025-01-04 16:11:05 +01:00
85deb66a54 feat: add Prettier for code formatting and enhance RoomList component scroll behavior 2025-01-03 02:36:48 +01:00
f9aef69cfa feat: add Room component and refactor Conference components, implement custom scrollbar styles and smooth 2025-01-03 02:19:20 +01:00
37a34dc004 feat: enhance Header component with user profile fetching and admin button, update ThemeSwitcher to use Button component 2025-01-02 22:11:09 +01:00
c325305442 feat: add production start script for Docker deployment 2025-01-02 21:08:22 +01:00
65cc556080 feat: reposition ThemeSwitcher component in Header and enhance button styling 2025-01-02 20:26:05 +01:00
482b43eaf4 fix: update Docker build script to remove no-cache option for improved performance 2025-01-02 19:54:21 +01:00
52ba69107e feat: add Docker build script to package.json for containerized deployment 2025-01-02 19:48:15 +01:00
c45579221b refactor: simplify conditional check for class response before fetching rooms 2025-01-02 19:30:50 +01:00
e6d8a9ae9e feat: enhance Header component to display user initials and refactor ThemeSwitcher for improved button handling 2025-01-02 18:54:59 +01:00
b4d054de36 feat: add theme switcher and refactor conference components to use new Room interface 2025-01-02 18:34:08 +01:00
bc8216fbe2 feat: implement Conference List & Card 2024-12-29 03:25:51 +01:00
M1000fr
c54d6b2ea8 feat: implement NextUI & header 2024-12-26 00:23:23 +01:00
m1000
40e002e998 Merge pull request 'feature/auth' (#3) from feature/auth into development
Reviewed-on: Toogether/Client#3
2024-12-24 08:45:32 +00:00
M1000fr
bc880295c4 Refactor: Simplify conditional rendering in FetchApi component 2024-12-13 18:39:49 +01:00
M1000fr
52fc2b5e07 Refactor: Update docker-compose.yml to remove unnecessary build and environment configurations 2024-12-13 18:16:43 +01:00
M1000fr
76d05af0bb Refactor: Update moment locale to French in axios.ts and authOptions.ts 2024-12-13 14:42:34 +01:00
54 changed files with 3628 additions and 369 deletions

View File

@ -9,4 +9,5 @@ OAUTH_ISSUER=
OAUTH_AUTHORIZATION_URL= OAUTH_AUTHORIZATION_URL=
OAUTH_TOKEN_URL= OAUTH_TOKEN_URL=
OAUTH_USERINFO_URL= OAUTH_USERINFO_URL=
OAUTH_JWKS_ENDPOINT= OAUTH_JWKS_ENDPOINT=
OAUTH_SCOPES="openid email profile"

5
.prettierrc Normal file
View File

@ -0,0 +1,5 @@
{
"tabWidth": 4,
"useTabs": true,
"semi": true
}

View File

@ -1,6 +1,6 @@
# syntax=docker.io/docker/dockerfile:1 # syntax=docker.io/docker/dockerfile:1
FROM node:18-alpine AS base FROM node:20-alpine AS base
# Install dependencies only when needed # Install dependencies only when needed
FROM base AS deps FROM base AS deps

View File

@ -1,10 +1,5 @@
services: services:
webapp: webapp:
image: toogether/webapp image: toogether/webapp
ports: ports:
- "80:3000" - "80:3000"
build:
context: ./
dockerfile: Dockerfile
environment:
- NEXT_PUBLIC_API_URL=http://api:3000

View File

@ -1,21 +1,33 @@
{ {
"name": "client", "name": "@toogether/webapp",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev -p 4000",
"build": "next build", "build": "next build",
"start": "next start", "build:docker": "docker build -t toogether/webapp .",
"lint": "next lint" "start": "next start -p 4000",
"start:prod": "docker compose up --force-recreate -d",
"lint": "next lint",
"format": "prettier --write ."
}, },
"dependencies": { "dependencies": {
"@internationalized/date": "^3.6.0",
"@nextui-org/navbar": "^2.2.7",
"@nextui-org/react": "^2.6.10",
"@nextui-org/system": "^2.4.5",
"@nextui-org/theme": "^2.4.4",
"axios": "^1.7.9", "axios": "^1.7.9",
"axios-hooks": "^5.1.0",
"framer-motion": "^11.15.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"moment": "^2.30.1", "moment-timezone": "^0.5.46",
"next": "15.0.3", "next": "15.0.3",
"next-auth": "^4.24.10", "next-auth": "^4.24.10",
"next-themes": "^0.4.4",
"react": "19.0.0-rc-66855b96-20241106", "react": "19.0.0-rc-66855b96-20241106",
"react-dom": "19.0.0-rc-66855b96-20241106", "react-dom": "19.0.0-rc-66855b96-20241106",
"react-icons": "^5.4.0",
"zustand": "^5.0.2" "zustand": "^5.0.2"
}, },
"devDependencies": { "devDependencies": {
@ -26,6 +38,7 @@
"eslint": "^8", "eslint": "^8",
"eslint-config-next": "15.0.3", "eslint-config-next": "15.0.3",
"postcss": "^8", "postcss": "^8",
"prettier": "^3.4.2",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"typescript": "^5" "typescript": "^5"
} }

View File

@ -0,0 +1,10 @@
import { ClassList } from "@/app/components/Class";
import { Metadata } from "next";
export const metadata: Metadata = {
title: "Classes | Toogether",
};
export default async function Page() {
return <ClassList />;
}

50
src/app/admin/layout.tsx Normal file
View File

@ -0,0 +1,50 @@
"use client";
import { Sidebar } from "@/app/components/Sidebar";
import { Divider } from "@nextui-org/react";
import { usePathname } from "next/navigation";
import { FaDoorClosed, FaRegUser } from "react-icons/fa";
import { FiSettings } from "react-icons/fi";
import { IoMdStats } from "react-icons/io";
import { MdInbox } from "react-icons/md";
import { SidebarItemProps } from "../components/Sidebar/item";
export default function Layout({ children }: { children: React.ReactNode }) {
const pathName = usePathname();
const sidebarItems = [
[
{ href: "/admin", title: "Dashboard", icon: <IoMdStats /> },
{ href: "/admin/users", title: "Users", icon: <FaRegUser /> },
{ href: "/admin/classes", title: "Classes", icon: <MdInbox /> },
{ href: "/admin/rooms", title: "Rooms", icon: <FaDoorClosed /> },
],
[
{
href: "/admin/settings",
title: "Settings",
icon: <FiSettings />,
},
],
] as SidebarItemProps[][];
return (
<div className="flex">
<Sidebar items={sidebarItems} />
<main className="p-7 w-full">
<div className="flex items-center gap-2">
<h1 className="text-2xl font-semibold">
{
sidebarItems
.flat()
.find((item) => item.href === pathName)?.title
}
</h1>
</div>
<Divider className="mt-2 mb-5 bg-foreground-300" />
{children}
</main>
</div>
);
}

11
src/app/admin/page.tsx Normal file
View File

@ -0,0 +1,11 @@
import { Metadata } from "next";
export const metadata: Metadata = {
title: "Admin | Toogether",
};
export default async function Page() {
return (
<p>Hello world!</p>
);
}

View File

@ -0,0 +1,11 @@
import { Metadata } from "next";
export const metadata: Metadata = {
title: "Rooms | Toogether",
};
export default async function Page() {
return (
<p>Hello world!</p>
);
}

View File

@ -0,0 +1,11 @@
import { Metadata } from "next";
export const metadata: Metadata = {
title: "Settings | Toogether",
};
export default async function Page() {
return (
<p>Hello world!</p>
);
}

View File

@ -0,0 +1,10 @@
import { UserList } from "@/app/components/Users";
import { Metadata } from "next";
export const metadata: Metadata = {
title: "Users | Toogether",
};
export default async function Page() {
return <UserList />;
}

View File

@ -1,5 +1,5 @@
import NextAuth from "next-auth"; import NextAuth from "next-auth";
import { authOptions } from "@/authOptions"; import { authOptions } from "@/authOptions";
const handler = NextAuth(authOptions); const handler = NextAuth(authOptions);
export { handler as GET, handler as POST }; export { handler as GET, handler as POST };

View File

@ -1,10 +1,11 @@
"use client"; import { Metadata } from "next";
import { authOptions } from "@/authOptions"; import { ProvidersList } from "./providersList";
import { signIn } from "next-auth/react";
const LoginPage = () => { export const metadata: Metadata = {
const provider = authOptions.providers[0]; title: "Toogether | Connexion",
};
export default function LoginPage() {
return ( return (
<div className="flex flex-col h-screen items-center justify-center bg-gradient-to-br from-green-700 to-green-950 bg-[length:200%_200%] animate-gradient-x text-white"> <div className="flex flex-col h-screen items-center justify-center bg-gradient-to-br from-green-700 to-green-950 bg-[length:200%_200%] animate-gradient-x text-white">
<div className="flex flex-col justify-center items-center gap-6 bg-black bg-opacity-40 rounded-md p-5 mx-5 w-2/3"> <div className="flex flex-col justify-center items-center gap-6 bg-black bg-opacity-40 rounded-md p-5 mx-5 w-2/3">
@ -25,19 +26,8 @@ const LoginPage = () => {
<div className="w-full border-t border-white"></div> <div className="w-full border-t border-white"></div>
<h3 className="font-bold text-xl">Via</h3> <h3 className="font-bold text-xl">Via</h3>
<ul> <ProvidersList />
<li key={provider.id}>
<button
className="bg-white text-black p-2 rounded-md"
onClick={() => signIn(provider.id)}
>
{provider.name}
</button>
</li>
</ul>
</div> </div>
</div> </div>
); );
}; }
export default LoginPage;

View File

@ -0,0 +1,22 @@
"use client";
import { authOptions } from "@/authOptions";
import { signIn } from "next-auth/react";
export const ProvidersList = () => {
const providers = authOptions.providers;
return (
<div className="flex gap-2">
{providers.map((provider) => (
<button
key={provider.id}
className="bg-white text-black p-2 rounded-md"
onClick={() => signIn(provider.id)}
>
{provider.name}
</button>
))}
</div>
);
};

View File

@ -1,18 +1,18 @@
"use client"; "use client";
import { useEffect } from "react"; import { useEffect } from "react";
import { signOut, useSession } from "next-auth/react"; import { signOut, useSession } from "next-auth/react";
const LogoutPage = () => { const LogoutPage = () => {
const session = useSession(); const session = useSession();
useEffect(() => { useEffect(() => {
if (session) { if (session) {
signOut(); signOut();
} }
}, [session]); }, [session]);
return null; return null;
}; };
export default LogoutPage; export default LogoutPage;

View File

@ -0,0 +1,24 @@
"use client";
import { NextUIProvider } from "@nextui-org/react";
import { Session } from "next-auth";
import { SessionProvider } from "next-auth/react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
export default function AppWrapper({
session,
children,
}: {
session: Session | null;
children: React.ReactNode;
}) {
return (
<SessionProvider session={session} refetchOnWindowFocus={false}>
<NextUIProvider>
<NextThemesProvider attribute="class" defaultTheme="dark">
{children}
</NextThemesProvider>
</NextUIProvider>
</SessionProvider>
);
}

View File

@ -0,0 +1,71 @@
"use client";
import { Class } from "@/app/interface/class";
import { classesService } from "@/app/services/classes.service";
import {
Card,
Skeleton,
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
} from "@nextui-org/react";
import { useEffect, useState } from "react";
export const ClassList = () => {
const [classes, setClasses] = useState<Class[]>();
useEffect(() => {
classesService.getAll().then((response) => {
setClasses(response.data);
});
}, []);
if (!classes) {
return (
<Card className="w-full space-y-5 p-4" radius="lg">
<Skeleton className="rounded-lg">
<div className="h-10 rounded-lg bg-default-300" />
</Skeleton>
<div className="space-y-3">
<Skeleton className="w-3/5 rounded-lg">
<div className="h-3 w-3/5 rounded-lg bg-default-200" />
</Skeleton>
<Skeleton className="w-4/5 rounded-lg">
<div className="h-3 w-4/5 rounded-lg bg-default-200" />
</Skeleton>
<Skeleton className="w-2/5 rounded-lg">
<div className="h-3 w-2/5 rounded-lg bg-default-300" />
</Skeleton>
</div>
</Card>
);
}
if (classes.length === 0) {
return (
<Card className="w-full p-4" radius="lg">
<p className="text-center text-lg text-gray-500 dark:text-gray-400">
No classes found
</p>
</Card>
);
}
return (
<Table aria-label="List of users" className="w-full">
<TableHeader>
<TableColumn>NAME</TableColumn>
</TableHeader>
<TableBody>
{classes.map((Class) => (
<TableRow key={Class.id.toString()}>
<TableCell>{Class.name}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
};

View File

@ -1,29 +0,0 @@
"use client";
import { axiosInstance } from "@/app/lib/axios";
import { useRef } from "react";
export const FetchApi = () => {
const listRef = useRef<HTMLUListElement>(null);
const handleFetch = async () => {
const response = await axiosInstance.get<{
message: string;
}>("/ping");
const li = document.createElement("li");
li.textContent = response.data.message;
listRef.current?.appendChild(li);
};
return (
<div>
<button
className="text-white px-2 py-2 bg-green-500 rounded-md"
onClick={handleFetch}
>
Fetch API
</button>
<ul ref={listRef}></ul>
</div>
);
};

View File

@ -1,28 +0,0 @@
import { getServerSession, Session } from "next-auth";
import Link from "next/link";
const Header = async () => {
const session = (await getServerSession()) as Session;
return (
<header className="bg-gray-900 text-white p-4">
<div className="container mx-auto flex justify-between items-center">
<h1 className="text-xl font-bold">Toogether</h1>
<div className="flex items-center gap-2">
<p>
Logged in as <strong>{session.user.name}</strong>
</p>
<nav className="space-x-4">
<Link href="/auth/logout">
<button className="bg-gray-800 px-4 py-2 rounded hover:scale-105 transition duration-200">
Logout
</button>
</Link>
</nav>
</div>
</div>
</header>
);
};
export default Header;

View File

@ -0,0 +1,123 @@
"use client";
import { useClassStore } from "@/app/stores/classStore";
import { useUserStore } from "@/app/stores/userStore";
import { User } from "@/app/types/next-auth";
import { getInitials } from "@/app/utils/initial";
import {
Autocomplete,
AutocompleteItem,
Avatar,
Button,
Dropdown,
DropdownItem,
DropdownMenu,
DropdownTrigger,
Navbar,
NavbarBrand,
NavbarContent,
NavbarItem,
} from "@nextui-org/react";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { ThemeSwitcher } from "../ThemeSwitcher/ThemeSwitcher";
export const HeaderContent = ({ user }: { user?: User }) => {
const router = useRouter();
const { classes, fetchClass } = useClassStore();
const {
currentClassId: selectedClassId,
setCurrentClassId: setSelectedClassId,
} = useUserStore();
useEffect(() => {
fetchClass();
}, [fetchClass]);
const selectedClass = classes.find((Class) => Class.id === selectedClassId);
useEffect(() => {
if (!selectedClass && classes.length > 0)
setSelectedClassId(classes[0].id);
}, [selectedClass, classes, setSelectedClassId]);
return (
<Navbar className="mb-2">
<NavbarBrand>
<p className="font-bold text-inherit">Toogether</p>
</NavbarBrand>
<NavbarContent as="div" justify="end">
{user?.roles.includes("admin") ? (
<NavbarItem>
<Button
size="sm"
variant="flat"
className="min-w-0"
onPress={() => router.push("/admin")}
>
🔧
</Button>
</NavbarItem>
) : null}
<NavbarItem>
<ThemeSwitcher />
</NavbarItem>
<Dropdown placement="bottom-end">
<DropdownTrigger>
<Avatar
isBordered
as="button"
className="transition-transform"
color="secondary"
name={user?.name ? getInitials(user.name) : ""}
size="sm"
/>
</DropdownTrigger>
<DropdownMenu
aria-label="Profile Actions"
variant="flat"
closeOnSelect={false}
>
<DropdownItem key="profile" className="h-14 gap-2" showDivider>
<p>Signed in as</p>
<p className="font-semibold">{user?.name}</p>
</DropdownItem>
<DropdownItem key="class" className="h-14 gap-2">
<Autocomplete
size="sm"
label="Select an class"
value={selectedClass?.name}
selectedKey={selectedClass?.id}
onSelectionChange={(selectedId) => {
const inputSelectedClass = classes.find(
(Class) => Class.id === selectedId,
);
if (inputSelectedClass)
setSelectedClassId(
inputSelectedClass.id,
);
}}
>
{classes.map((Class) => (
<AutocompleteItem key={Class.id}>
{Class.name}
</AutocompleteItem>
))}
</Autocomplete>
</DropdownItem>
<DropdownItem key="settings">Settings</DropdownItem>
<DropdownItem
key="logout"
color="danger"
href="/auth/logout"
>
Logout
</DropdownItem>
</DropdownMenu>
</Dropdown>
</NavbarContent>
</Navbar>
);
};

View File

@ -0,0 +1,9 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/authOptions";
import { HeaderContent } from "./contents";
export const Header = async () => {
const session = await getServerSession(authOptions);
return <HeaderContent user={session?.user} />;
};

View File

@ -0,0 +1,90 @@
"use client";
import { parseDate, Time } from "@internationalized/date";
import {
Button,
Card,
CardBody,
CardHeader,
DateInput,
Divider,
TimeInput,
} from "@nextui-org/react";
import { Room } from "../../interface/room";
import moment from "moment";
import { useRouter } from "next/navigation";
export const RoomCard = ({ id, name, date, Times, Presentator }: Room) => {
const router = useRouter();
return (
<Card className="w-[300px]">
<CardHeader>
<div className="flex flex-col min-h-20">
<p className="text-md">{name}</p>
<p className="text-small text-default-500">
{Presentator.username}
</p>
</div>
</CardHeader>
<Divider />
<CardBody>
<div className="flex flex-col gap-2" key={`times.${id}`}>
<DateInput
isReadOnly
label="Date"
value={parseDate(moment(date).format("YYYY-MM-DD"))}
/>
{Times.map((time) => (
<div key={`time.${time.id}`}>
<div className="flex items-center gap-2">
<TimeInput
isReadOnly
label="Start"
hourCycle={24}
value={
new Time(
moment(time.startTime).hours(),
moment(time.startTime).minutes(),
)
}
/>
<span>-</span>
<TimeInput
isReadOnly
label="End"
hourCycle={24}
value={
new Time(
moment(time.endTime).hours(),
moment(time.endTime).minutes(),
)
}
/>
</div>
</div>
))}
</div>
</CardBody>
{moment(date).dayOfYear() === moment().dayOfYear() && (
<div className="flex p-2">
<Button
isDisabled={moment(
moment(Times[0].startTime).subtract(5, "minutes"),
).isAfter(moment())}
color="primary"
radius="full"
size="sm"
variant={"flat"}
onPress={() => {
router.push(`/room/${id}`);
}}
>
Join
</Button>
</div>
)}
</Card>
);
};

View File

@ -0,0 +1,87 @@
"use client";
import { useEffect, useRef } from "react";
import { RoomCard } from "./Card";
import { Room } from "../../interface/room";
import { SkeletonRoomCard } from "./SkeletonRoomCard";
export const RoomList = ({ rooms }: { rooms: Room[] | null }) => {
const scrollContainerRef = useRef<HTMLUListElement>(null);
const handleWheel = (event: WheelEvent) => {
if (event.deltaY === 0) return;
if (event.ctrlKey || event.shiftKey || event.altKey) return;
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) return;
const goLeft = event.deltaY < 0;
const isEnd = goLeft
? scrollContainer.scrollLeft === 0
: scrollContainer.scrollLeft + scrollContainer.clientWidth >=
scrollContainer.scrollWidth;
if (isEnd) return;
event.preventDefault();
const scrollAmount = 10;
const direction = event.deltaY > 0 ? 1 : -1;
let scrollCount = 0;
const interval = setInterval(() => {
scrollContainer.scrollLeft += scrollAmount * direction;
scrollCount += scrollAmount;
if (scrollCount >= 100) {
clearInterval(interval);
}
}, 10);
};
useEffect(() => {
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) return;
scrollContainer.addEventListener("wheel", handleWheel);
return () => {
scrollContainer.removeEventListener("wheel", handleWheel);
};
}, []);
if (rooms?.length == 0)
return (
<div className="flex gap-2 rounded-xl bg-default-100 p-4">
<p className="text-default-500 text-center w-full">
No rooms found
</p>
</div>
);
return (
<ul
ref={scrollContainerRef}
className="flex gap-2 overflow-x-auto scrollbar-hide rounded-xl bg-default-100 p-1"
>
{rooms ? (
rooms.map((room) => (
<li key={room.id}>
<RoomCard
id={room.id}
name={room.name}
date={room.date}
Presentator={room.Presentator}
Times={room.Times}
/>
</li>
))
) : (
<>
{Array.from({ length: 3 }).map((_, i) => (
<li key={i}>
<SkeletonRoomCard />
</li>
))}
</>
)}
</ul>
);
};

View File

@ -0,0 +1,30 @@
"use client";
import { Card, Skeleton, Divider } from "@nextui-org/react";
export const SkeletonRoomCard = () => {
return (
<Card className="w-[200px] space-y-5 p-4" radius="lg">
<div className="flex flex-col gap-2">
<Skeleton className="w-4/5 rounded-lg">
<div className="h-3 w-4/5 rounded-lg bg-default-200" />
</Skeleton>
<Skeleton className="w-2/5 rounded-lg">
<div className="h-3 w-2/5 rounded-lg bg-default-300" />
</Skeleton>
</div>
<Divider />
<Skeleton className="rounded-lg">
<div className="h-12 rounded-lg bg-default-300" />
</Skeleton>
<div className="flex items-center gap-2">
<Skeleton className="rounded-lg w-1/2">
<div className="h-10 rounded-lg bg-default-300" />
</Skeleton>
<span>-</span>
<Skeleton className="rounded-lg w-1/2">
<div className="h-10 rounded-lg bg-default-300" />
</Skeleton>
</div>
</Card>
);
};

View File

@ -0,0 +1,31 @@
"use client";
import { useRoomStore } from "@/app/stores/roomStore";
import { useUserStore } from "@/app/stores/userStore";
import { useEffect } from "react";
import { RoomList } from "./List";
export const RoomTable = () => {
const { fetchRooms, actual, future, past } = useRoomStore();
const { currentClassId: selectedClassId } = useUserStore();
useEffect(() => {
if (selectedClassId) fetchRooms();
}, [fetchRooms, selectedClassId]);
return (
<div className="flex flex-col gap-4">
<section className="flex flex-col gap-2">
<h2 className="font-semibold text-lg">Upcoming</h2>
<RoomList rooms={future} />
</section>
<section className="flex flex-col gap-2">
<h2 className="font-semibold text-lg">Today</h2>
<RoomList rooms={actual} />
</section>
<section className="flex flex-col gap-2">
<h2 className="font-semibold text-lg">Past</h2>
<RoomList rooms={past} />
</section>
</div>
);
};

View File

@ -1,11 +0,0 @@
"use client";
import { SessionProvider } from "next-auth/react";
export default function SessionProviderWrapper({
children,
}: {
children: React.ReactNode;
}) {
return <SessionProvider>{children}</SessionProvider>;
}

View File

@ -0,0 +1,129 @@
import { Divider, Link } from "@nextui-org/react";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import {
BsLayoutSidebarInset,
BsReverseLayoutSidebarInsetReverse,
} from "react-icons/bs";
import { ThemeSwitcher } from "../ThemeSwitcher/ThemeSwitcher";
import { SidebarItem } from "./item";
import { useUserStore } from "@/app/stores/userStore";
export const Sidebar = ({
items,
}: {
items: {
href: string;
title: string;
icon: React.ReactNode;
}[][];
}) => {
const router = useRouter();
const pathName = usePathname();
// State to manage menu openness
const { setSidebarIsOpen, sidebarIsOpen } = useUserStore();
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const handleResize = () => {
if (sidebarIsOpen) return;
if (window.innerWidth <= 1024 && !isMobile) {
setIsMobile(true);
} else if (window.innerWidth > 1024 && isMobile) {
setIsMobile(false);
}
};
handleResize();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
});
useEffect(() => {
if (isMobile) {
setSidebarIsOpen(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMobile]);
return (
<aside
className={`
${sidebarIsOpen ? "w-96" : "w-20"} z-40 h-screen transition-all bg-foreground-100
`}
>
{/* items-center lg:items-start */}
<div
className={`flex flex-col h-full px-3 py-4 overflow-y-auto scrollbar-hide ${sidebarIsOpen ? "items-start" : "items-center"}`}
>
<div
className={`flex flex-col gap-2 p-2 mb-4 w-full ${!sidebarIsOpen ? "items-center" : ""}`}
>
{sidebarIsOpen ? (
<div className="flex items-center justify-between w-full">
<Link
className="text-2xl font-semibold text-gray-900 dark:text-white rounded-lg cursor-pointer"
onPress={() => router.push("/")}
>
Toogether
</Link>
<button
onClick={() => setSidebarIsOpen(!sidebarIsOpen)}
>
<BsLayoutSidebarInset className="text-xl" />
</button>
</div>
) : (
<button
onClick={() => setSidebarIsOpen(!sidebarIsOpen)}
>
<BsReverseLayoutSidebarInsetReverse className="text-xl" />
</button>
)}
{sidebarIsOpen && (
<p className="text-sm text-foreground-400 dark:text-gray-400">
Manage classes, rooms, and users
</p>
)}
</div>
{items.map((group, index) => (
<div key={index} className={sidebarIsOpen ? "w-full" : ""}>
<section className="flex flex-col">
<ul className={`flex flex-col gap-2 w-full`}>
{group.map((item, index) => (
<li
key={index}
className={`${
pathName === item.href
? "bg-foreground-300"
: "hover:bg-foreground-200"
} rounded-md cursor-pointer w-full flex `}
>
<SidebarItem
href={item.href}
title={item.title}
icon={item.icon}
setOpen={setSidebarIsOpen}
showTitle={sidebarIsOpen}
isMobile={isMobile}
/>
</li>
))}
</ul>
</section>
{index < items.length - 1 && (
<Divider className="bg-foreground-300 my-4" />
)}
</div>
))}
<section
className={`mt-auto ${!sidebarIsOpen && "justify-center"}`}
>
<ThemeSwitcher />
</section>
</div>
</aside>
);
};

View File

@ -0,0 +1,37 @@
"use client";
import { Link } from "@nextui-org/react";
import { useRouter } from "next/navigation";
export type SidebarItemProps = {
title: string;
icon: React.ReactNode;
href: string;
setOpen: (open: boolean) => void;
showTitle: boolean;
isMobile: boolean;
};
export const SidebarItem = ({
title,
icon,
href,
setOpen,
showTitle = true,
isMobile
}: SidebarItemProps) => {
const router = useRouter();
return (
<Link
onPress={() => {
router.push(href);
if (isMobile) setOpen(false);
}}
color="foreground"
className="w-full p-2 gap-3 text-md"
>
<span className="text-xl">{icon}</span>
{showTitle && <span className="text-sm">{title}</span>}
</Link>
);
};

View File

@ -0,0 +1,26 @@
"use client";
import { Button } from "@nextui-org/react";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
export const ThemeSwitcher = () => {
const [mounted, setMounted] = useState(false);
const { theme, setTheme } = useTheme();
useEffect(() => {
setMounted(true);
}, []);
return (
<Button
size="sm"
variant="flat"
className="min-w-0"
onPress={() =>
mounted && setTheme(theme === "light" ? "dark" : "light")
}
>
{mounted && theme === "light" ? "🌑" : "☀️"}
</Button>
);
};

View File

@ -0,0 +1,81 @@
"use client";
import { User } from "@/app/interface/user";
import { usersService } from "@/app/services/users.service";
import {
Card,
Skeleton,
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
} from "@nextui-org/react";
import { useEffect, useState } from "react";
export const UserList = () => {
const [users, setUsers] = useState<User[]>();
useEffect(() => {
usersService.getAll().then((response) => {
setUsers(response.data);
});
}, []);
if (!users) {
return (
<Card className="w-full space-y-5 p-4" radius="lg">
<Skeleton className="rounded-lg">
<div className="h-10 rounded-lg bg-default-300" />
</Skeleton>
<div className="space-y-3">
<Skeleton className="w-3/5 rounded-lg">
<div className="h-3 w-3/5 rounded-lg bg-default-200" />
</Skeleton>
<Skeleton className="w-4/5 rounded-lg">
<div className="h-3 w-4/5 rounded-lg bg-default-200" />
</Skeleton>
<Skeleton className="w-2/5 rounded-lg">
<div className="h-3 w-2/5 rounded-lg bg-default-300" />
</Skeleton>
</div>
</Card>
);
}
if (users.length === 0) {
return (
<Card className="w-full p-4" radius="lg">
<p className="text-center text-lg text-gray-500 dark:text-gray-400">
No users found
</p>
</Card>
);
}
return (
<Table aria-label="List of users" className="w-full">
<TableHeader>
<TableColumn>USERNAME</TableColumn>
<TableColumn>CLASS</TableColumn>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id.toString()}>
<TableCell>{user.username}</TableCell>
<TableCell className="flex flex-wrap gap-1">
{user.Class.map((c) => (
<span
className="px-2 rounded-md bg-foreground-100"
key={c.id.toString()}
>
{c.name}
</span>
))}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
};

View File

@ -0,0 +1,16 @@
const NEXT_PUBLIC_API_URL = process.env.NEXT_PUBLIC_API_URL;
export const API_URLS = {
class: {
all: `${NEXT_PUBLIC_API_URL}/@me/class`,
},
room: {
all: (classId: string) =>
`${NEXT_PUBLIC_API_URL}/@me/class/${classId}/rooms`,
},
admin: {
user: {
all: `${NEXT_PUBLIC_API_URL}/user`,
},
},
};

View File

@ -0,0 +1,19 @@
import { authOptions } from "@/authOptions";
import { getServerSession } from "next-auth";
import { HeaderContent } from "../components/Header/contents";
export default async function Layout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getServerSession(authOptions);
return (
<div>
<HeaderContent user={session?.user} />
<main>{children}</main>
</div>
);
}

View File

@ -0,0 +1,16 @@
import { Metadata } from "next";
import { RoomTable } from "../components/Room/Table";
export const metadata: Metadata = {
title: "Toogether | Home",
description:
"Toogether is a platform that allows you to create and join rooms to study together.",
};
export default async function Page() {
return (
<div className="flex flex-col gap-8 p-4">
<RoomTable />
</div>
);
}

View File

@ -1,3 +1,3 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;

View File

@ -0,0 +1,5 @@
export interface Class {
id: string;
name: string;
createdAt: string;
}

17
src/app/interface/room.ts Normal file
View File

@ -0,0 +1,17 @@
export interface Room {
id: string;
name: string;
date: string;
Times: {
id: number;
startTime: string;
endTime: string;
roomId: string;
}[];
Presentator: {
id: string;
username: string;
role: string;
createdAt: string;
};
}

10
src/app/interface/user.ts Normal file
View File

@ -0,0 +1,10 @@
export interface User {
id: string;
username: string;
Class: {
id: string;
name: string;
createdAt: string;
}[];
}

View File

@ -1,15 +1,18 @@
import SessionProviderWrapper from "./components/SessionProviderWrapper"; import { getSession } from "next-auth/react";
import AppWrapper from "./components/AppWrapper";
import "./globals.css"; import "./globals.css";
export default function RootLayout({ export default async function RootLayout({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const session = await getSession()
return ( return (
<html lang="fr"> <html lang="fr" suppressHydrationWarning>
<body> <body>
<SessionProviderWrapper>{children}</SessionProviderWrapper> <AppWrapper session={session}>{children}</AppWrapper>
</body> </body>
</html> </html>
); );

View File

@ -1,28 +1,55 @@
import axios from "axios"; import axios from "axios";
import moment, { Moment } from "moment"; import moment, { Moment } from "moment";
import { getSession } from "next-auth/react"; import { getSession, signOut } from "next-auth/react";
let cachedAccessToken: string | null = null; moment.locale("fr");
let tokenExpirationAt: Moment | null = null;
let cachedAccessToken: string | null = null;
export const axiosInstance = axios.create({ let tokenExpirationAt: Moment | null = null;
baseURL: process.env.NEXT_PUBLIC_API_URL,
}); export const axiosInstance = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
axiosInstance.interceptors.request.use(async (config) => { });
if (tokenExpirationAt && moment().isBefore(tokenExpirationAt)) {
config.headers.Authorization = `Bearer ${cachedAccessToken}`; let isRefreshing = false;
return config; let refreshQueue: Array<(token: string) => void> = [];
}
axiosInstance.interceptors.request.use(async (config) => {
const session = await getSession(); if (tokenExpirationAt && moment().isBefore(tokenExpirationAt)) {
if (!session) { config.headers.Authorization = `Bearer ${cachedAccessToken}`;
throw new Error("User is not authenticated"); return config;
} }
cachedAccessToken = session.accessToken; if (isRefreshing) {
tokenExpirationAt = moment(session.accessTokenExpires); // Add the request to the queue
return new Promise((resolve) => {
config.headers.Authorization = `Bearer ${cachedAccessToken}`; refreshQueue.push((token: string) => {
return config; config.headers.Authorization = `Bearer ${token}`;
}); resolve(config);
});
});
}
isRefreshing = true;
try {
const session = await getSession();
if (!session || session.error) {
console.log("No session found, redirecting to login page");
await signOut({ callbackUrl: "/auth/login" });
// cancel the request
return new Promise(() => {});
}
cachedAccessToken = session.accessToken;
tokenExpirationAt = moment(session.accessTokenExpires);
// Execute the queue
refreshQueue.forEach((cb) => cb(cachedAccessToken!));
refreshQueue = [];
config.headers.Authorization = `Bearer ${cachedAccessToken}`;
return config;
} finally {
isRefreshing = false;
}
});

View File

@ -1,3 +0,0 @@
export const UppercaseFirstLetter = (str: string) => {
return str.slice(0, 1).toLocaleUpperCase() + str.slice(1);
}

View File

@ -1,13 +1,20 @@
import Header from "./components/Header"; import { Metadata } from "next";
import { FetchApi } from "./components/FetchApi"; import { Header } from "./components/Header";
import { RoomTable } from "./components/Room/Table";
const HomePage = () => { export const metadata: Metadata = {
title: "Toogether | Home",
description:
"Toogether is a platform that allows you to create and join rooms to study together.",
};
export default async function Page() {
return ( return (
<> <>
<Header /> <Header />
<FetchApi /> <main className="flex flex-col gap-8 p-4">
<RoomTable />
</main>
</> </>
); );
}; }
export default HomePage;

View File

@ -0,0 +1,7 @@
import { API_URLS } from "../constants/apiUrl.constant";
import { Class } from "../interface/class";
import { axiosInstance } from "../lib/axios";
const getAll = () => axiosInstance.get<Class[]>(API_URLS.class.all);
export const classesService = { getAll };

View File

@ -0,0 +1,7 @@
import { API_URLS } from "../constants/apiUrl.constant";
import { axiosInstance } from "../lib/axios";
import { Room } from "../interface/room";
const getAll = (classId: string) => axiosInstance.get<Room[]>(API_URLS.room.all(classId));
export const roomsService = { getAll };

View File

@ -0,0 +1,7 @@
import { API_URLS } from "../constants/apiUrl.constant";
import { User } from "../interface/user";
import { axiosInstance } from "../lib/axios";
const getAll = () => axiosInstance.get<User[]>(API_URLS.admin.user.all);
export const usersService = { getAll };

View File

@ -0,0 +1,32 @@
import { create } from "zustand";
import { classesService } from "../services/classes.service";
import { Class } from "../interface/class";
type ClassStoreState = {
classes: Class[];
};
type ClassStoreActions = {
_setClass: (classes: Class[]) => void;
fetchClass: () => Promise<Class[]>;
};
type ClassStore = ClassStoreState & ClassStoreActions;
const defaultState: ClassStoreState = {
classes: [],
};
export const useClassStore = create<ClassStore>()((set) => ({
...defaultState,
_setClass: (classes: Class[]) => {
set(() => ({
classes: classes,
}));
},
fetchClass: async () => {
const classResponse = await classesService.getAll();
useClassStore.getState()._setClass(classResponse.data);
return classResponse.data;
},
}));

View File

@ -0,0 +1,47 @@
import moment from "moment";
import { create } from "zustand";
import { Room } from "../interface/room";
import { useUserStore } from "./userStore";
import { roomsService } from "../services/rooms.service";
type RoomStoreState = {
future: Room[] | null;
actual: Room[] | null;
past: Room[] | null;
};
type RoomStoreActions = {
_setRooms: (rooms: Room[]) => void;
fetchRooms: () => void;
};
type RoomStore = RoomStoreState & RoomStoreActions;
const defaultState: RoomStoreState = {
future: null,
actual: null,
past: null,
};
export const useRoomStore = create<RoomStore>()((set) => ({
...defaultState,
_setRooms: (rooms) => {
const future = rooms.filter((room) =>
moment(room.date).isAfter(moment(), "day"),
);
const actual = rooms.filter((room) =>
moment(room.date).isSame(moment(), "day"),
);
const past = rooms.filter((room) =>
moment(room.date).isBefore(moment(), "day"),
);
set({ future, actual, past });
},
fetchRooms: async () => {
const selectedClassId = useUserStore.getState().currentClassId;
if (!selectedClassId) return;
const roomResponse = await roomsService.getAll(selectedClassId);
useRoomStore.getState()._setRooms(roomResponse.data);
},
}));

View File

@ -0,0 +1,37 @@
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
type UserStoreState = {
currentClassId: string | null;
sidebarIsOpen: boolean;
};
type UserStoreActions = {
setCurrentClassId: (classId: string) => void;
setSidebarIsOpen: (isOpen: boolean) => void;
};
type UserStore = UserStoreState & UserStoreActions;
const defaultState: UserStoreState = {
currentClassId: null,
sidebarIsOpen: false,
};
export const useUserStore = create<UserStore>()(
persist(
(set) => ({
...defaultState,
setCurrentClassId: (classId: string) => {
set({ currentClassId: classId });
},
setSidebarIsOpen: (isOpen: boolean) => {
set({ sidebarIsOpen: isOpen });
},
}),
{
name: "userStore",
storage: createJSONStorage(() => localStorage),
},
),
);

View File

@ -1,12 +1,13 @@
declare namespace NodeJS { declare namespace NodeJS {
interface ProcessEnv { interface ProcessEnv {
OAUTH_PROVIDER_NAME: string; OAUTH_PROVIDER_NAME: string;
OAUTH_CLIENT_ID: string; OAUTH_CLIENT_ID: string;
OAUTH_CLIENT_SECRET: string; OAUTH_CLIENT_SECRET: string;
OAUTH_AUTHORIZATION_URL: string; OAUTH_AUTHORIZATION_URL: string;
OAUTH_TOKEN_URL: string; OAUTH_TOKEN_URL: string;
OAUTH_USERINFO_URL: string; OAUTH_USERINFO_URL: string;
OAUTH_ISSUER: string; OAUTH_ISSUER: string;
OAUTH_JWKS_ENDPOINT: string; OAUTH_JWKS_ENDPOINT: string;
} OAUTH_SCOPES: string;
} }
}

View File

@ -1,42 +1,63 @@
/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unused-vars */
import { Moment } from "moment"; import { Moment } from "moment";
import NextAuth, { DefaultSession } from "next-auth"; import { DefaultSession } from "next-auth";
import { JWT } from "next-auth/jwt"; import "next-auth/jwt";
interface User { interface User {
id: string; id: string;
sub: string; sub: string;
name: string; name: string;
preferred_username: string; preferred_username: string;
given_name: string; given_name: string;
family_name: string; family_name: string;
} email: string;
roles: string[];
declare module "next-auth" { }
interface Session extends DefaultSession {
accessToken: string; export interface JWTDecoded {
refreshToken: string; realm_access: {
accessTokenExpires: Moment; roles: string[];
error?: Error; };
user: User; }
}
declare module "next-auth" {
interface Account { interface Session extends DefaultSession {
expires_at: number; accessToken: string;
access_token: string; refreshToken: string;
refresh_token: string; accessTokenExpires: Moment;
} error?: string;
} user: User;
}
declare module "next-auth/jwt" {
interface JWT { interface Account {
accessToken: string; provider: string;
accessTokenExpires: Moment; type: string;
refreshToken: string; providerAccountId: string;
error?: Error; access_token: string;
user: User | AdapterUser; expires_at: number;
iat: number; refresh_expires_in: number;
exp: number; refresh_token: string;
jti: string; token_type: string;
} id_token: string;
} session_state: string;
scope: string;
}
}
declare module "next-auth/jwt" {
interface JWT {
// Default properties
name: string;
email: string;
picture: string;
sub: string;
// Custom properties
accessToken: string;
accessTokenExpires: Moment;
refreshToken: string;
refreshTokenExpires: Moment | undefined;
error?: string;
user: User | AdapterUser;
}
}

13
src/app/utils/initial.ts Normal file
View File

@ -0,0 +1,13 @@
export const getInitials = (name: string) => {
if (!name) return "";
const nameParts = name.split(" ");
if (nameParts.length === 1) {
return name;
}
const firstInitial = nameParts[0]?.[0] || "";
const secondInitial = nameParts[1]?.[0] || nameParts[0]?.[1] || "";
return firstInitial + secondInitial;
};

View File

@ -1,104 +1,153 @@
import axios from "axios"; import axios, { AxiosResponse } from "axios";
import moment from "moment"; import moment from "moment-timezone";
import { AuthOptions, Session } from "next-auth"; import { AuthOptions, Session } from "next-auth";
import { JWT } from "next-auth/jwt"; import { JWT } from "next-auth/jwt";
import jsonwebtoken from "jsonwebtoken";
export const authOptions: AuthOptions = { import { JWTDecoded } from "./app/types/next-auth";
providers: [
{ moment.tz.setDefault("America/New_York");
id: "oauth",
name: process.env.OAUTH_PROVIDER_NAME, export const authOptions: AuthOptions = {
type: "oauth", providers: [
clientId: process.env.OAUTH_CLIENT_ID, {
clientSecret: process.env.OAUTH_CLIENT_SECRET, id: "oauth",
authorization: { name: process.env.OAUTH_PROVIDER_NAME,
url: process.env.OAUTH_AUTHORIZATION_URL, type: "oauth",
params: { clientId: process.env.OAUTH_CLIENT_ID,
scope: "openid profile offline_access", clientSecret: process.env.OAUTH_CLIENT_SECRET,
response_type: "code", authorization: {
}, url: process.env.OAUTH_AUTHORIZATION_URL,
}, params: {
checks: ["pkce", "state"], scope: process.env.OAUTH_SCOPES,
idToken: true, response_type: "code",
token: process.env.OAUTH_TOKEN_URL, },
userinfo: process.env.OAUTH_USERINFO_URL, },
issuer: process.env.OAUTH_ISSUER, checks: ["pkce", "state"],
jwks_endpoint: process.env.OAUTH_JWKS_ENDPOINT, idToken: true,
profile(profile: Session["user"]) { token: process.env.OAUTH_TOKEN_URL,
return { userinfo: process.env.OAUTH_USERINFO_URL,
id: profile.sub || profile.id, issuer: process.env.OAUTH_ISSUER,
name: jwks_endpoint: process.env.OAUTH_JWKS_ENDPOINT,
profile.name || wellKnown: `${process.env.OAUTH_ISSUER}/.well-known/openid-configuration`,
profile.preferred_username || profile(profile: Session["user"]) {
`${profile.given_name} ${profile.family_name}`, return {
}; id: profile.sub || profile.id,
}, name:
}, profile.name ||
], profile.preferred_username ||
callbacks: { `${profile.given_name} ${profile.family_name}`,
async jwt({ token, account, user }) { email: profile.email,
if (account && user) { };
token.accessToken = account.access_token; },
token.accessTokenExpires = moment(account.expires_at * 1000).subtract(5, "s"); },
token.refreshToken = account.refresh_token; ],
token.user = user; callbacks: {
return token; async jwt({ token, account, user: profile }) {
} // Initial sign in
if (account && profile) {
if (moment().isBefore(moment(token.accessTokenExpires))) { token.accessToken = account.access_token;
return token; token.refreshToken = account.refresh_token;
}
token.accessTokenExpires = moment.unix(account.expires_at);
return refreshAccessToken(token);
}, token.refreshTokenExpires =
async session({ session, token }) { account.refresh_expires_in != 0
if (token) { ? moment().add(account.refresh_expires_in, "seconds")
session.user = token.user; : undefined;
session.accessToken = token.accessToken;
session.accessTokenExpires = token.accessTokenExpires; const accessTokenDecode = jsonwebtoken.decode(
session.error = token.error; account.access_token,
} ) as JWTDecoded;
return session; token.user = {
}, ...profile,
}, roles: accessTokenDecode.realm_access.roles,
pages: { };
signIn: "/auth/login",
signOut: "/auth/logout", return token;
} }
};
if (
const refreshAccessToken = async (token: JWT): Promise<JWT> => { token.refreshTokenExpires &&
const response = await axios.post<{ moment().isAfter(token.refreshTokenExpires)
access_token: string; )
expires_in: number; return {
refresh_token: string; ...token,
error_description?: string; error: "Refresh token has expired",
}>( };
process.env.OAUTH_TOKEN_URL,
{ // Return previous token if the access token has not expired yet
grant_type: "refresh_token", if (moment().isBefore(moment(token.accessTokenExpires)))
refresh_token: token.refreshToken, return token;
client_id: process.env.OAUTH_CLIENT_ID,
client_secret: process.env.OAUTH_CLIENT_SECRET, // Access token has expired, try to refresh it
}, return refreshAccessToken(token);
{ },
headers: { async session({ session, token }) {
"Content-Type": "application/x-www-form-urlencoded", if (token) {
}, session.user = token.user;
} session.accessToken = token.accessToken;
); session.accessTokenExpires = token.accessTokenExpires;
session.error = token.error;
if (response.status != 200) { }
throw new Error(
response.data.error_description || "Failed to refresh access token" return session;
); },
} },
pages: {
return { signIn: "/auth/login",
...token, signOut: "/auth/logout",
accessToken: response.data.access_token, },
accessTokenExpires: moment().add(response.data.expires_in, "seconds").subtract(5, "s"), };
refreshToken: response.data.refresh_token,
}; const refreshAccessToken = async (token: JWT): Promise<JWT> => {
}; try {
const response = await axios.post<{
access_token: string;
expires_in: number;
id_token: string;
"not-before-policy": number;
refresh_expires_in: number;
refresh_token: string;
scope: string;
session_state: string;
token_type: string;
error?: string;
error_description?: string;
}>(
process.env.OAUTH_TOKEN_URL,
{
grant_type: "refresh_token",
refresh_token: token.refreshToken,
client_id: process.env.OAUTH_CLIENT_ID,
client_secret: process.env.OAUTH_CLIENT_SECRET,
},
{
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
},
);
if (response.status !== 200) throw response.data;
return {
...token,
accessToken: response.data.access_token,
accessTokenExpires: moment()
.add(response.data.expires_in, "seconds")
.subtract(5, "s"),
refreshToken: response.data.refresh_token,
};
} catch (e) {
console.error(
"Error refreshing access token",
(e as AxiosResponse).data,
);
return {
...token,
error: "RefreshAccessTokenError",
};
}
};

View File

@ -1,25 +1,33 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getToken } from "next-auth/jwt"; import { getToken } from "next-auth/jwt";
export async function middleware(req: NextRequest) { export async function middleware(req: NextRequest) {
const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET }); const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET });
const isAuth = !!token; const isAuth = !!token;
const url = req.nextUrl.clone(); const url = req.nextUrl.clone();
if (isAuth && url.pathname === "/auth/login") { if (isAuth && url.pathname === "/auth/login") {
url.pathname = "/"; url.pathname = "/";
return NextResponse.redirect(url); return NextResponse.redirect(url);
} }
if (!isAuth && url.pathname !== "/auth/login") { if (!isAuth && url.pathname !== "/auth/login") {
url.pathname = "/auth/login"; url.pathname = "/auth/login";
return NextResponse.redirect(url); return NextResponse.redirect(url);
} }
return NextResponse.next(); if (
} !token?.user.roles.includes("admin") &&
url.pathname.startsWith("/admin")
export const config = { ) {
matcher: ["/((?!api|_next|static|favicon.ico).*)"], url.pathname = "/";
}; return NextResponse.redirect(url);
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!api|_next|static|favicon.ico).*)"],
};

View File

@ -1,10 +1,12 @@
import type { Config } from "tailwindcss"; import type { Config } from "tailwindcss";
import { nextui } from "@nextui-org/react";
export default { export default {
content: [ content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}", "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}", "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}", "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
"./node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}",
], ],
theme: { theme: {
extend: { extend: {
@ -12,17 +14,8 @@ export default {
background: "var(--background)", background: "var(--background)",
foreground: "var(--foreground)", foreground: "var(--foreground)",
}, },
animation: {
"gradient-x": "gradient-x 5s ease infinite",
},
keyframes: {
"gradient-x": {
"0%": { "background-position": "0% 50%" },
"50%": { "background-position": "100% 50%" },
"100%": { "background-position": "0% 50%" },
},
},
}, },
}, },
plugins: [], darkMode: "class",
plugins: [nextui()],
} satisfies Config; } satisfies Config;

2133
yarn.lock

File diff suppressed because it is too large Load Diff