Scaffold V1: auth, profils et leaderboard
Next.js 16 + Tailwind + Supabase (Auth, Postgres, Storage). Connexion par pseudo/mot de passe via email interne dérivé, RLS avec points protégés au niveau colonne, pages login/signup/leaderboard/profile. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,33 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--color-navy: #0f2748;
|
||||
--color-gold: #c9a227;
|
||||
--color-ivory: #f4ecd8;
|
||||
--color-ink: #2a2116;
|
||||
|
||||
--background: var(--color-ivory);
|
||||
--foreground: var(--color-ink);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-navy: var(--color-navy);
|
||||
--color-gold: var(--color-gold);
|
||||
--color-ivory: var(--color-ivory);
|
||||
--color-ink: var(--color-ink);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-sans), Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--color-gold);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { NavBar } from "@/components/nav-bar";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Le Tribunal",
|
||||
description: "Comptes et leaderboard pour le jeu du Tribunal",
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
return (
|
||||
<html
|
||||
lang="fr"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col bg-ivory text-ink">
|
||||
{user && <NavBar />}
|
||||
<main className="flex flex-1 flex-col">{children}</main>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { Avatar } from "@/components/avatar";
|
||||
|
||||
export default async function LeaderboardPage() {
|
||||
const supabase = await createClient();
|
||||
const { data: profiles, error } = await supabase
|
||||
.from("profiles")
|
||||
.select("id, pseudo, avatar_url, points")
|
||||
.order("points", { ascending: false });
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-2xl px-4 py-8">
|
||||
<h1 className="mb-6 text-2xl font-semibold text-navy">Leaderboard</h1>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-red-700">
|
||||
Impossible de charger le leaderboard pour le moment.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!error && profiles?.length === 0 && (
|
||||
<p className="text-sm text-ink/70">Personne n'est encore inscrit.</p>
|
||||
)}
|
||||
|
||||
{!error && profiles && profiles.length > 0 && (
|
||||
<ol className="flex flex-col gap-2">
|
||||
{profiles.map((profile, index) => {
|
||||
const rank = index + 1;
|
||||
const isFirst = rank === 1;
|
||||
return (
|
||||
<li
|
||||
key={profile.id}
|
||||
className={`flex items-center gap-4 rounded-lg border px-4 py-3 ${
|
||||
isFirst
|
||||
? "border-gold bg-gold/10"
|
||||
: "border-navy/10 bg-white"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-6 shrink-0 text-center font-semibold ${
|
||||
isFirst ? "text-gold" : "text-navy/60"
|
||||
}`}
|
||||
>
|
||||
{rank}
|
||||
</span>
|
||||
<Avatar pseudo={profile.pseudo} avatarUrl={profile.avatar_url} size="sm" />
|
||||
<span className="flex-1 truncate font-medium">{profile.pseudo}</span>
|
||||
<span className="font-semibold text-navy">{profile.points}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { internalEmail } from "@/lib/auth";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [pseudo, setPseudo] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const supabase = createClient();
|
||||
const { error: signInError } = await supabase.auth.signInWithPassword({
|
||||
email: internalEmail(pseudo),
|
||||
password,
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (signInError) {
|
||||
setError("Pseudo ou mot de passe incorrect.");
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/leaderboard");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center px-4 py-12">
|
||||
<div className="w-full max-w-sm">
|
||||
<h1 className="mb-6 text-center text-2xl font-semibold text-navy">
|
||||
Connexion
|
||||
</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="pseudo" className="text-sm font-medium">
|
||||
Pseudo
|
||||
</label>
|
||||
<input
|
||||
id="pseudo"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
required
|
||||
value={pseudo}
|
||||
onChange={(event) => setPseudo(event.target.value)}
|
||||
className="rounded-md border border-navy/20 bg-white px-3 py-2 text-ink outline-none focus:border-gold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="password" className="text-sm font-medium">
|
||||
Mot de passe
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
className="rounded-md border border-navy/20 bg-white px-3 py-2 text-ink outline-none focus:border-gold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="mt-2 rounded-md bg-navy px-4 py-2 font-medium text-ivory transition hover:bg-navy/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Connexion…" : "Se connecter"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-sm">
|
||||
Pas encore de compte ?{" "}
|
||||
<Link href="/signup" className="font-medium text-navy underline hover:text-gold">
|
||||
Créer un compte
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
|
||||
export default async function Home() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
redirect(user ? "/leaderboard" : "/login");
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { ProfileForm } from "./profile-form";
|
||||
|
||||
export default async function ProfilePage() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const { data: profile } = await supabase
|
||||
.from("profiles")
|
||||
.select("pseudo, avatar_url")
|
||||
.eq("id", user.id)
|
||||
.single();
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-sm px-4 py-8">
|
||||
<h1 className="mb-6 text-2xl font-semibold text-navy">Mon profil</h1>
|
||||
<ProfileForm
|
||||
userId={user.id}
|
||||
initialPseudo={profile?.pseudo ?? ""}
|
||||
initialAvatarUrl={profile?.avatar_url ?? null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { slugify } from "@/lib/auth";
|
||||
import { Avatar } from "@/components/avatar";
|
||||
|
||||
function mapError(message: string): string {
|
||||
if (message.includes("duplicate key")) {
|
||||
return "Ce pseudo est déjà pris.";
|
||||
}
|
||||
return "Une erreur est survenue, réessaie.";
|
||||
}
|
||||
|
||||
export function ProfileForm({
|
||||
userId,
|
||||
initialPseudo,
|
||||
initialAvatarUrl,
|
||||
}: {
|
||||
userId: string;
|
||||
initialPseudo: string;
|
||||
initialAvatarUrl: string | null;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [pseudo, setPseudo] = useState(initialPseudo);
|
||||
const [avatarUrl, setAvatarUrl] = useState(initialAvatarUrl);
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const previewUrl = avatarFile ? URL.createObjectURL(avatarFile) : avatarUrl;
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
|
||||
const slug = slugify(pseudo);
|
||||
if (!slug) {
|
||||
setError("Choisis un pseudo valide.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const supabase = createClient();
|
||||
|
||||
let nextAvatarUrl = avatarUrl;
|
||||
|
||||
if (avatarFile) {
|
||||
const ext = avatarFile.name.split(".").pop() ?? "jpg";
|
||||
const path = `${userId}.${ext}`;
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from("avatars")
|
||||
.upload(path, avatarFile, { upsert: true });
|
||||
|
||||
if (uploadError) {
|
||||
setLoading(false);
|
||||
setError("Impossible d'envoyer la photo, réessaie.");
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: publicUrlData } = supabase.storage.from("avatars").getPublicUrl(path);
|
||||
nextAvatarUrl = `${publicUrlData.publicUrl}?t=${Date.now()}`;
|
||||
}
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from("profiles")
|
||||
.update({ pseudo, slug, avatar_url: nextAvatarUrl })
|
||||
.eq("id", userId);
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (updateError) {
|
||||
setError(mapError(updateError.message));
|
||||
return;
|
||||
}
|
||||
|
||||
setAvatarUrl(nextAvatarUrl);
|
||||
setAvatarFile(null);
|
||||
setSuccess(true);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Avatar pseudo={pseudo || "?"} avatarUrl={previewUrl ?? null} size="lg" />
|
||||
<label className="text-sm">
|
||||
<span className="sr-only">Changer la photo</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(event) => setAvatarFile(event.target.files?.[0] ?? null)}
|
||||
className="text-sm file:mr-3 file:rounded-md file:border-0 file:bg-navy file:px-3 file:py-1.5 file:text-ivory"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="pseudo" className="text-sm font-medium">
|
||||
Pseudo
|
||||
</label>
|
||||
<input
|
||||
id="pseudo"
|
||||
type="text"
|
||||
required
|
||||
value={pseudo}
|
||||
onChange={(event) => setPseudo(event.target.value)}
|
||||
className="rounded-md border border-navy/20 bg-white px-3 py-2 text-ink outline-none focus:border-gold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{success && <p className="text-sm text-green-700">Profil mis à jour.</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="mt-2 rounded-md bg-navy px-4 py-2 font-medium text-ivory transition hover:bg-navy/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Enregistrement…" : "Enregistrer"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { internalEmail, slugify } from "@/lib/auth";
|
||||
|
||||
function mapError(message: string): string {
|
||||
if (message.includes("duplicate key") || message.includes("already registered")) {
|
||||
return "Ce pseudo est déjà pris.";
|
||||
}
|
||||
return "Une erreur est survenue, réessaie.";
|
||||
}
|
||||
|
||||
export default function SignupPage() {
|
||||
const router = useRouter();
|
||||
const [pseudo, setPseudo] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
|
||||
const slug = slugify(pseudo);
|
||||
if (!slug) {
|
||||
setError("Choisis un pseudo valide.");
|
||||
return;
|
||||
}
|
||||
if (password.length < 6) {
|
||||
setError("Le mot de passe doit faire au moins 6 caractères.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const supabase = createClient();
|
||||
|
||||
const { data: signUpData, error: signUpError } = await supabase.auth.signUp({
|
||||
email: internalEmail(pseudo),
|
||||
password,
|
||||
});
|
||||
|
||||
if (signUpError || !signUpData.user) {
|
||||
setLoading(false);
|
||||
setError(mapError(signUpError?.message ?? ""));
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = signUpData.user.id;
|
||||
let avatarUrl: string | null = null;
|
||||
|
||||
if (avatarFile) {
|
||||
const ext = avatarFile.name.split(".").pop() ?? "jpg";
|
||||
const path = `${userId}.${ext}`;
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from("avatars")
|
||||
.upload(path, avatarFile, { upsert: true });
|
||||
|
||||
if (!uploadError) {
|
||||
const { data: publicUrlData } = supabase.storage.from("avatars").getPublicUrl(path);
|
||||
avatarUrl = publicUrlData.publicUrl;
|
||||
}
|
||||
}
|
||||
|
||||
const { error: profileError } = await supabase.from("profiles").insert({
|
||||
id: userId,
|
||||
pseudo,
|
||||
slug,
|
||||
avatar_url: avatarUrl,
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (profileError) {
|
||||
setError(mapError(profileError.message));
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/leaderboard");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center px-4 py-12">
|
||||
<div className="w-full max-w-sm">
|
||||
<h1 className="mb-6 text-center text-2xl font-semibold text-navy">
|
||||
Créer un compte
|
||||
</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="pseudo" className="text-sm font-medium">
|
||||
Pseudo
|
||||
</label>
|
||||
<input
|
||||
id="pseudo"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
required
|
||||
value={pseudo}
|
||||
onChange={(event) => setPseudo(event.target.value)}
|
||||
className="rounded-md border border-navy/20 bg-white px-3 py-2 text-ink outline-none focus:border-gold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="password" className="text-sm font-medium">
|
||||
Mot de passe
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={6}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
className="rounded-md border border-navy/20 bg-white px-3 py-2 text-ink outline-none focus:border-gold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="avatar" className="text-sm font-medium">
|
||||
Photo de profil (optionnel)
|
||||
</label>
|
||||
<input
|
||||
id="avatar"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(event) => setAvatarFile(event.target.files?.[0] ?? null)}
|
||||
className="text-sm file:mr-3 file:rounded-md file:border-0 file:bg-navy file:px-3 file:py-1.5 file:text-ivory"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="mt-2 rounded-md bg-navy px-4 py-2 font-medium text-ivory transition hover:bg-navy/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Création…" : "Créer mon compte"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-sm">
|
||||
Déjà un compte ?{" "}
|
||||
<Link href="/login" className="font-medium text-navy underline hover:text-gold">
|
||||
Se connecter
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import Image from "next/image";
|
||||
|
||||
function initials(pseudo: string): string {
|
||||
return pseudo.trim().slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
const SIZE_CLASSES = {
|
||||
sm: "h-8 w-8 text-xs",
|
||||
md: "h-12 w-12 text-sm",
|
||||
lg: "h-24 w-24 text-2xl",
|
||||
} as const;
|
||||
|
||||
export function Avatar({
|
||||
pseudo,
|
||||
avatarUrl,
|
||||
size = "md",
|
||||
}: {
|
||||
pseudo: string;
|
||||
avatarUrl: string | null;
|
||||
size?: keyof typeof SIZE_CLASSES;
|
||||
}) {
|
||||
const sizeClass = SIZE_CLASSES[size];
|
||||
|
||||
if (avatarUrl) {
|
||||
return (
|
||||
<Image
|
||||
src={avatarUrl}
|
||||
alt={pseudo}
|
||||
width={96}
|
||||
height={96}
|
||||
className={`${sizeClass} rounded-full object-cover`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${sizeClass} flex items-center justify-center rounded-full bg-navy font-semibold text-ivory`}
|
||||
aria-hidden
|
||||
>
|
||||
{initials(pseudo)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
|
||||
export function NavBar() {
|
||||
const router = useRouter();
|
||||
const [signingOut, setSigningOut] = useState(false);
|
||||
|
||||
async function handleSignOut() {
|
||||
setSigningOut(true);
|
||||
const supabase = createClient();
|
||||
await supabase.auth.signOut();
|
||||
router.push("/login");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="bg-navy text-ivory">
|
||||
<div className="mx-auto flex max-w-3xl items-center justify-between px-4 py-3">
|
||||
<span className="font-semibold tracking-wide">Le Tribunal</span>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<Link href="/leaderboard" className="hover:text-gold">
|
||||
Leaderboard
|
||||
</Link>
|
||||
<Link href="/profile" className="hover:text-gold">
|
||||
Profil
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSignOut}
|
||||
disabled={signingOut}
|
||||
className="hover:text-gold disabled:opacity-50"
|
||||
>
|
||||
{signingOut ? "…" : "Déconnexion"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
const COMBINING_DIACRITICS = /[̀-ͯ]/g;
|
||||
|
||||
export function slugify(pseudo: string): string {
|
||||
return pseudo
|
||||
.normalize("NFD")
|
||||
.replace(COMBINING_DIACRITICS, "")
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9\s-]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
// Supabase Auth rejette le TLD ".local" (email_address_invalid).
|
||||
// ".test" est réservé par la RFC 2606 pour cet usage et passe la validation.
|
||||
export function internalEmail(pseudo: string): string {
|
||||
return `${slugify(pseudo)}@letribunal.test`;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createBrowserClient } from "@supabase/ssr";
|
||||
|
||||
export function createClient() {
|
||||
return createBrowserClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
|
||||
const PROTECTED_PATHS = ["/leaderboard", "/profile"];
|
||||
const AUTH_PATHS = ["/login", "/signup"];
|
||||
|
||||
export async function updateSession(request: NextRequest) {
|
||||
let response = NextResponse.next({ request });
|
||||
|
||||
const supabase = createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return request.cookies.getAll();
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value));
|
||||
response = NextResponse.next({ request });
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
response.cookies.set(name, value, options),
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
const { pathname } = request.nextUrl;
|
||||
const isProtected = PROTECTED_PATHS.some((path) => pathname.startsWith(path));
|
||||
const isAuthPath = AUTH_PATHS.some((path) => pathname.startsWith(path));
|
||||
|
||||
if (!user && isProtected) {
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = "/login";
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
if (user && isAuthPath) {
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = "/leaderboard";
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export async function createClient() {
|
||||
const cookieStore = await cookies();
|
||||
|
||||
return createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll();
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
cookieStore.set(name, value, options),
|
||||
);
|
||||
} catch {
|
||||
// called from a Server Component; middleware refreshes the session instead
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { updateSession } from "@/lib/supabase/middleware";
|
||||
|
||||
export async function proxy(request: NextRequest) {
|
||||
return updateSession(request);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)"],
|
||||
};
|
||||
Reference in New Issue
Block a user