ref: format

This commit is contained in:
Rémi 2025-01-04 16:11:22 +01:00
parent f54a8ccc0a
commit b81a058a1c
18 changed files with 484 additions and 474 deletions

View File

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

View File

@ -1,5 +1,5 @@
services: services:
webapp: webapp:
image: toogether/webapp image: toogether/webapp
ports: ports:
- "80:3000" - "80:3000"

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,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

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

View File

@ -1,100 +1,108 @@
'use client' "use client";
import { import {
Avatar, Avatar,
Button, Button,
Dropdown, Dropdown,
DropdownItem, DropdownItem,
DropdownMenu, DropdownMenu,
DropdownTrigger, DropdownTrigger,
Navbar, Navbar,
NavbarBrand, NavbarBrand,
NavbarContent, NavbarContent,
NavbarItem NavbarItem,
} from '@nextui-org/react' } from "@nextui-org/react";
import { useSession } from 'next-auth/react' import { useSession } from "next-auth/react";
import { ThemeSwitcher } from '../ThemeSwitcher/ThemeSwitcher' import { ThemeSwitcher } from "../ThemeSwitcher/ThemeSwitcher";
import { axiosInstance } from '@/app/lib/axios' import { axiosInstance } from "@/app/lib/axios";
import { useEffect, useState } from 'react' import { useEffect, useState } from "react";
import { useRouter } from 'next/navigation' import { useRouter } from "next/navigation";
const getInitials = (name: string) => { const getInitials = (name: string) => {
if (!name) return '' if (!name) return "";
const nameParts = name.split(' ') const nameParts = name.split(" ");
if (nameParts.length === 1) { if (nameParts.length === 1) {
return name return name;
} }
const firstInitial = nameParts[0]?.[0] || '' const firstInitial = nameParts[0]?.[0] || "";
const secondInitial = nameParts[1]?.[0] || nameParts[0]?.[1] || '' const secondInitial = nameParts[1]?.[0] || nameParts[0]?.[1] || "";
return firstInitial + secondInitial return firstInitial + secondInitial;
} };
export const Header = () => { export const Header = () => {
const { data: session } = useSession() const { data: session } = useSession();
const router = useRouter() const router = useRouter();
const [userProfile, setUserProfile] = useState<{ const [userProfile, setUserProfile] = useState<{
id: string id: string;
username: string username: string;
role: 'ADMIN' | 'STUDENT' role: "ADMIN" | "STUDENT";
}>() }>();
const initials = session?.user?.name ? getInitials(session.user.name) : '' const initials = session?.user?.name ? getInitials(session.user.name) : "";
const fetchUserProfile = async () => { const fetchUserProfile = async () => {
return await axiosInstance<{ return await axiosInstance<{
id: string id: string;
username: string username: string;
role: 'ADMIN' | 'STUDENT' role: "ADMIN" | "STUDENT";
}>('/@me') }>("/@me");
} };
useEffect(() => { useEffect(() => {
fetchUserProfile().then(r => { fetchUserProfile().then((r) => {
setUserProfile(r.data) setUserProfile(r.data);
}) });
}, []) }, []);
return ( return (
<Navbar className='mb-2'> <Navbar className="mb-2">
<NavbarBrand> <NavbarBrand>
<p className='font-bold text-inherit'>Toogether</p> <p className="font-bold text-inherit">Toogether</p>
</NavbarBrand> </NavbarBrand>
<NavbarContent as='div' justify='end'> <NavbarContent as="div" justify="end">
<Dropdown placement='bottom-end'> <Dropdown placement="bottom-end">
<DropdownTrigger> <DropdownTrigger>
<Avatar <Avatar
isBordered isBordered
as='button' as="button"
className='transition-transform' className="transition-transform"
color='secondary' color="secondary"
name={initials} name={initials}
size='sm' size="sm"
/> />
</DropdownTrigger> </DropdownTrigger>
<DropdownMenu aria-label='Profile Actions' variant='flat'> <DropdownMenu aria-label="Profile Actions" variant="flat">
<DropdownItem key='profile' className='h-14 gap-2'> <DropdownItem key="profile" className="h-14 gap-2">
<p>Signed in as</p> <p>Signed in as</p>
<p className='font-semibold'>{session?.user?.name}</p> <p className="font-semibold">
</DropdownItem> {session?.user?.name}
<DropdownItem key='settings'>Settings</DropdownItem> </p>
<DropdownItem key='logout' color='danger' href='/auth/logout'> </DropdownItem>
Logout <DropdownItem key="settings">Settings</DropdownItem>
</DropdownItem> <DropdownItem
</DropdownMenu> key="logout"
</Dropdown> color="danger"
<NavbarItem> href="/auth/logout"
<ThemeSwitcher /> >
</NavbarItem> Logout
{userProfile?.role === 'ADMIN' ? ( </DropdownItem>
<NavbarItem> </DropdownMenu>
<Button onPress={() => router.push('/admin')}>🔧</Button> </Dropdown>
</NavbarItem> <NavbarItem>
) : null} <ThemeSwitcher />
</NavbarContent> </NavbarItem>
</Navbar> {userProfile?.role === "ADMIN" ? (
) <NavbarItem>
} <Button onPress={() => router.push("/admin")}>
🔧
</Button>
</NavbarItem>
) : null}
</NavbarContent>
</Navbar>
);
};

View File

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

View File

@ -1,8 +1,8 @@
'use client'; "use client";
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from "react";
import { RoomCard } from './Card'; import { RoomCard } from "./Card";
import { Room } from './Room'; import { Room } from "./Room";
import { SkeletonRoomCard } from './SkeletonRoomCard'; import { SkeletonRoomCard } from "./SkeletonRoomCard";
export const RoomList = ({ rooms }: { rooms: Room[] }) => { export const RoomList = ({ rooms }: { rooms: Room[] }) => {
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null);
@ -18,7 +18,7 @@ export const RoomList = ({ rooms }: { rooms: Room[] }) => {
const isEnd = goLeft const isEnd = goLeft
? scrollContainer.scrollLeft === 0 ? scrollContainer.scrollLeft === 0
: scrollContainer.scrollLeft + scrollContainer.clientWidth >= : scrollContainer.scrollLeft + scrollContainer.clientWidth >=
scrollContainer.scrollWidth; scrollContainer.scrollWidth;
if (isEnd) return; if (isEnd) return;
event.preventDefault(); event.preventDefault();
@ -40,22 +40,22 @@ export const RoomList = ({ rooms }: { rooms: Room[] }) => {
const scrollContainer = scrollContainerRef.current; const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) return; if (!scrollContainer) return;
scrollContainer.addEventListener('wheel', handleWheel); scrollContainer.addEventListener("wheel", handleWheel);
return () => { return () => {
scrollContainer.removeEventListener('wheel', handleWheel); scrollContainer.removeEventListener("wheel", handleWheel);
}; };
}, []); }, []);
return ( return (
<div <div
ref={scrollContainerRef} ref={scrollContainerRef}
className='overflow-x-auto scrollbar-hide rounded-xl bg-default-100' className="overflow-x-auto scrollbar-hide rounded-xl bg-default-100"
> >
<ul className='flex'> <ul className="flex">
{rooms?.length > 0 ? ( {rooms?.length > 0 ? (
rooms.map(room => ( rooms.map((room) => (
<li key={room.id} className='p-2'> <li key={room.id} className="p-2">
<RoomCard <RoomCard
id={room.id} id={room.id}
name={room.name} name={room.name}
@ -68,7 +68,7 @@ export const RoomList = ({ rooms }: { rooms: Room[] }) => {
) : ( ) : (
<> <>
{Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: 5 }).map((_, i) => (
<li key={i} className='p-2'> <li key={i} className="p-2">
<SkeletonRoomCard /> <SkeletonRoomCard />
</li> </li>
))} ))}

View File

@ -13,5 +13,5 @@ export interface Room {
username: string; username: string;
role: string; role: string;
createdAt: string; createdAt: string;
} };
} }

View File

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

View File

@ -1,21 +1,21 @@
'use client' "use client";
import { Button } from '@nextui-org/react' import { Button } from "@nextui-org/react";
import { useTheme } from 'next-themes' import { useTheme } from "next-themes";
import { useEffect, useState } from 'react' import { useEffect, useState } from "react";
export const ThemeSwitcher = () => { export const ThemeSwitcher = () => {
const [mounted, setMounted] = useState(false) const [mounted, setMounted] = useState(false);
const { theme, setTheme } = useTheme() const { theme, setTheme } = useTheme();
useEffect(() => { useEffect(() => {
setMounted(true) setMounted(true);
}, []) }, []);
if (!mounted) return null if (!mounted) return null;
return ( return (
<Button onPress={() => setTheme(theme === 'light' ? 'dark' : 'light')}> <Button onPress={() => setTheme(theme === "light" ? "dark" : "light")}>
{theme === 'light' ? '🌑' : '☀️'} {theme === "light" ? "🌑" : "☀️"}
</Button> </Button>
) );
} };

View File

@ -1,30 +1,30 @@
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 } from "next-auth/react";
moment.locale("fr"); moment.locale("fr");
let cachedAccessToken: string | null = null; let cachedAccessToken: string | null = null;
let tokenExpirationAt: Moment | null = null; let tokenExpirationAt: Moment | null = null;
export const axiosInstance = axios.create({ export const axiosInstance = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL, baseURL: process.env.NEXT_PUBLIC_API_URL,
}); });
axiosInstance.interceptors.request.use(async (config) => { axiosInstance.interceptors.request.use(async (config) => {
if (tokenExpirationAt && moment().isBefore(tokenExpirationAt)) { if (tokenExpirationAt && moment().isBefore(tokenExpirationAt)) {
config.headers.Authorization = `Bearer ${cachedAccessToken}`; config.headers.Authorization = `Bearer ${cachedAccessToken}`;
return config; return config;
} }
const session = await getSession(); const session = await getSession();
if (!session) { if (!session) {
throw new Error("User is not authenticated"); throw new Error("User is not authenticated");
} }
cachedAccessToken = session.accessToken; cachedAccessToken = session.accessToken;
tokenExpirationAt = moment(session.accessTokenExpires); tokenExpirationAt = moment(session.accessTokenExpires);
config.headers.Authorization = `Bearer ${cachedAccessToken}`; config.headers.Authorization = `Bearer ${cachedAccessToken}`;
return config; return config;
}); });

View File

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

View File

@ -1,11 +1,11 @@
'use client'; "use client";
import { Card, Divider, Skeleton } from '@nextui-org/react'; import { Card, Divider, Skeleton } from "@nextui-org/react";
import moment from 'moment'; import moment from "moment";
import { useEffect, useState } from 'react'; import { useEffect, useState } from "react";
import { Room } from './components/Room/Room'; import { Room } from "./components/Room/Room";
import { RoomList } from './components/Room/List'; import { RoomList } from "./components/Room/List";
import { Header } from './components/Header'; import { Header } from "./components/Header";
import { axiosInstance } from './lib/axios'; import { axiosInstance } from "./lib/axios";
const HomePage = () => { const HomePage = () => {
const [roomsLoading, setRoomsLoading] = useState(true); const [roomsLoading, setRoomsLoading] = useState(true);
@ -16,30 +16,30 @@ const HomePage = () => {
}>({ }>({
future: [], future: [],
actual: [], actual: [],
past: [] past: [],
}); });
useEffect(() => { useEffect(() => {
axiosInstance axiosInstance
.get<{ id: string; name: string; createdAt: string }[]>( .get<
'/@me/class' { id: string; name: string; createdAt: string }[]
) >("/@me/class")
.then(classResponse => { .then((classResponse) => {
if (classResponse.data.length) if (classResponse.data.length)
axiosInstance axiosInstance
.get<Room[]>( .get<
`/@me/class/${classResponse.data[0].id}/rooms` Room[]
) >(`/@me/class/${classResponse.data[0].id}/rooms`)
.then(classes => { .then((classes) => {
// Filter rooms by date, get future, actual and past rooms // Filter rooms by date, get future, actual and past rooms
const future = classes.data.filter(room => const future = classes.data.filter((room) =>
moment(room.date).isAfter(moment(), 'day') moment(room.date).isAfter(moment(), "day"),
); );
const actual = classes.data.filter(room => const actual = classes.data.filter((room) =>
moment(room.date).isSame(moment(), 'day') moment(room.date).isSame(moment(), "day"),
); );
const past = classes.data.filter(room => const past = classes.data.filter((room) =>
moment(room.date).isBefore(moment()) moment(room.date).isBefore(moment()),
); );
setRooms({ future, actual, past }); setRooms({ future, actual, past });
@ -51,17 +51,17 @@ const HomePage = () => {
return ( return (
<> <>
<Header /> <Header />
<main className='flex flex-col gap-8 p-4'> <main className="flex flex-col gap-8 p-4">
<section className='flex flex-col gap-2'> <section className="flex flex-col gap-2">
<h2 className='font-semibold text-lg'>Upcoming</h2> <h2 className="font-semibold text-lg">Upcoming</h2>
<RoomList rooms={rooms.future} /> <RoomList rooms={rooms.future} />
</section> </section>
<section className='flex flex-col gap-2'> <section className="flex flex-col gap-2">
<h2 className='font-semibold text-lg'>Current</h2> <h2 className="font-semibold text-lg">Current</h2>
<RoomList rooms={rooms.actual} /> <RoomList rooms={rooms.actual} />
</section> </section>
<section className='flex flex-col gap-2'> <section className="flex flex-col gap-2">
<h2 className='font-semibold text-lg'>Past</h2> <h2 className="font-semibold text-lg">Past</h2>
<RoomList rooms={rooms.past} /> <RoomList rooms={rooms.past} />
</section> </section>
</main> </main>

View File

@ -1,12 +1,12 @@
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;
} }
} }

View File

@ -1,42 +1,42 @@
/* 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 NextAuth, { DefaultSession } from "next-auth";
import { JWT } from "next-auth/jwt"; import { JWT } from "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;
} }
declare module "next-auth" { declare module "next-auth" {
interface Session extends DefaultSession { interface Session extends DefaultSession {
accessToken: string; accessToken: string;
refreshToken: string; refreshToken: string;
accessTokenExpires: Moment; accessTokenExpires: Moment;
error?: Error; error?: Error;
user: User; user: User;
} }
interface Account { interface Account {
expires_at: number; expires_at: number;
access_token: string; access_token: string;
refresh_token: string; refresh_token: string;
} }
} }
declare module "next-auth/jwt" { declare module "next-auth/jwt" {
interface JWT { interface JWT {
accessToken: string; accessToken: string;
accessTokenExpires: Moment; accessTokenExpires: Moment;
refreshToken: string; refreshToken: string;
error?: Error; error?: Error;
user: User | AdapterUser; user: User | AdapterUser;
iat: number; iat: number;
exp: number; exp: number;
jti: string; jti: string;
} }
} }

View File

@ -1,106 +1,110 @@
import axios from "axios"; import axios from "axios";
import moment from "moment"; import moment from "moment";
import { AuthOptions, Session } from "next-auth"; import { AuthOptions, Session } from "next-auth";
import { JWT } from "next-auth/jwt"; import { JWT } from "next-auth/jwt";
moment.locale("fr"); moment.locale("fr");
export const authOptions: AuthOptions = { export const authOptions: AuthOptions = {
providers: [ providers: [
{ {
id: "oauth", id: "oauth",
name: process.env.OAUTH_PROVIDER_NAME, name: process.env.OAUTH_PROVIDER_NAME,
type: "oauth", type: "oauth",
clientId: process.env.OAUTH_CLIENT_ID, clientId: process.env.OAUTH_CLIENT_ID,
clientSecret: process.env.OAUTH_CLIENT_SECRET, clientSecret: process.env.OAUTH_CLIENT_SECRET,
authorization: { authorization: {
url: process.env.OAUTH_AUTHORIZATION_URL, url: process.env.OAUTH_AUTHORIZATION_URL,
params: { params: {
scope: "openid profile offline_access", scope: "openid profile offline_access",
response_type: "code", response_type: "code",
}, },
}, },
checks: ["pkce", "state"], checks: ["pkce", "state"],
idToken: true, idToken: true,
token: process.env.OAUTH_TOKEN_URL, token: process.env.OAUTH_TOKEN_URL,
userinfo: process.env.OAUTH_USERINFO_URL, userinfo: process.env.OAUTH_USERINFO_URL,
issuer: process.env.OAUTH_ISSUER, issuer: process.env.OAUTH_ISSUER,
jwks_endpoint: process.env.OAUTH_JWKS_ENDPOINT, jwks_endpoint: process.env.OAUTH_JWKS_ENDPOINT,
profile(profile: Session["user"]) { profile(profile: Session["user"]) {
return { return {
id: profile.sub || profile.id, id: profile.sub || profile.id,
name: name:
profile.name || profile.name ||
profile.preferred_username || profile.preferred_username ||
`${profile.given_name} ${profile.family_name}`, `${profile.given_name} ${profile.family_name}`,
}; };
}, },
}, },
], ],
callbacks: { callbacks: {
async jwt({ token, account, user }) { async jwt({ token, account, user }) {
if (account && user) { if (account && user) {
token.accessToken = account.access_token; token.accessToken = account.access_token;
token.accessTokenExpires = moment(account.expires_at * 1000).subtract(5, "s"); token.accessTokenExpires = moment(
token.refreshToken = account.refresh_token; account.expires_at * 1000,
token.user = user; ).subtract(5, "s");
return token; token.refreshToken = account.refresh_token;
} token.user = user;
return token;
if (moment().isBefore(moment(token.accessTokenExpires))) { }
return token;
} if (moment().isBefore(moment(token.accessTokenExpires))) {
return token;
return refreshAccessToken(token); }
},
async session({ session, token }) { return refreshAccessToken(token);
if (token) { },
session.user = token.user; async session({ session, token }) {
session.accessToken = token.accessToken; if (token) {
session.accessTokenExpires = token.accessTokenExpires; session.user = token.user;
session.error = token.error; session.accessToken = token.accessToken;
} session.accessTokenExpires = token.accessTokenExpires;
session.error = token.error;
return session; }
},
}, return session;
pages: { },
signIn: "/auth/login", },
signOut: "/auth/logout", pages: {
} signIn: "/auth/login",
}; signOut: "/auth/logout",
},
const refreshAccessToken = async (token: JWT): Promise<JWT> => { };
const response = await axios.post<{
access_token: string; const refreshAccessToken = async (token: JWT): Promise<JWT> => {
expires_in: number; const response = await axios.post<{
refresh_token: string; access_token: string;
error_description?: string; expires_in: number;
}>( refresh_token: string;
process.env.OAUTH_TOKEN_URL, error_description?: string;
{ }>(
grant_type: "refresh_token", process.env.OAUTH_TOKEN_URL,
refresh_token: token.refreshToken, {
client_id: process.env.OAUTH_CLIENT_ID, grant_type: "refresh_token",
client_secret: process.env.OAUTH_CLIENT_SECRET, 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", {
}, headers: {
} "Content-Type": "application/x-www-form-urlencoded",
); },
},
if (response.status != 200) { );
throw new Error(
response.data.error_description || "Failed to refresh access token" if (response.status != 200) {
); throw new Error(
} response.data.error_description || "Failed to refresh access token",
);
return { }
...token,
accessToken: response.data.access_token, return {
accessTokenExpires: moment().add(response.data.expires_in, "seconds").subtract(5, "s"), ...token,
refreshToken: response.data.refresh_token, accessToken: response.data.access_token,
}; accessTokenExpires: moment()
}; .add(response.data.expires_in, "seconds")
.subtract(5, "s"),
refreshToken: response.data.refresh_token,
};
};

View File

@ -1,25 +1,25 @@
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(); return NextResponse.next();
} }
export const config = { export const config = {
matcher: ["/((?!api|_next|static|favicon.ico).*)"], matcher: ["/((?!api|_next|static|favicon.ico).*)"],
}; };