Compare commits

...

45 Commits

Author SHA1 Message Date
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
52 changed files with 3407 additions and 366 deletions

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
FROM node:18-alpine AS base
FROM node:20-alpine AS base
# Install dependencies only when needed
FROM base AS deps

View File

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

View File

@ -1,21 +1,33 @@
{
"name": "client",
"name": "@toogether/webapp",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"dev": "next dev -p 4000",
"build": "next build",
"start": "next start",
"lint": "next lint"
"build:docker": "docker build -t toogether/webapp .",
"start": "next start -p 4000",
"start:prod": "docker compose up --force-recreate -d",
"lint": "next lint",
"format": "prettier --write ."
},
"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-hooks": "^5.1.0",
"framer-motion": "^11.15.0",
"jsonwebtoken": "^9.0.2",
"moment": "^2.30.1",
"next": "15.0.3",
"next-auth": "^4.24.10",
"next-themes": "^0.4.4",
"react": "19.0.0-rc-66855b96-20241106",
"react-dom": "19.0.0-rc-66855b96-20241106",
"react-icons": "^5.4.0",
"zustand": "^5.0.2"
},
"devDependencies": {
@ -26,6 +38,7 @@
"eslint": "^8",
"eslint-config-next": "15.0.3",
"postcss": "^8",
"prettier": "^3.4.2",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}

View File

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

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

@ -0,0 +1,10 @@
import { Sidebar } from "@/app/components/Sidebar";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="flex">
<Sidebar />
<main>{children}</main>
</div>
);
}

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,11 @@
"use client";
import { authOptions } from "@/authOptions";
import { signIn } from "next-auth/react";
import { Metadata } from "next";
import { ProvidersList } from "./providersList";
const LoginPage = () => {
const provider = authOptions.providers[0];
export const metadata: Metadata = {
title: "Toogether | Connexion",
};
export default function LoginPage() {
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 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>
<h3 className="font-bold text-xl">Via</h3>
<ul>
<li key={provider.id}>
<button
className="bg-white text-black p-2 rounded-md"
onClick={() => signIn(provider.id)}
>
{provider.name}
</button>
</li>
</ul>
<ProvidersList />
</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";
import { useEffect } from "react";
import { signOut, useSession } from "next-auth/react";
const LogoutPage = () => {
const session = useSession();
useEffect(() => {
if (session) {
signOut();
}
}, [session]);
return null;
};
export default LogoutPage;
"use client";
import { useEffect } from "react";
import { signOut, useSession } from "next-auth/react";
const LogoutPage = () => {
const session = useSession();
useEffect(() => {
if (session) {
signOut();
}
}, [session]);
return null;
};
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

@ -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,82 @@
"use client";
import { Divider, Link } from "@nextui-org/react";
import { useRouter } 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 { ThemeSwitcher } from "../ThemeSwitcher/ThemeSwitcher";
import { SidebarItem } from "./item";
export const Sidebar = () => {
const router = useRouter();
return (
<aside className="h-screen">
<div className="flex flex-col w-64 gap-2 h-full px-3 py-4 overflow-y-auto bg-foreground-100">
<ul className="font-medium gap-2">
<li>
<Link
className="flex justify-center p-2 text-gray-900 dark:text-white rounded-lg cursor-pointer"
onPress={() => router.push("/")}
>
Toogether
</Link>
</li>
</ul>
<section className="flex flex-col">
<ul className="flex flex-col gap-2">
<li className="hover:bg-foreground-300 rounded-md cursor-pointer">
<SidebarItem
href="/admin"
title="Dashboard"
icon={<IoMdStats />}
/>
</li>
<li className="hover:bg-foreground-300 rounded-md cursor-pointer">
<SidebarItem
href="/admin/users"
title="Users"
icon={<FaRegUser />}
/>
</li>
<li className="hover:bg-foreground-300 rounded-md cursor-pointer">
<SidebarItem
href="/admin/classes"
title="Classes"
icon={<MdInbox />}
/>
</li>
<li className="hover:bg-foreground-300 rounded-md cursor-pointer">
<SidebarItem
href="/admin/rooms"
title="Rooms"
icon={<FaDoorClosed />}
/>
</li>
</ul>
</section>
<Divider className="bg-foreground-300 my-4" />
<section className="flex flex-col">
<ul className="flex flex-col gap-2">
<li className="hover:bg-foreground-300 rounded-md cursor-pointer">
<SidebarItem
href="/admin/settings"
title="Settings"
icon={<FiSettings />}
/>
</li>
</ul>
</section>
<section className="mt-auto flex justify-between">
<ThemeSwitcher />
</section>
</div>
</aside>
);
};

View File

@ -0,0 +1,26 @@
"use client";
import { Link } from "@nextui-org/react";
import { useRouter } from "next/navigation";
export const SidebarItem = ({
title,
icon,
href,
}: {
title: string;
icon: React.ReactNode;
href: string;
}) => {
const router = useRouter();
return (
<Link
onPress={() => router.push(href)}
color="foreground"
className="w-full px-2 py-1 gap-2 text-xl"
>
{icon}
{title}
</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,61 @@
"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-[300px] 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>
);
}
return (
<Table aria-label="List of users" className="w-[300px]">
<TableHeader>
<TableColumn>USERNAME</TableColumn>
</TableHeader>
<TableBody>
{users &&
users.map((user) => (
<TableRow key={user.id.toString()}>
<TableCell>{user.username}</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 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;
};
}

View File

@ -0,0 +1,4 @@
export interface User {
id: string;
username: 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";
export default function RootLayout({
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getSession()
return (
<html lang="fr">
<html lang="fr" suppressHydrationWarning>
<body>
<SessionProviderWrapper>{children}</SessionProviderWrapper>
<AppWrapper session={session}>{children}</AppWrapper>
</body>
</html>
);

View File

@ -1,28 +1,55 @@
import axios from "axios";
import moment, { Moment } from "moment";
import { getSession } from "next-auth/react";
let cachedAccessToken: string | null = null;
let tokenExpirationAt: Moment | null = null;
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}`;
return config;
}
const session = await getSession();
if (!session) {
throw new Error("User is not authenticated");
}
cachedAccessToken = session.accessToken;
tokenExpirationAt = moment(session.accessTokenExpires);
config.headers.Authorization = `Bearer ${cachedAccessToken}`;
return config;
});
import axios from "axios";
import moment, { Moment } from "moment";
import { getSession, signOut } from "next-auth/react";
moment.locale("fr");
let cachedAccessToken: string | null = null;
let tokenExpirationAt: Moment | null = null;
export const axiosInstance = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
});
let isRefreshing = false;
let refreshQueue: Array<(token: string) => void> = [];
axiosInstance.interceptors.request.use(async (config) => {
if (tokenExpirationAt && moment().isBefore(tokenExpirationAt)) {
config.headers.Authorization = `Bearer ${cachedAccessToken}`;
return config;
}
if (isRefreshing) {
// Add the request to the queue
return new Promise((resolve) => {
refreshQueue.push((token: string) => {
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 { FetchApi } from "./components/FetchApi";
import { Metadata } from "next";
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 (
<>
<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,31 @@
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
type UserStoreState = {
currentClassId: string | null;
};
type UserStoreActions = {
setCurrentClassId: (classId: string) => void;
};
type UserStore = UserStoreState & UserStoreActions;
const defaultState: UserStoreState = {
currentClassId: null,
};
export const useUserStore = create<UserStore>()(
persist(
(set) => ({
...defaultState,
setCurrentClassId: (classId: string) => {
set({ currentClassId: classId });
},
}),
{
name: "userStore",
storage: createJSONStorage(() => localStorage),
},
),
);

View File

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

View File

@ -1,42 +1,63 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Moment } from "moment";
import NextAuth, { DefaultSession } from "next-auth";
import { JWT } from "next-auth/jwt";
interface User {
id: string;
sub: string;
name: string;
preferred_username: string;
given_name: string;
family_name: string;
}
declare module "next-auth" {
interface Session extends DefaultSession {
accessToken: string;
refreshToken: string;
accessTokenExpires: Moment;
error?: Error;
user: User;
}
interface Account {
expires_at: number;
access_token: string;
refresh_token: string;
}
}
declare module "next-auth/jwt" {
interface JWT {
accessToken: string;
accessTokenExpires: Moment;
refreshToken: string;
error?: Error;
user: User | AdapterUser;
iat: number;
exp: number;
jti: string;
}
}
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Moment } from "moment";
import { DefaultSession } from "next-auth";
import "next-auth/jwt";
interface User {
id: string;
sub: string;
name: string;
preferred_username: string;
given_name: string;
family_name: string;
email: string;
roles: string[];
}
export interface JWTDecoded {
realm_access: {
roles: string[];
};
}
declare module "next-auth" {
interface Session extends DefaultSession {
accessToken: string;
refreshToken: string;
accessTokenExpires: Moment;
error?: string;
user: User;
}
interface Account {
provider: string;
type: string;
providerAccountId: string;
access_token: string;
expires_at: number;
refresh_expires_in: number;
refresh_token: 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;
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,148 @@
import axios from "axios";
import moment from "moment";
import { AuthOptions, Session } from "next-auth";
import { JWT } from "next-auth/jwt";
export const authOptions: AuthOptions = {
providers: [
{
id: "oauth",
name: process.env.OAUTH_PROVIDER_NAME,
type: "oauth",
clientId: process.env.OAUTH_CLIENT_ID,
clientSecret: process.env.OAUTH_CLIENT_SECRET,
authorization: {
url: process.env.OAUTH_AUTHORIZATION_URL,
params: {
scope: "openid profile offline_access",
response_type: "code",
},
},
checks: ["pkce", "state"],
idToken: true,
token: process.env.OAUTH_TOKEN_URL,
userinfo: process.env.OAUTH_USERINFO_URL,
issuer: process.env.OAUTH_ISSUER,
jwks_endpoint: process.env.OAUTH_JWKS_ENDPOINT,
profile(profile: Session["user"]) {
return {
id: profile.sub || profile.id,
name:
profile.name ||
profile.preferred_username ||
`${profile.given_name} ${profile.family_name}`,
};
},
},
],
callbacks: {
async jwt({ token, account, user }) {
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;
return token;
}
if (moment().isBefore(moment(token.accessTokenExpires))) {
return token;
}
return refreshAccessToken(token);
},
async session({ session, token }) {
if (token) {
session.user = token.user;
session.accessToken = token.accessToken;
session.accessTokenExpires = token.accessTokenExpires;
session.error = token.error;
}
return session;
},
},
pages: {
signIn: "/auth/login",
signOut: "/auth/logout",
}
};
const refreshAccessToken = async (token: JWT): Promise<JWT> => {
const response = await axios.post<{
access_token: string;
expires_in: number;
refresh_token: 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 new Error(
response.data.error_description || "Failed to refresh access token"
);
}
return {
...token,
accessToken: response.data.access_token,
accessTokenExpires: moment().add(response.data.expires_in, "seconds").subtract(5, "s"),
refreshToken: response.data.refresh_token,
};
};
import axios from "axios";
import moment from "moment";
import { AuthOptions, Session } from "next-auth";
import { JWT } from "next-auth/jwt";
import jsonwebtoken from "jsonwebtoken";
import { JWTDecoded } from "./app/types/next-auth";
moment.locale("fr");
export const authOptions: AuthOptions = {
providers: [
{
id: "oauth",
name: process.env.OAUTH_PROVIDER_NAME,
type: "oauth",
clientId: process.env.OAUTH_CLIENT_ID,
clientSecret: process.env.OAUTH_CLIENT_SECRET,
authorization: {
url: process.env.OAUTH_AUTHORIZATION_URL,
params: {
scope: "openid email profile",
response_type: "code",
},
},
checks: ["pkce", "state"],
idToken: true,
token: process.env.OAUTH_TOKEN_URL,
userinfo: process.env.OAUTH_USERINFO_URL,
issuer: process.env.OAUTH_ISSUER,
jwks_endpoint: process.env.OAUTH_JWKS_ENDPOINT,
wellKnown: `${process.env.OAUTH_ISSUER}/.well-known/openid-configuration`,
profile(profile: Session["user"]) {
return {
id: profile.sub || profile.id,
name:
profile.name ||
profile.preferred_username ||
`${profile.given_name} ${profile.family_name}`,
email: profile.email,
};
},
},
],
callbacks: {
async jwt({ token, account, user: profile }) {
// Initial sign in
if (account && profile) {
token.accessToken = account.access_token;
token.refreshToken = account.refresh_token;
token.accessTokenExpires = moment.unix(account.expires_at);
token.refreshTokenExpires = moment().add(
account.refresh_expires_in,
"seconds",
);
const accessTokenDecode = jsonwebtoken.decode(
account.access_token,
) as JWTDecoded;
token.user = {
...profile,
roles: accessTokenDecode.realm_access.roles,
};
return token;
}
// Return previous token if the access token has not expired yet
if (moment().isBefore(moment(token.accessTokenExpires)))
return token;
if (
token.refreshTokenExpires &&
moment().isAfter(token.refreshTokenExpires)
)
return {
...token,
error: "Refresh token has expired",
};
// Access token has expired, try to refresh it
return refreshAccessToken(token);
},
async session({ session, token }) {
if (token) {
session.user = token.user;
session.accessToken = token.accessToken;
session.accessTokenExpires = token.accessTokenExpires;
session.error = token.error;
}
return session;
},
},
pages: {
signIn: "/auth/login",
signOut: "/auth/logout",
},
};
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 {
return {
...token,
error: "RefreshAccessTokenError",
};
}
};

View File

@ -1,25 +1,33 @@
import { NextRequest, NextResponse } from "next/server";
import { getToken } from "next-auth/jwt";
export async function middleware(req: NextRequest) {
const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET });
const isAuth = !!token;
const url = req.nextUrl.clone();
if (isAuth && url.pathname === "/auth/login") {
url.pathname = "/";
return NextResponse.redirect(url);
}
if (!isAuth && url.pathname !== "/auth/login") {
url.pathname = "/auth/login";
return NextResponse.redirect(url);
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!api|_next|static|favicon.ico).*)"],
};
import { NextRequest, NextResponse } from "next/server";
import { getToken } from "next-auth/jwt";
export async function middleware(req: NextRequest) {
const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET });
const isAuth = !!token;
const url = req.nextUrl.clone();
if (isAuth && url.pathname === "/auth/login") {
url.pathname = "/";
return NextResponse.redirect(url);
}
if (!isAuth && url.pathname !== "/auth/login") {
url.pathname = "/auth/login";
return NextResponse.redirect(url);
}
if (
!token?.user.roles.includes("admin") &&
url.pathname.startsWith("/admin")
) {
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 { nextui } from "@nextui-org/react";
export default {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
"./node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
@ -12,17 +14,8 @@ export default {
background: "var(--background)",
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;

2124
yarn.lock

File diff suppressed because it is too large Load Diff