Compare commits

..

3 Commits

Author SHA1 Message Date
Valentin ROBIN e64e8d3ff2 Merge remote-tracking branch 'origin/main'
Build and deploy / deploy (push) Failing after 28s
2026-07-26 18:08:56 +02:00
Valentin ROBIN 36adb143ca Inscription sur invitation uniquement
Ferme l'auto-inscription publique : l'app repose désormais sur le
mécanisme d'invitation natif de Supabase (Authentication → Users →
Invite user) plutôt que sur un formulaire /signup ouvert à tous.

- /login : retire le lien « Créer un compte ».
- /signup : entièrement repensée. Ce n'est plus un formulaire d'auto-
  inscription (email + mot de passe) mais une page de FINALISATION de
  compte invité. Au montage, vérifie s'il existe une session (établie
  par le clic sur le lien d'invitation reçu par email) :
    * pas de session → message « invitation requise » ;
    * session + profil déjà finalisé (pseudo réel, pas le défaut
      "Nouveau membre" posé par le trigger handle_new_user) → redirige
      directement vers le leaderboard ;
    * session + profil à finaliser → formulaire pseudo + mot de passe
      + photo, qui appelle `updateUser({ password })` puis met à jour
      `profiles` (au lieu de `signUp()`).
- proxy : /signup retiré de AUTH_PATHS — un compte fraîchement invité a
  déjà une session active et doit pouvoir rester sur cette page pour la
  finaliser, sans être renvoyé au leaderboard par le redirect prévu pour
  les utilisateurs déjà connectés.
- README/CLAUDE.md : documentent la désactivation de "Allow new users
  to sign up", la configuration Site URL/Redirect URLs (→ /signup) pour
  que le lien d'invitation atterrisse au bon endroit, et le flux
  d'invitation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 18:08:03 +02:00
Valentin ROBIN a807c4c85e Nouveau logo L'Agora (favicon, header, login, signup, classement)
Remplace le laurier dessiné à la main par le logo fourni (badge circulaire
« L'Agora ») comme identité visuelle principale : favicon (icon.png),
icône iOS (apple-icon.png, remplace icon.svg), marque du header, page
login, page signup, et grand format en en-tête du classement.

- components/agora-logo.tsx : composant partagé, `unoptimized` sur
  next/image — l'optimiseur d'images de Next.js aplatit systématiquement
  la transparence de ce fichier (PNG ou WebP) lors du redimensionnement
  à la volée, donc on sert l'asset statique tel quel.
- Asset retravaillé (public/logo-agora.webp) : le fichier fourni n'avait
  pas de vraie transparence (damier dessiné en dur, y compris dans les
  zones "vides" de l'illustration) et le cercle était mal centré dans
  son canevas carré (anneau doré rogné en haut). Recadré, recentré avec
  une marge uniforme, fond extérieur en transparence réelle, fond
  intérieur aplati en blanc crème (#F4ECD8, le token "marble" existant)
  pour se fondre dans les cartes marbre de l'app.
- Header : bandeau renommé « LE TRIBUNAL » → « L'AGORA » (à gauche) ;
  les icônes du menu déroulant restent inchangées (icônes distinctes
  par page, nécessaires pour s'y retrouver).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 18:07:19 +02:00
13 changed files with 119 additions and 103 deletions
+1 -1
View File
@@ -76,7 +76,7 @@ Ce pattern (RLS pour l'accès à la ligne + trigger `BEFORE UPDATE` pour l'accè
## 4. Authentification
Email + mot de passe (Supabase Auth impose l'unicité de l'email). Confirmation d'email **activée** — nécessite un SMTP custom configuré côté Supabase (le service intégré est trop limité en volume, voir README). Le pseudo est un simple nom d'affichage stocké dans `profiles`, jamais l'email n'est montré aux autres membres.
**Sur invitation uniquement**, plus d'auto-inscription publique : "Allow new users to sign up" est désactivé côté Supabase. Un Archonte invite via Authentication → Users → Invite user ; le lien reçu établit une session directement sur `/signup`, qui sert alors à finaliser le compte (mot de passe, pseudo, photo) — sans session valide, la page affiche un message « invitation requise ». Le profil est créé dès l'invitation par le trigger `handle_new_user` (pseudo temporaire `"Nouveau membre"` jusqu'à finalisation). Nécessite un SMTP custom côté Supabase (le service intégré est trop limité en volume, voir README). Le pseudo est un simple nom d'affichage stocké dans `profiles`, jamais l'email n'est montré aux autres membres.
---
+9 -6
View File
@@ -56,9 +56,10 @@ npm install
1. Créez un nouveau projet sur [supabase.com](https://supabase.com/).
2. Dans **SQL Editor**, exécutez tout le contenu de [`supabase/schema.sql`](supabase/schema.sql) (tables `profiles`/`points_log`/`days`/`events`/`settings`, policies RLS, triggers, RPC, bucket `avatars`, Realtime). Le script est idempotent, vous pouvez le ré-exécuter sans risque après une mise à jour.
3. Récupérez l'URL du projet et la clé `anon` dans **Project Settings → API**.
4. Dans **Authentication → Providers → Email**, **activez "Confirm email"** (un vrai email par personne, condition d'unicité des comptes).
5. Dans **Authentication → Settings → SMTP Settings**, branchez un fournisseur SMTP custom (ex. Resend) — le service email par défaut de Supabase est bridé à quelques emails/heure. Voir [Email transactionnel](#email-transactionnel) ci-dessous.
6. Une fois votre propre compte créé et confirmé via `/signup`, désignez-vous Archonte (voir [Rôles & administration](#rôles--administration)).
4. Dans **Authentication → Providers → Email**, **désactivez "Allow new users to sign up"** — l'inscription publique est fermée, seules les invitations créent un compte (voir [Authentification](#authentification)).
5. Dans **Authentication → URL Configuration**, mettez **Site URL** sur `http://localhost:3000/signup` (puis votre domaine + `/signup` une fois déployé), et ajoutez la même valeur dans **Redirect URLs**. C'est la page où atterrit un lien d'invitation.
6. Dans **Authentication → Settings → SMTP Settings**, branchez un fournisseur SMTP custom (ex. Resend) — le service email par défaut de Supabase est bridé à quelques emails/heure. Voir [Email transactionnel](#email-transactionnel) ci-dessous.
7. Invitez-vous vous-même depuis **Authentication → Users → Invite user**, finalisez votre compte via le lien reçu, puis désignez-vous Archonte (voir [Rôles & administration](#rôles--administration)).
### 3. Variables d'environnement
@@ -109,7 +110,7 @@ tribunal-app/
├─ src/
│ ├─ app/
│ │ ├─ login/page.tsx # Connexion (email + mot de passe)
│ │ ├─ signup/page.tsx # Création de compte + photo recadrée
│ │ ├─ signup/page.tsx # Finalisation d'un compte invité (mot de passe, pseudo, photo)
│ │ ├─ leaderboard/
│ │ │ ├─ page.tsx # Fetch initial + rôle de l'utilisateur
│ │ │ └─ leaderboard-view.tsx # Realtime, podium, liste, contrôles juges
@@ -139,9 +140,11 @@ tribunal-app/
## Authentification
La connexion se fait par **email + mot de passe** ; Supabase impose l'unicité de l'email, ce qui empêche la création de comptes multiples. Le pseudo est purement un nom d'affichage (leaderboard, profil, journal) : l'email n'est jamais montré aux autres membres. La confirmation d'email est obligatoire (voir [SMTP](#email-transactionnel)).
**Sur invitation uniquement** : l'auto-inscription publique est désactivée côté Supabase (Authentication → Providers → Email → "Allow new users to sign up" décoché). Il n'y a plus de bouton « Créer un compte » sur `/login`.
Le profil (`public.profiles`) est créé automatiquement à l'inscription via un trigger sur `auth.users` (fonctionne même si l'email n'est pas encore confirmé).
Pour ajouter un membre : **Authentication → Users → Invite user** (email). Supabase lui envoie un email (via le SMTP configuré) contenant un lien d'invitation qui, une fois cliqué, établit directement une session sur `/signup` — c'est cette page qui sert alors à finaliser le compte (choix du mot de passe, du pseudo, upload de la photo). Sans session active (lien absent, invalide ou expiré), `/signup` affiche un message « invitation requise » plutôt qu'un formulaire d'inscription.
Le profil (`public.profiles`) est créé automatiquement dès l'invitation via un trigger sur `auth.users` (pseudo temporaire `"Nouveau membre"` tant que la finalisation n'a pas eu lieu). Le pseudo est purement un nom d'affichage (leaderboard, profil, journal) : l'email n'est jamais montré aux autres membres.
### Verrou de pseudo
Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

-18
View File
@@ -1,18 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="#C9A227">
<rect x="0" y="0" width="100" height="100" rx="20" fill="#0A1B33" />
<ellipse cx="44.18" cy="85.39" rx="7.00" ry="2.80" transform="rotate(-168.00 44.18 85.39)"/>
<ellipse cx="31.70" cy="81.14" rx="6.71" ry="2.68" transform="rotate(-141.67 31.70 81.14)"/>
<ellipse cx="21.98" cy="71.26" rx="6.42" ry="2.57" transform="rotate(-115.33 21.98 71.26)"/>
<ellipse cx="17.50" cy="57.43" rx="6.13" ry="2.45" transform="rotate(-89.00 17.50 57.43)"/>
<ellipse cx="19.80" cy="42.39" rx="5.83" ry="2.33" transform="rotate(-62.67 19.80 42.39)"/>
<ellipse cx="28.97" cy="29.40" rx="5.54" ry="2.22" transform="rotate(-36.33 28.97 29.40)"/>
<ellipse cx="43.58" cy="21.56" rx="5.25" ry="2.10" transform="rotate(-10.00 43.58 21.56)"/>
<ellipse cx="55.82" cy="85.39" rx="7.00" ry="2.80" transform="rotate(-168.00 55.82 85.39)"/>
<ellipse cx="68.30" cy="81.14" rx="6.71" ry="2.68" transform="rotate(-141.67 68.30 81.14)"/>
<ellipse cx="78.02" cy="71.26" rx="6.42" ry="2.57" transform="rotate(-115.33 78.02 71.26)"/>
<ellipse cx="82.50" cy="57.43" rx="6.13" ry="2.45" transform="rotate(-89.00 82.50 57.43)"/>
<ellipse cx="80.20" cy="42.39" rx="5.83" ry="2.33" transform="rotate(-62.67 80.20 42.39)"/>
<ellipse cx="71.03" cy="29.40" rx="5.54" ry="2.22" transform="rotate(-36.33 71.03 29.40)"/>
<ellipse cx="56.42" cy="21.56" rx="5.25" ry="2.10" transform="rotate(-10.00 56.42 21.56)"/>
<circle cx="50" cy="85" r="2.4"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

+4 -5
View File
@@ -1,6 +1,7 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { LeaderboardView } from "./leaderboard-view";
import { AgoraLogo } from "@/components/agora-logo";
export default async function LeaderboardPage() {
const supabase = await createClient();
@@ -21,11 +22,9 @@ export default async function LeaderboardPage() {
return (
<div className="mx-auto w-full max-w-2xl px-4 py-8">
<div className="mb-6 text-center">
<h1 className="font-heading text-2xl tracking-[0.1em] text-gold-bright uppercase">
L&apos;Agora
</h1>
<p className="font-serif text-sm text-marble/60 italic">Le classement des Citoyens</p>
<div className="mb-6 flex flex-col items-center text-center">
<AgoraLogo className="h-32 w-32 sm:h-40 sm:w-40" />
<p className="mt-2 font-serif text-sm text-marble/60 italic">Le classement des Citoyens</p>
</div>
{error && (
+3 -7
View File
@@ -1,9 +1,8 @@
"use client";
import Link from "next/link";
import { useState, type FormEvent } from "react";
import { createClient } from "@/lib/supabase/client";
import { LaurelWreath } from "@/components/laurel-wreath";
import { AgoraLogo } from "@/components/agora-logo";
function mapSignInError(message: string): string {
if (message.toLowerCase().includes("email not confirmed")) {
@@ -43,7 +42,7 @@ export default function LoginPage() {
<div className="flex flex-1 items-center justify-center px-4 py-12">
<div className="marble-surface w-full max-w-sm rounded-2xl border border-gold/40 p-6 shadow-xl sm:p-8">
<div className="mb-4 flex flex-col items-center gap-2">
<LaurelWreath className="h-10 w-10" color="#A5342A" />
<AgoraLogo className="h-24 w-24" />
<h1 className="font-heading text-xl tracking-[0.1em] text-text-marble uppercase">
Connexion
</h1>
@@ -99,10 +98,7 @@ export default function LoginPage() {
</form>
<p className="mt-6 text-center text-sm text-text-mut">
Pas encore de compte ?{" "}
<Link href="/signup" className="font-medium text-oxblood underline hover:text-gold">
Créer un compte
</Link>
L&apos;inscription se fait uniquement sur invitation d&apos;un Archonte.
</p>
</div>
</div>
+72 -62
View File
@@ -1,32 +1,65 @@
"use client";
import Link from "next/link";
import { useState, type FormEvent } from "react";
import { useEffect, useState, type FormEvent } from "react";
import { createClient } from "@/lib/supabase/client";
import { Avatar } from "@/components/avatar";
import { AvatarPicker } from "@/components/avatar-picker";
import { LaurelWreath } from "@/components/laurel-wreath";
import { AgoraLogo } from "@/components/agora-logo";
function mapSignUpError(message: string): string {
// Plus d'auto-inscription publique : cette page ne fonctionne que via un
// lien d'invitation envoyé par un Archonte (Supabase Authentication → Users
// → Invite user). Le clic sur ce lien établit une session ; cette page sert
// alors à finaliser le compte (mot de passe, pseudo, photo).
type Status = "checking" | "no-invite" | "ready" | "done";
function mapError(message: string): string {
const lower = message.toLowerCase();
if (lower.includes("already registered")) {
return "Un compte existe déjà avec cet email.";
}
if (lower.includes("rate limit")) {
return "Trop d'emails envoyés récemment (limite Supabase), réessaie dans quelques minutes.";
if (lower.includes("password")) {
return "Le mot de passe doit faire au moins 6 caractères.";
}
return "Une erreur est survenue, réessaie.";
}
export default function SignupPage() {
const [email, setEmail] = useState("");
const [status, setStatus] = useState<Status>("checking");
const [userId, setUserId] = useState<string | null>(null);
const [pseudo, setPseudo] = useState("");
const [password, setPassword] = useState("");
const [avatarBlob, setAvatarBlob] = useState<Blob | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [confirmationSent, setConfirmationSent] = useState(false);
useEffect(() => {
async function checkInvite() {
const supabase = createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
setStatus("no-invite");
return;
}
const { data: profile } = await supabase
.from("profiles")
.select("pseudo")
.eq("id", user.id)
.single();
if (profile && profile.pseudo !== "Nouveau membre") {
window.location.href = "/leaderboard";
return;
}
setUserId(user.id);
setStatus("ready");
}
checkInvite();
}, []);
function handleCropped(blob: Blob) {
setAvatarBlob(blob);
@@ -46,6 +79,7 @@ export default function SignupPage() {
setError("Le mot de passe doit faire au moins 6 caractères.");
return;
}
if (!userId) return;
setLoading(true);
const supabase = createClient();
@@ -60,58 +94,56 @@ export default function SignupPage() {
return;
}
const { data: signUpData, error: signUpError } = await supabase.auth.signUp({
email,
password,
options: { data: { pseudo: trimmedPseudo } },
});
if (signUpError || !signUpData.user) {
const { error: passwordError } = await supabase.auth.updateUser({ password });
if (passwordError) {
setLoading(false);
setError(mapSignUpError(signUpError?.message ?? ""));
setError(mapError(passwordError.message));
return;
}
// Sans session active (confirmation email en attente), impossible
// d'uploader la photo : la policy Storage exige un utilisateur
// authentifié. On l'ajoutera depuis /profile après la 1ère connexion.
if (signUpData.session && avatarBlob) {
const path = `${signUpData.user.id}.webp`;
let avatarUrl: string | null = null;
if (avatarBlob) {
const path = `${userId}.webp`;
const { error: uploadError } = await supabase.storage
.from("avatars")
.upload(path, avatarBlob, { upsert: true, contentType: "image/webp" });
if (!uploadError) {
const { data: publicUrlData } = supabase.storage.from("avatars").getPublicUrl(path);
await supabase
avatarUrl = publicUrlData.publicUrl;
}
}
const { error: profileError } = await supabase
.from("profiles")
.update({ avatar_url: publicUrlData.publicUrl })
.eq("id", signUpData.user.id);
}
}
.update({ pseudo: trimmedPseudo, ...(avatarUrl ? { avatar_url: avatarUrl } : {}) })
.eq("id", userId);
setLoading(false);
if (!signUpData.session) {
setConfirmationSent(true);
if (profileError) {
setError(mapError(profileError.message));
return;
}
window.location.href = "/leaderboard";
}
if (confirmationSent) {
if (status === "checking") {
return <div className="flex flex-1 items-center justify-center px-4 py-12" />;
}
if (status === "no-invite") {
return (
<div className="flex flex-1 items-center justify-center px-4 py-12">
<div className="marble-surface w-full max-w-sm rounded-2xl border border-gold/40 p-6 text-center shadow-xl sm:p-8">
<LaurelWreath className="mx-auto mb-3 h-10 w-10" color="#5E6B3B" />
<AgoraLogo className="mx-auto mb-3 h-16 w-16" />
<h1 className="mb-4 font-heading text-xl tracking-[0.1em] text-text-marble uppercase">
Compte créé !
Invitation requise
</h1>
<p className="text-sm text-text-marble/80">
Vérifie ta boîte mail (et les spams) pour confirmer ton adresse, puis
connecte-toi. Tu pourras ajouter ta photo de profil ensuite depuis ton
profil.
La création de compte se fait uniquement sur invitation d&apos;un
Archonte. Ce lien est invalide ou a expiré.
</p>
<Link
href="/login"
@@ -128,9 +160,9 @@ export default function SignupPage() {
<div className="flex flex-1 items-center justify-center px-4 py-12">
<div className="marble-surface w-full max-w-sm rounded-2xl border border-gold/40 p-6 shadow-xl sm:p-8">
<div className="mb-4 flex flex-col items-center gap-2">
<LaurelWreath className="h-10 w-10" color="#A5342A" />
<AgoraLogo className="h-24 w-24" />
<h1 className="font-heading text-xl tracking-[0.1em] text-text-marble uppercase">
Créer un compte
Bienvenue, Citoyen
</h1>
<p className="font-serif text-sm text-text-mut italic">
« Que ton nom soit gravé parmi les Citoyens. »
@@ -138,21 +170,6 @@ export default function SignupPage() {
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<label htmlFor="email" className="text-sm font-medium text-text-marble">
Email
</label>
<input
id="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
className="rounded-md border border-gold/30 bg-white/50 px-3 py-2 text-text-marble outline-none focus:border-gold"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="pseudo" className="text-sm font-medium text-text-marble">
Pseudo
@@ -170,7 +187,7 @@ export default function SignupPage() {
<div className="flex flex-col gap-1">
<label htmlFor="password" className="text-sm font-medium text-text-marble">
Mot de passe
Choisis un mot de passe
</label>
<input
id="password"
@@ -209,16 +226,9 @@ export default function SignupPage() {
disabled={loading}
className="mt-2 rounded-md bg-ink-2 px-4 py-2 font-heading text-sm tracking-wide text-gold-bright uppercase transition hover:bg-ink disabled:opacity-50"
>
{loading ? "Création…" : "Créer mon compte"}
{loading ? "Enregistrement…" : "Rejoindre le Tribunal"}
</button>
</form>
<p className="mt-6 text-center text-sm text-text-mut">
Déjà un compte ?{" "}
<Link href="/login" className="font-medium text-oxblood underline hover:text-gold">
Se connecter
</Link>
</p>
</div>
</div>
);
+20
View File
@@ -0,0 +1,20 @@
import Image from "next/image";
// unoptimized : l'optimiseur d'images de Next.js aplatit systématiquement la
// transparence de ce fichier (PNG ou WebP) lors du redimensionnement à la
// volée — on sert donc le fichier statique tel quel, sa transparence réelle
// est alors préservée par le navigateur.
export function AgoraLogo({ className = "h-9 w-9" }: { className?: string }) {
return (
<span className={`relative block shrink-0 ${className}`}>
<Image
src="/logo-agora.webp"
alt="L'Agora"
fill
priority
unoptimized
className="object-cover"
/>
</span>
);
}
+3 -2
View File
@@ -3,7 +3,8 @@
import { useRef, useState } from "react";
import Cropper, { type Area } from "react-easy-crop";
import { cropImageToBlob } from "@/lib/image";
import { IconCamera, IconClose } from "@/components/icons";
import { IconClose } from "@/components/icons";
import { AgoraLogo } from "@/components/agora-logo";
export function AvatarPicker({
hasExistingPhoto = false,
@@ -126,7 +127,7 @@ export function AvatarPicker({
onClick={() => inputRef.current?.click()}
className="flex items-center gap-2 rounded-md bg-ink-2 px-3 py-1.5 text-sm font-medium text-gold-bright hover:bg-ink"
>
<IconCamera className="h-4 w-4" />
<AgoraLogo className="h-4 w-4" />
{fileName || hasExistingPhoto ? "Changer ma photo" : "Choisir une photo"}
</button>
+3 -2
View File
@@ -6,6 +6,7 @@ import { useEffect, useRef, useState } from "react";
import { createClient } from "@/lib/supabase/client";
import { LaurelWreath } from "@/components/laurel-wreath";
import { Avatar } from "@/components/avatar";
import { AgoraLogo } from "@/components/agora-logo";
import {
IconWheel,
IconScroll,
@@ -106,9 +107,9 @@ export function Header({
<header className="sticky top-0 z-10 bg-ink-2/95 backdrop-blur">
<div className="mx-auto flex max-w-4xl items-center justify-between gap-2 px-2 py-2.5 sm:px-4 sm:py-3">
<Link href="/leaderboard" className="flex min-w-0 items-center gap-1.5 sm:gap-2">
<LaurelWreath className="h-5 w-5 shrink-0 sm:h-7 sm:w-7" color="#E7C560" />
<AgoraLogo className="h-7 w-7 sm:h-9 sm:w-9" />
<span className="whitespace-nowrap font-heading text-[0.6rem] tracking-[0.08em] text-gold-bright uppercase sm:text-sm sm:tracking-[0.15em]">
Le Tribunal
L&apos;Agora
</span>
</Link>
+5 -1
View File
@@ -2,7 +2,11 @@ import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
const PROTECTED_PATHS = ["/leaderboard", "/profile", "/admin", "/journal", "/roulette", "/calendrier"];
const AUTH_PATHS = ["/login", "/signup"];
// /signup n'est PAS dans AUTH_PATHS : un compte fraîchement invité a déjà une
// session (établie par le lien d'invitation) mais doit pouvoir rester sur
// /signup pour finaliser mot de passe/pseudo/photo sans être renvoyé au
// leaderboard par ce redirect avant d'avoir terminé.
const AUTH_PATHS = ["/login"];
export async function updateSession(request: NextRequest) {
let response = NextResponse.next({ request });