From 36adb143ca66d46f92daa51a4f954e78f5aeb3bc Mon Sep 17 00:00:00 2001 From: Valentin ROBIN Date: Sun, 26 Jul 2026 18:08:03 +0200 Subject: [PATCH] Inscription sur invitation uniquement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 2 +- README.md | 15 ++-- src/app/login/page.tsx | 10 +-- src/app/signup/page.tsx | 132 ++++++++++++++++++--------------- src/lib/supabase/middleware.ts | 6 +- 5 files changed, 89 insertions(+), 76 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 35fe0e9..5cde555 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. --- diff --git a/README.md b/README.md index d9c2d20..5f00c8e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index b487970..4b1b41d 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -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() {
- +

Connexion

@@ -99,10 +98,7 @@ export default function LoginPage() {

- Pas encore de compte ?{" "} - - Créer un compte - + L'inscription se fait uniquement sur invitation d'un Archonte.

diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx index 28c5bbc..b673698 100644 --- a/src/app/signup/page.tsx +++ b/src/app/signup/page.tsx @@ -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("checking"); + const [userId, setUserId] = useState(null); const [pseudo, setPseudo] = useState(""); const [password, setPassword] = useState(""); const [avatarBlob, setAvatarBlob] = useState(null); const [avatarPreview, setAvatarPreview] = useState(null); const [error, setError] = useState(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 - .from("profiles") - .update({ avatar_url: publicUrlData.publicUrl }) - .eq("id", signUpData.user.id); + avatarUrl = publicUrlData.publicUrl; } } + const { error: profileError } = await supabase + .from("profiles") + .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
; + } + + if (status === "no-invite") { return (
- +

- Compte créé ! + Invitation requise

- 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'un + Archonte. Ce lien est invalide ou a expiré.

- +

- Créer un compte + Bienvenue, Citoyen

« Que ton nom soit gravé parmi les Citoyens. » @@ -138,21 +170,6 @@ export default function SignupPage() {

-
- - 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" - /> -
-
); diff --git a/src/lib/supabase/middleware.ts b/src/lib/supabase/middleware.ts index 373c1bf..9e9ddbe 100644 --- a/src/lib/supabase/middleware.ts +++ b/src/lib/supabase/middleware.ts @@ -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 });