Merge pull request #1 from UiO-Gaming/feature/galtinn-oauth

Add Galtinn OAuth login
This commit is contained in:
leander authored and GitHub committed 2025-03-09 14:02:13 +01:00
commit 7ca5eb5f5f
16 files changed
+923 -164

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
NEXTAUTH_URL=http://localhost:3000
GALTINN_API_URL=http://localhost:8000/api
GALTINN_CLIENT_ID=
GALTINN_CLIENT_SECRET=
+3
View File
@@ -34,3 +34,6 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# privacy
.env.local
+714 -96
View File
File diff suppressed because it is too large. Load diff
+7 -6
View File
@@ -9,18 +9,19 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"react": "^18", "next": "^15.2.0",
"react-dom": "^18", "next-auth": "^4.24.11",
"next": "14.2.13" "react": "^19.0.0",
"react-dom": "^19.0.0"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "14.2.13",
"postcss": "^8", "postcss": "^8",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"eslint": "^8", "typescript": "^5"
"eslint-config-next": "14.2.13"
} }
} }
+11
View File
@@ -0,0 +1,11 @@
"use client";
import { SessionProvider } from "next-auth/react";
type Props = {
children?: React.ReactNode;
};
export const NextAuthProvider = ({ children }: Props) => {
return <SessionProvider>{children}</SessionProvider>;
};
+8 -6
View File
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Membership } from './Membership'; import Image from 'next/image';
import { Subscription, getSubscriptionDetails } from './Subscription'; import { Subscription, getSubscriptionDetails } from './Subscription';
interface MembershipItemProps { interface MembershipItemProps {
@@ -14,9 +14,11 @@ const MembershipItem: React.FC<MembershipItemProps> = ({ subscription }) => {
<div className="flex flex-row place-content-between"> <div className="flex flex-row place-content-between">
<div className="flex flex-row gap-3"> <div className="flex flex-row gap-3">
<div className="w-10 h-10 rounded-md"> <div className="w-10 h-10 rounded-md">
<img <Image
src={membership.image} src={membership.image}
alt="User Icon" alt="User Icon"
width={40}
height={40}
className="w-full h-full rounded-md" className="w-full h-full rounded-md"
/> />
</div> </div>
@@ -31,13 +33,13 @@ const MembershipItem: React.FC<MembershipItemProps> = ({ subscription }) => {
<div className="flex flex-1 flex-col gap-5"> <div className="flex flex-1 flex-col gap-5">
<div> <div>
<p className="font-semibold">Perks</p> <p className="font-semibold">Perks</p>
{membership.perks.map((perk) => ( {membership.perks.map((perk, index) => (
<p className="p">+ {perk}</p> <p key={index} className="p">+ {perk}</p>
))} ))}
</div> </div>
<div> <div>
{membership.specialPerks.map((perk) => ( {membership.specialPerks.map((perk, index) => (
<p className="p">+ {perk}</p> <p key={index} className="p">+ {perk}</p>
))} ))}
</div> </div>
</div> </div>
+21 -10
View File
@@ -1,4 +1,7 @@
import React from "react"; import React from "react";
import { signOut, useSession } from "next-auth/react";
import Image from "next/image";
import LogoutIcon from "../../../public/icons/LogoutIcon"; import LogoutIcon from "../../../public/icons/LogoutIcon";
interface ProfileCardProps { interface ProfileCardProps {
@@ -58,6 +61,8 @@ const ProfileCard: React.FC<ProfileCardProps> = ({
hasUiogMembership, hasUiogMembership,
hasDnsMembership, hasDnsMembership,
}) => { }) => {
const { data: session } = useSession();
const statusKey = hasUiogMembership const statusKey = hasUiogMembership
? hasDnsMembership ? hasDnsMembership
? "Combined" ? "Combined"
@@ -70,27 +75,32 @@ const ProfileCard: React.FC<ProfileCardProps> = ({
return ( return (
<div <div
className={`flex flex-1 flex-col gap-5 p-5 rounded-lg shadow-md text-white bg-gradient-to-b from-primary ${membershipStatus.gradient} transition-all duration-500 h-full w-full max-w-3xl`} className={`flex flex-1 flex-col gap-5 p-5 rounded-lg border border-overlay-3 text-white bg-gradient-to-b from-primary ${membershipStatus.gradient} transition-all duration-500 h-full w-full max-w-3xl`}
> >
{/* Top Row */} {/* Top Row */}
<div className="flex justify-between items-start mb-2 gap-2"> <div className="flex justify-between items-start mb-2 gap-2">
{/* User Icon and Name */} {/* User Icon and Name */}
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<div className="w-12 h-12 rounded-md"> <div className="w-12 h-12 rounded-md">
<img <Image
src={membershipStatus.image} src={membershipStatus.image}
alt="User Icon" alt="Membership Icon"
width={40}
height={40}
className="w-full h-full rounded-md" className="w-full h-full rounded-md"
/> />
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<p className="font-bold">wailern</p> <p className="font-bold">{session?.user?.name}</p>
<p className="opacity-50">{membershipStatus.tag}</p> <p className="opacity-50">{membershipStatus.tag}</p>
</div> </div>
</div> </div>
{/* Log Out Button */} {/* Log Out Button */}
<button className="flex flex-row bg-overlay-bright px-3 py-0.25 rounded-mds outline outline-outline2 outline-1 items-center gap-3"> <button
className="flex flex-row bg-overlay-bright px-3 py-0.25 rounded-mds outline outline-outline2 outline-1 items-center gap-3"
onClick={() => signOut()}
>
<p className="font-bold text-outline2">Log Out</p> <p className="font-bold text-outline2">Log Out</p>
<LogoutIcon className="w-5 ml-2 text-outline2" /> <LogoutIcon className="w-5 ml-2 text-outline2" />
</button> </button>
@@ -99,7 +109,8 @@ const ProfileCard: React.FC<ProfileCardProps> = ({
{/* Membership Status */} {/* Membership Status */}
<div className="flex flex-1 flex-col gap-2"> <div className="flex flex-1 flex-col gap-2">
{["UIOG", "DNS"].map((key) => { {["UIOG", "DNS"].map((key) => {
const isActive = key === "UIOG" ? hasUiogMembership : hasDnsMembership; const isActive =
key === "UIOG" ? hasUiogMembership : hasDnsMembership;
const displayName = key === "UIOG" ? "UiO Gaming" : "DNS Standard"; const displayName = key === "UIOG" ? "UiO Gaming" : "DNS Standard";
return ( return (
<div <div
@@ -108,10 +119,10 @@ const ProfileCard: React.FC<ProfileCardProps> = ({
isActive ? "bg-success" : "bg-failure" isActive ? "bg-success" : "bg-failure"
}`} }`}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p className="font-semibold pl">{isActive ? `✅` : `❌`}</p> <p className="font-semibold pl">{isActive ? `✅` : `❌`}</p>
<p className="font-semibold pl">{displayName}</p> <p className="font-semibold pl">{displayName}</p>
</div> </div>
</div> </div>
); );
})} })}
+11 -8
View File
@@ -1,4 +1,5 @@
import React from "react"; import React from "react";
import Image from "next/image";
interface PurchaseItem { interface PurchaseItem {
membership: { membership: {
@@ -19,9 +20,11 @@ const PurchaseItem: React.FC<PurchaseItem> = ({
<div className="flex flex-row place-content-between"> <div className="flex flex-row place-content-between">
<div className="flex flex-row gap-3"> <div className="flex flex-row gap-3">
<div className="w-10 h-10 rounded-md"> <div className="w-10 h-10 rounded-md">
<img <Image
src={membership.image} src={membership.image}
alt="User Icon" alt="Membership Icon"
width={40}
height={40}
className="w-full h-full rounded-md" className="w-full h-full rounded-md"
/> />
</div> </div>
@@ -30,20 +33,20 @@ const PurchaseItem: React.FC<PurchaseItem> = ({
<p className="opacity-50">kr {membership.priceYear} / year</p> <p className="opacity-50">kr {membership.priceYear} / year</p>
</div> </div>
</div> </div>
<div className="w-10 h-10 rounded-md bg-overlay-2 justify-items-center content-center outline outline-1 outline-overlay-3"> <div className="w-10 h-10 rounded-md bg-overlay-2 flex items-center justify-center outline outline-1 outline-overlay-3">
<p className="p2xl text-overlay-3">+</p> <p className="text-2xl text-overlay-3">+</p>
</div> </div>
</div> </div>
<div className="flex flex-1 flex-col gap-5"> <div className="flex flex-1 flex-col gap-5">
<div> <div>
<p className="font-semibold">Perks</p> <p className="font-semibold">Perks</p>
{membership.perks.map((perk) => ( {membership.perks.map((perk, index) => (
<p className="p">+ {perk}</p> <p key={index} className="p">+ {perk}</p>
))} ))}
</div> </div>
<div> <div>
{membership.specialPerks.map((perk) => ( {membership.specialPerks.map((perk, index) => (
<p className="p">+ {perk}</p> <p key={index} className="p">+ {perk}</p>
))} ))}
</div> </div>
</div> </div>
+4 -5
View File
@@ -2,7 +2,7 @@ import { Membership, membershipData } from './Membership';
export interface Subscription { export interface Subscription {
membershipId: string; membershipId: string;
startDate: Date; endDate: Date;
} }
export function calculateDaysLeft(subscription: Subscription): number { export function calculateDaysLeft(subscription: Subscription): number {
@@ -22,14 +22,13 @@ export function calculateDaysLeft(subscription: Subscription): number {
} }
else { else {
// if the membership does not end at new years, we can just calculate the days based on the duration of the membership // if the membership does not end at new years, we read the end date from the subscription
if (!membership.duration) { if (!membership.duration) {
return 0; return 0;
} }
const duration: number = parseInt(membership.duration.toString()); const timeDiff = subscription.endDate.getTime() - today.getTime();
daysLeft = Math.ceil(timeDiff / (1000 * 60 * 60 * 24));
daysLeft = Math.ceil((subscription.startDate.getTime() + duration * 24 * 60 * 60 * 1000 - today.getTime()) / (1000 * 60 * 60 * 24));
} }
return daysLeft; return daysLeft;
+8 -6
View File
@@ -2,7 +2,9 @@ import type { Metadata } from "next";
import localFont from "next/font/local"; import localFont from "next/font/local";
import "./globals.css"; import "./globals.css";
import { Nunito, Rubik } from "next/font/google" import { NextAuthProvider } from "./SessionProvider";
import { Nunito, Rubik } from "next/font/google";
const geistSans = localFont({ const geistSans = localFont({
src: "./fonts/GeistVF.woff", src: "./fonts/GeistVF.woff",
@@ -19,15 +21,15 @@ const nunito = Nunito({
weight: ["400", "500", "600", "800"], weight: ["400", "500", "600", "800"],
style: ["normal", "italic"], style: ["normal", "italic"],
subsets: ["latin"], subsets: ["latin"],
variable: "--font-nunito" variable: "--font-nunito",
}) });
const rubik = Rubik({ const rubik = Rubik({
weight: ["400", "500", "600", "800"], weight: ["400", "500", "600", "800"],
style: ["normal", "italic"], style: ["normal", "italic"],
subsets: ["latin"], subsets: ["latin"],
variable: "--font-rubik" variable: "--font-rubik",
}) });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "mus", title: "mus",
@@ -44,7 +46,7 @@ export default function RootLayout({
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased ${nunito.variable} ${rubik.variable}`} className={`${geistSans.variable} ${geistMono.variable} antialiased ${nunito.variable} ${rubik.variable}`}
> >
{children} <NextAuthProvider>{children}</NextAuthProvider>
</body> </body>
</html> </html>
); );
+14 -2
View File
@@ -1,9 +1,21 @@
"use client";
import { useSession } from "next-auth/react";
import ButtonSpecial from "./components/ButtonSpecial"; import ButtonSpecial from "./components/ButtonSpecial";
import Button from "./components/Button"; import Button from "./components/Button";
import Image from "next/image"; import Image from "next/image";
import LoginIcon from "../../public/icons/LoginIcon"; import LoginIcon from "../../public/icons/LoginIcon";
import Main from "../pages/main";
export default function Home() { export default function Home() {
const { data: session } = useSession();
if (session) {
return <Main />;
}
return ( return (
<div className="flex flex-col p-2.5 gap-2.5 place-items-center place-content-center h-full"> <div className="flex flex-col p-2.5 gap-2.5 place-items-center place-content-center h-full">
<div className="flex flex-1 flex-col bg-foreground rounded-lg p-5 gap-6 h-full w-full max-w-3xl"> <div className="flex flex-1 flex-col bg-foreground rounded-lg p-5 gap-6 h-full w-full max-w-3xl">
@@ -64,8 +76,8 @@ export default function Home() {
<Button <Button
text="Log in with Galtinn" text="Log in with Galtinn"
type="primary" type="primary"
icon={<LoginIcon className="w-5 text-black"/>} icon={<LoginIcon className="w-5 text-black" />}
link="https://galtinn.neuf.no/home/" link="/api/auth/signin" // TODO: use signin() function with onClick event
/> />
</div> </div>
<div className="w-full md:w-2/5"> <div className="w-full md:w-2/5">
+9 -6
View File
@@ -1,15 +1,18 @@
import { Nunito } from 'next/font/google'; import { SessionProvider } from "next-auth/react";
import { Nunito } from "next/font/google";
const nunito = Nunito({ const nunito = Nunito({
subsets: ['latin'] subsets: ["latin"],
}); });
import { AppProps } from 'next/app'; import { AppProps } from "next/app";
export default function MyApp({ Component, pageProps }: AppProps) { export default function MyApp({ Component, pageProps }: AppProps) {
return ( return (
<main className={nunito.className}> <SessionProvider session={pageProps.session}>
<Component {...pageProps} /> <main className={nunito.className}>
</main> <Component {...pageProps} />
</main>
</SessionProvider>
); );
} }
+54
View File
@@ -0,0 +1,54 @@
import NextAuth from "next-auth";
export const authOptions = {
providers: [
{
id: "galtinn",
name: "Galtinn",
type: "oauth",
wellKnown: `${process.env.GALTINN_API_URL}/oauth/.well-known/openid-configuration/`,
userinfo: `${process.env.GALTINN_API_URL}/me/oauth`,
checks: ["pkce", "state"],
clientId: process.env.GALTINN_ID,
clientSecret: process.env.GALTINN_SECRET,
authorization: { params: { scope: "openid email profile" } },
profile(profile) {
return {
id: profile.sub,
name: profile.name,
email: profile.email,
username: profile.preferred_username,
is_volunteer: profile.is_volunteer,
is_member: profile.is_member,
last_membership: profile.last_membership,
groups: profile.groups,
};
},
},
],
callbacks: {
async jwt({ token, user }) {
// Initial sign in
if (user) {
token.id = user.id;
token.username = user.username;
token.is_volunteer = user.is_volunteer;
token.is_member = user.is_member;
token.last_membership = user.last_membership;
token.groups = user.groups;
}
return token;
},
async session({ session, token, user }) {
session.user.username = token.username;
session.user.is_volunteer = token.is_volunteer;
session.user.is_member = token.is_member;
session.user.last_membership = token.last_membership;
session.user.groups = token.groups;
return session;
},
},
};
export default NextAuth(authOptions);
+31 -13
View File
@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import "../app/globals.css"; import "../app/globals.css";
import { useSession } from "next-auth/react";
import ProfileCard from '@/app/components/ProfileCard'; import ProfileCard from '@/app/components/ProfileCard';
import MembershipCard from '@/app/components/MembershipCard'; import MembershipCard from '@/app/components/MembershipCard';
@@ -7,24 +8,41 @@ import PurchaseCard from '@/app/components/PurchaseCard';
import { Subscription } from '@/app/components/Subscription'; import { Subscription } from '@/app/components/Subscription';
import Navbar from '@/app/components/Navbar'; import Navbar from '@/app/components/Navbar';
let dnsSubscription: Subscription | null = null; let hasDns = false;
let uiogSubscription: Subscription | null = null; let hasUiog = false;
let dnsSubscription: Subscription = {
// uiogSubscription = { membershipId: "",
// membershipId: "uiog", endDate: new Date(),
// startDate: new Date(new Date().setDate(new Date().getDate() - 111)),
// };
dnsSubscription = {
membershipId: "dns",
startDate: new Date(new Date().setDate(new Date().getDate() - 150)),
}; };
const hasDns = dnsSubscription !== null; let uiogSubscription: Subscription = {
const hasUiog = uiogSubscription !== null; membershipId: "",
endDate: new Date(),
};
export default function Main() { export default function Main() {
const { data: session } = useSession();
if (session?.user?.groups.includes("Aktiv")) {
hasDns = true;
const membershipDates = session?.user?.last_membership.split(" - ");
dnsSubscription = {
membershipId: "dns",
endDate: new Date(membershipDates[1])
};
}
if (session?.user?.groups.includes("UiO Gaming")) {
hasUiog = true;
uiogSubscription = {
membershipId: "uiog",
endDate: new Date(session?.user?.last_membership)
};
}
return ( return (
<div className="flex justify-center"> <div className="flex justify-center">
<div className="max-w-5xl w-full"> <div className="max-w-5xl w-full">
+5 -1
View File
@@ -63,7 +63,11 @@ const config: Config = {
mdl: "1.1rem", mdl: "1.1rem",
lg: "1.125rem", lg: "1.125rem",
xl: "1.25rem" xl: "1.25rem",
"2xl": "1.5rem",
"3xl": "1.875rem",
"4xl": "2.25rem",
}, },
fontWeight: { fontWeight: {
thin: "100", thin: "100",
+19 -5
View File
@@ -1,6 +1,10 @@
{ {
"compilerOptions": { "compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"], "lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
@@ -18,9 +22,19 @@
} }
], ],
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": [
} "./src/*"
]
},
"target": "ES2017"
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": [
"exclude": ["node_modules"] "next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
} }