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>
This commit is contained in:
Valentin ROBIN
2026-07-26 18:08:03 +02:00
parent a807c4c85e
commit 36adb143ca
5 changed files with 89 additions and 76 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 ## 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/). 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. 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**. 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). 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 → 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. 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. Une fois votre propre compte créé et confirmé via `/signup`, désignez-vous Archonte (voir [Rôles & administration](#rôles--administration)). 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 ### 3. Variables d'environnement
@@ -109,7 +110,7 @@ tribunal-app/
├─ src/ ├─ src/
│ ├─ app/ │ ├─ app/
│ │ ├─ login/page.tsx # Connexion (email + mot de passe) │ │ ├─ 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/ │ │ ├─ leaderboard/
│ │ │ ├─ page.tsx # Fetch initial + rôle de l'utilisateur │ │ │ ├─ page.tsx # Fetch initial + rôle de l'utilisateur
│ │ │ └─ leaderboard-view.tsx # Realtime, podium, liste, contrôles juges │ │ │ └─ leaderboard-view.tsx # Realtime, podium, liste, contrôles juges
@@ -139,9 +140,11 @@ tribunal-app/
## Authentification ## 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 ### Verrou de pseudo
+3 -7
View File
@@ -1,9 +1,8 @@
"use client"; "use client";
import Link from "next/link";
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import { createClient } from "@/lib/supabase/client"; import { createClient } from "@/lib/supabase/client";
import { LaurelWreath } from "@/components/laurel-wreath"; import { AgoraLogo } from "@/components/agora-logo";
function mapSignInError(message: string): string { function mapSignInError(message: string): string {
if (message.toLowerCase().includes("email not confirmed")) { 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="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="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"> <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"> <h1 className="font-heading text-xl tracking-[0.1em] text-text-marble uppercase">
Connexion Connexion
</h1> </h1>
@@ -99,10 +98,7 @@ export default function LoginPage() {
</form> </form>
<p className="mt-6 text-center text-sm text-text-mut"> <p className="mt-6 text-center text-sm text-text-mut">
Pas encore de compte ?{" "} L&apos;inscription se fait uniquement sur invitation d&apos;un Archonte.
<Link href="/signup" className="font-medium text-oxblood underline hover:text-gold">
Créer un compte
</Link>
</p> </p>
</div> </div>
</div> </div>
+71 -61
View File
@@ -1,32 +1,65 @@
"use client"; "use client";
import Link from "next/link"; 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 { createClient } from "@/lib/supabase/client";
import { Avatar } from "@/components/avatar"; import { Avatar } from "@/components/avatar";
import { AvatarPicker } from "@/components/avatar-picker"; 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(); const lower = message.toLowerCase();
if (lower.includes("already registered")) { if (lower.includes("password")) {
return "Un compte existe déjà avec cet email."; return "Le mot de passe doit faire au moins 6 caractères.";
}
if (lower.includes("rate limit")) {
return "Trop d'emails envoyés récemment (limite Supabase), réessaie dans quelques minutes.";
} }
return "Une erreur est survenue, réessaie."; return "Une erreur est survenue, réessaie.";
} }
export default function SignupPage() { 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 [pseudo, setPseudo] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [avatarBlob, setAvatarBlob] = useState<Blob | null>(null); const [avatarBlob, setAvatarBlob] = useState<Blob | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null); const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); 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) { function handleCropped(blob: Blob) {
setAvatarBlob(blob); setAvatarBlob(blob);
@@ -46,6 +79,7 @@ export default function SignupPage() {
setError("Le mot de passe doit faire au moins 6 caractères."); setError("Le mot de passe doit faire au moins 6 caractères.");
return; return;
} }
if (!userId) return;
setLoading(true); setLoading(true);
const supabase = createClient(); const supabase = createClient();
@@ -60,58 +94,56 @@ export default function SignupPage() {
return; return;
} }
const { data: signUpData, error: signUpError } = await supabase.auth.signUp({ const { error: passwordError } = await supabase.auth.updateUser({ password });
email, if (passwordError) {
password,
options: { data: { pseudo: trimmedPseudo } },
});
if (signUpError || !signUpData.user) {
setLoading(false); setLoading(false);
setError(mapSignUpError(signUpError?.message ?? "")); setError(mapError(passwordError.message));
return; return;
} }
// Sans session active (confirmation email en attente), impossible let avatarUrl: string | null = null;
// d'uploader la photo : la policy Storage exige un utilisateur if (avatarBlob) {
// authentifié. On l'ajoutera depuis /profile après la 1ère connexion. const path = `${userId}.webp`;
if (signUpData.session && avatarBlob) {
const path = `${signUpData.user.id}.webp`;
const { error: uploadError } = await supabase.storage const { error: uploadError } = await supabase.storage
.from("avatars") .from("avatars")
.upload(path, avatarBlob, { upsert: true, contentType: "image/webp" }); .upload(path, avatarBlob, { upsert: true, contentType: "image/webp" });
if (!uploadError) { if (!uploadError) {
const { data: publicUrlData } = supabase.storage.from("avatars").getPublicUrl(path); const { data: publicUrlData } = supabase.storage.from("avatars").getPublicUrl(path);
await supabase avatarUrl = publicUrlData.publicUrl;
.from("profiles")
.update({ avatar_url: publicUrlData.publicUrl })
.eq("id", signUpData.user.id);
} }
} }
const { error: profileError } = await supabase
.from("profiles")
.update({ pseudo: trimmedPseudo, ...(avatarUrl ? { avatar_url: avatarUrl } : {}) })
.eq("id", userId);
setLoading(false); setLoading(false);
if (!signUpData.session) { if (profileError) {
setConfirmationSent(true); setError(mapError(profileError.message));
return; return;
} }
window.location.href = "/leaderboard"; 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 ( return (
<div className="flex flex-1 items-center justify-center px-4 py-12"> <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"> <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"> <h1 className="mb-4 font-heading text-xl tracking-[0.1em] text-text-marble uppercase">
Compte créé ! Invitation requise
</h1> </h1>
<p className="text-sm text-text-marble/80"> <p className="text-sm text-text-marble/80">
Vérifie ta boîte mail (et les spams) pour confirmer ton adresse, puis La création de compte se fait uniquement sur invitation d&apos;un
connecte-toi. Tu pourras ajouter ta photo de profil ensuite depuis ton Archonte. Ce lien est invalide ou a expiré.
profil.
</p> </p>
<Link <Link
href="/login" 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="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="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"> <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"> <h1 className="font-heading text-xl tracking-[0.1em] text-text-marble uppercase">
Créer un compte Bienvenue, Citoyen
</h1> </h1>
<p className="font-serif text-sm text-text-mut italic"> <p className="font-serif text-sm text-text-mut italic">
« Que ton nom soit gravé parmi les Citoyens. » « Que ton nom soit gravé parmi les Citoyens. »
@@ -138,21 +170,6 @@ export default function SignupPage() {
</div> </div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4"> <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"> <div className="flex flex-col gap-1">
<label htmlFor="pseudo" className="text-sm font-medium text-text-marble"> <label htmlFor="pseudo" className="text-sm font-medium text-text-marble">
Pseudo Pseudo
@@ -170,7 +187,7 @@ export default function SignupPage() {
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label htmlFor="password" className="text-sm font-medium text-text-marble"> <label htmlFor="password" className="text-sm font-medium text-text-marble">
Mot de passe Choisis un mot de passe
</label> </label>
<input <input
id="password" id="password"
@@ -209,16 +226,9 @@ export default function SignupPage() {
disabled={loading} 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" 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> </button>
</form> </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>
</div> </div>
); );
+5 -1
View File
@@ -2,7 +2,11 @@ import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server"; import { NextResponse, type NextRequest } from "next/server";
const PROTECTED_PATHS = ["/leaderboard", "/profile", "/admin", "/journal", "/roulette", "/calendrier"]; 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) { export async function updateSession(request: NextRequest) {
let response = NextResponse.next({ request }); let response = NextResponse.next({ request });