mirror of
https://github.com/arthur-pbty/binouz.git
synced 2026-06-03 23:36:27 +02:00
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:
@@ -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
@@ -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",
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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"),
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user