feat: add authentication and user management features

- Implemented AuthButton component for Discord sign-in and sign-out functionality.
- Created CopyButton component for copying server IP addresses.
- Developed EventCard and GradeCard components for displaying events and grades.
- Added Footer and Navbar components for site navigation and information.
- Introduced PurchaseButton for handling grade purchases with Stripe integration.
- Created SectionHeader component for consistent section titles.
- Implemented session management with SessionProvider for NextAuth.
- Set up PostgreSQL database with Docker and Prisma for data management.
- Added admin guard functionality to restrict access to certain routes.
- Configured NextAuth with Discord provider for user authentication.
- Defined Prisma schema for user, admin, grade, event, and purchase models.
- Seeded database with initial grades and events data.
- Added SVG hero image for the landing page.
- Extended NextAuth types to include additional user properties.
This commit is contained in:
Puechberty Arthur
2026-04-28 21:09:55 +02:00
parent 87deccb662
commit b7010a1704
43 changed files with 2794 additions and 126 deletions
+40
View File
@@ -0,0 +1,40 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { db } from "@/lib/db";
export type AdminGuardResult =
| {
ok: true;
discordId: string;
}
| {
ok: false;
response: Response;
};
export const requireAdmin = async (): Promise<AdminGuardResult> => {
const session = await getServerSession(authOptions);
const discordId = session?.user?.discordId;
if (!discordId) {
return {
ok: false,
response: Response.json({ error: "Unauthorized" }, { status: 401 }),
};
}
const envAdmin = process.env.ADMIN_DISCORD_ID;
if (envAdmin && envAdmin === discordId) {
return { ok: true, discordId };
}
const admin = await db.admin.findUnique({ where: { discordId } });
if (!admin) {
return {
ok: false,
response: Response.json({ error: "Forbidden" }, { status: 403 }),
};
}
return { ok: true, discordId };
};
+99
View File
@@ -0,0 +1,99 @@
import type { NextAuthOptions } from "next-auth";
import DiscordProvider from "next-auth/providers/discord";
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import { db } from "@/lib/db";
const adminDiscordId = process.env.ADMIN_DISCORD_ID;
const discordRedirectUrl = process.env.DISCORD_REDIRECT_URL;
const resolveAdmin = async (discordId?: string) => {
if (!discordId) return false;
if (adminDiscordId && discordId === adminDiscordId) return true;
const admin = await db.admin.findUnique({ where: { discordId } });
return Boolean(admin);
};
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(db),
session: {
strategy: "jwt",
},
providers: [
DiscordProvider({
clientId: process.env.DISCORD_CLIENT_ID ?? "",
clientSecret: process.env.DISCORD_CLIENT_SECRET ?? "",
authorization: {
params: {
scope: "identify email",
...(discordRedirectUrl
? { redirect_uri: discordRedirectUrl }
: {}),
},
},
}),
],
callbacks: {
async jwt({ token, account, profile }) {
if (account && profile) {
const discordProfile = profile as {
id?: string;
username?: string;
avatar?: string;
};
token.discordId = discordProfile.id;
token.discordUsername = discordProfile.username;
token.discordAvatar = discordProfile.avatar;
}
token.isAdmin = await resolveAdmin(token.discordId as string | undefined);
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.sub ?? "";
session.user.discordId = (token.discordId as string | undefined) ?? "";
session.user.discordUsername =
(token.discordUsername as string | undefined) ?? "";
session.user.discordAvatar =
(token.discordAvatar as string | undefined) ?? "";
session.user.isAdmin = token.isAdmin === true;
}
return session;
},
},
events: {
async signIn({ user, account, profile }) {
if (!account || account.provider !== "discord" || !profile) return;
const discordId = (profile as { id?: string }).id;
const discordUsername = (profile as { username?: string }).username;
const discordAvatar = (profile as { avatar?: string }).avatar;
if (!discordId) return;
await db.user.update({
where: { id: user.id },
data: {
discordId,
discordUsername,
discordAvatar,
},
});
if (adminDiscordId && discordId === adminDiscordId) {
await db.admin.upsert({
where: { discordId },
update: {},
create: {
discordId,
userId: user.id,
},
});
}
},
},
pages: {
signIn: "/auth/signin",
},
};
+18
View File
@@ -0,0 +1,18 @@
import { PrismaClient } from "@prisma/client";
type PrismaGlobal = typeof globalThis & {
prisma?: PrismaClient;
};
const globalForPrisma = globalThis as PrismaGlobal;
export const db =
globalForPrisma.prisma ??
new PrismaClient({
log:
process.env.NODE_ENV === "development" ? ["warn", "error"] : ["error"],
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = db;
}
+14
View File
@@ -0,0 +1,14 @@
export const formatEventDate = (value: Date) => {
return new Intl.DateTimeFormat("en-GB", {
dateStyle: "medium",
timeStyle: "short",
}).format(value);
};
export const formatPrice = (value: number) => {
return new Intl.NumberFormat("en-GB", {
style: "currency",
currency: "EUR",
maximumFractionDigits: 0,
}).format(value);
};
+58
View File
@@ -0,0 +1,58 @@
export const siteConfig = {
name: "BinouzUHC",
description:
"Serveur UHC competitif et immersif. PvP nerveux, events reguliers et communaute engagee.",
serverAddress: "play.binouzuhc.eu",
version: "1.8.X",
discordInviteUrl: "https://discord.gg/binouz",
shopDisclaimer:
"Paiement Stripe a venir. Les achats sont simules pour l'instant.",
} as const;
export const fallbackGrades = [
{
id: "starter",
name: "Eclaireur",
price: 9,
description:
"Acces prioritaire, prefix colore, particules discretes et slots reserves.",
},
{
id: "elite",
name: "Gladiateur",
price: 19,
description:
"Kit cosmetique PvP, tags exclusifs, bonus d'events et loot personnalise.",
},
{
id: "legend",
name: "Titan",
price: 39,
description:
"Rang ultime, aura lumineuse, salons prives et avantages boutique VIP.",
},
];
export const fallbackEvents = [
{
id: "uhc-arena",
title: "UHC Arena - Duel Night",
description:
"Tournoi 1v1 avec bracket rapide. Score bonus pour les eliminations propres.",
eventDate: new Date("2026-05-18T19:00:00Z"),
},
{
id: "rush",
title: "Rush 2v2 - Full PvP",
description:
"Arches, potions, clutchs. Inscription par equipe sur Discord.",
eventDate: new Date("2026-05-25T19:00:00Z"),
},
{
id: "uhc-royale",
title: "UHC Royale",
description:
"Format 50 joueurs, shrink progressif, recompenses exclusives.",
eventDate: new Date("2026-06-01T19:00:00Z"),
},
];