diff --git a/.prettierrc b/.prettierrc index 1035938..d40b652 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,5 +1,5 @@ { - "tabWidth": 4, - "useTabs": true, - "semi": true + "tabWidth": 4, + "useTabs": true, + "semi": true } diff --git a/docker-compose.yml b/docker-compose.yml index ad0d926..1b0cfbb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,5 @@ -services: - webapp: - image: toogether/webapp - ports: - - "80:3000" +services: + webapp: + image: toogether/webapp + ports: + - "80:3000" diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts index df3a04c..13b941a 100644 --- a/src/app/api/auth/[...nextauth]/route.ts +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -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 }; diff --git a/src/app/auth/logout/page.tsx b/src/app/auth/logout/page.tsx index 9f582af..c0d45ba 100644 --- a/src/app/auth/logout/page.tsx +++ b/src/app/auth/logout/page.tsx @@ -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; diff --git a/src/app/components/AppWrapper.tsx b/src/app/components/AppWrapper.tsx index 97b7d2c..36e0cc5 100644 --- a/src/app/components/AppWrapper.tsx +++ b/src/app/components/AppWrapper.tsx @@ -1,21 +1,21 @@ -"use client"; - -import { NextUIProvider } from "@nextui-org/react"; -import { SessionProvider } from "next-auth/react"; -import { ThemeProvider as NextThemesProvider } from "next-themes"; - -export default function AppWrapper({ - children, -}: { - children: React.ReactNode; -}) { - return ( - - - - {children} - - - - ); -} +"use client"; + +import { NextUIProvider } from "@nextui-org/react"; +import { SessionProvider } from "next-auth/react"; +import { ThemeProvider as NextThemesProvider } from "next-themes"; + +export default function AppWrapper({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + + {children} + + + + ); +} diff --git a/src/app/components/Header/index.tsx b/src/app/components/Header/index.tsx index 2847409..3fc1c19 100644 --- a/src/app/components/Header/index.tsx +++ b/src/app/components/Header/index.tsx @@ -1,100 +1,108 @@ -'use client' -import { - Avatar, - Button, - Dropdown, - DropdownItem, - DropdownMenu, - DropdownTrigger, - Navbar, - NavbarBrand, - NavbarContent, - NavbarItem -} from '@nextui-org/react' -import { useSession } from 'next-auth/react' -import { ThemeSwitcher } from '../ThemeSwitcher/ThemeSwitcher' -import { axiosInstance } from '@/app/lib/axios' -import { useEffect, useState } from 'react' -import { useRouter } from 'next/navigation' - -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 -} - -export const Header = () => { - const { data: session } = useSession() - const router = useRouter() - - const [userProfile, setUserProfile] = useState<{ - id: string - username: string - role: 'ADMIN' | 'STUDENT' - }>() - - const initials = session?.user?.name ? getInitials(session.user.name) : '' - - const fetchUserProfile = async () => { - return await axiosInstance<{ - id: string - username: string - role: 'ADMIN' | 'STUDENT' - }>('/@me') - } - - useEffect(() => { - fetchUserProfile().then(r => { - setUserProfile(r.data) - }) - }, []) - - return ( - - -

Toogether

-
- - - - - - - - -

Signed in as

-

{session?.user?.name}

-
- Settings - - Logout - -
-
- - - - {userProfile?.role === 'ADMIN' ? ( - - - - ) : null} -
-
- ) -} +"use client"; +import { + Avatar, + Button, + Dropdown, + DropdownItem, + DropdownMenu, + DropdownTrigger, + Navbar, + NavbarBrand, + NavbarContent, + NavbarItem, +} from "@nextui-org/react"; +import { useSession } from "next-auth/react"; +import { ThemeSwitcher } from "../ThemeSwitcher/ThemeSwitcher"; +import { axiosInstance } from "@/app/lib/axios"; +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; + +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; +}; + +export const Header = () => { + const { data: session } = useSession(); + const router = useRouter(); + + const [userProfile, setUserProfile] = useState<{ + id: string; + username: string; + role: "ADMIN" | "STUDENT"; + }>(); + + const initials = session?.user?.name ? getInitials(session.user.name) : ""; + + const fetchUserProfile = async () => { + return await axiosInstance<{ + id: string; + username: string; + role: "ADMIN" | "STUDENT"; + }>("/@me"); + }; + + useEffect(() => { + fetchUserProfile().then((r) => { + setUserProfile(r.data); + }); + }, []); + + return ( + + +

Toogether

+
+ + + + + + + + +

Signed in as

+

+ {session?.user?.name} +

+
+ Settings + + Logout + +
+
+ + + + {userProfile?.role === "ADMIN" ? ( + + + + ) : null} +
+
+ ); +}; diff --git a/src/app/components/Room/Card.tsx b/src/app/components/Room/Card.tsx index ca41a1f..35cdc24 100644 --- a/src/app/components/Room/Card.tsx +++ b/src/app/components/Room/Card.tsx @@ -1,6 +1,6 @@ -'use client'; +"use client"; -import { parseDate, Time } from '@internationalized/date'; +import { parseDate, Time } from "@internationalized/date"; import { Button, @@ -9,55 +9,55 @@ import { CardHeader, DateInput, Divider, - TimeInput -} from '@nextui-org/react'; -import { Room } from './Room'; -import moment from 'moment'; -import { useRouter } from 'next/navigation'; + TimeInput, +} from "@nextui-org/react"; +import { Room } from "./Room"; +import moment from "moment"; +import { useRouter } from "next/navigation"; export const RoomCard = ({ id, name, date, Times, Presentator }: Room) => { const router = useRouter(); return ( - + -
-

{name}

-

+

+

{name}

+

{Presentator.username}

- {Times.map(time => ( -
+ {Times.map((time) => ( +
-
+
- @@ -66,15 +66,13 @@ export const RoomCard = ({ id, name, date, Times, Presentator }: Room) => { ))} {moment(date).dayOfYear() === moment().dayOfYear() && ( -
+
- ) -} + ); +}; diff --git a/src/app/lib/axios.ts b/src/app/lib/axios.ts index 39346df..7e5437f 100644 --- a/src/app/lib/axios.ts +++ b/src/app/lib/axios.ts @@ -1,30 +1,30 @@ -import axios from "axios"; -import moment, { Moment } from "moment"; -import { getSession } 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, -}); - -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 } 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, +}); + +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; +}); diff --git a/src/app/lib/stringUtils.ts b/src/app/lib/stringUtils.ts index bc1566c..9aed25d 100644 --- a/src/app/lib/stringUtils.ts +++ b/src/app/lib/stringUtils.ts @@ -1,3 +1,3 @@ -export const UppercaseFirstLetter = (str: string) => { - return str.slice(0, 1).toLocaleUpperCase() + str.slice(1); -} \ No newline at end of file +export const UppercaseFirstLetter = (str: string) => { + return str.slice(0, 1).toLocaleUpperCase() + str.slice(1); +}; diff --git a/src/app/page.tsx b/src/app/page.tsx index 96faa32..b74e5b3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,11 +1,11 @@ -'use client'; -import { Card, Divider, Skeleton } from '@nextui-org/react'; -import moment from 'moment'; -import { useEffect, useState } from 'react'; -import { Room } from './components/Room/Room'; -import { RoomList } from './components/Room/List'; -import { Header } from './components/Header'; -import { axiosInstance } from './lib/axios'; +"use client"; +import { Card, Divider, Skeleton } from "@nextui-org/react"; +import moment from "moment"; +import { useEffect, useState } from "react"; +import { Room } from "./components/Room/Room"; +import { RoomList } from "./components/Room/List"; +import { Header } from "./components/Header"; +import { axiosInstance } from "./lib/axios"; const HomePage = () => { const [roomsLoading, setRoomsLoading] = useState(true); @@ -16,30 +16,30 @@ const HomePage = () => { }>({ future: [], actual: [], - past: [] + past: [], }); useEffect(() => { axiosInstance - .get<{ id: string; name: string; createdAt: string }[]>( - '/@me/class' - ) - .then(classResponse => { + .get< + { id: string; name: string; createdAt: string }[] + >("/@me/class") + .then((classResponse) => { if (classResponse.data.length) axiosInstance - .get( - `/@me/class/${classResponse.data[0].id}/rooms` - ) - .then(classes => { + .get< + Room[] + >(`/@me/class/${classResponse.data[0].id}/rooms`) + .then((classes) => { // Filter rooms by date, get future, actual and past rooms - const future = classes.data.filter(room => - moment(room.date).isAfter(moment(), 'day') + const future = classes.data.filter((room) => + moment(room.date).isAfter(moment(), "day"), ); - const actual = classes.data.filter(room => - moment(room.date).isSame(moment(), 'day') + const actual = classes.data.filter((room) => + moment(room.date).isSame(moment(), "day"), ); - const past = classes.data.filter(room => - moment(room.date).isBefore(moment()) + const past = classes.data.filter((room) => + moment(room.date).isBefore(moment()), ); setRooms({ future, actual, past }); @@ -51,17 +51,17 @@ const HomePage = () => { return ( <>
-
-
-

Upcoming

+
+
+

Upcoming

-
-

Current

+
+

Current

-
-

Past

+
+

Past

diff --git a/src/app/types/env.d.ts b/src/app/types/env.d.ts index c704b2d..e33577d 100644 --- a/src/app/types/env.d.ts +++ b/src/app/types/env.d.ts @@ -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; + } +} diff --git a/src/app/types/next-auth.d.ts b/src/app/types/next-auth.d.ts index e79cde0..d0cbdeb 100644 --- a/src/app/types/next-auth.d.ts +++ b/src/app/types/next-auth.d.ts @@ -1,42 +1,42 @@ -/* 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 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; + } +} diff --git a/src/authOptions.ts b/src/authOptions.ts index 702670a..5aac6ab 100644 --- a/src/authOptions.ts +++ b/src/authOptions.ts @@ -1,106 +1,110 @@ -import axios from "axios"; -import moment from "moment"; -import { AuthOptions, Session } from "next-auth"; -import { JWT } from "next-auth/jwt"; - -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 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 => { - 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"; + +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 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 => { + 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, + }; +}; diff --git a/src/middleware.ts b/src/middleware.ts index 4910976..4dff0c1 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,25 +1,25 @@ -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); + } + + return NextResponse.next(); +} + +export const config = { + matcher: ["/((?!api|_next|static|favicon.ico).*)"], +};