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:
@@ -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'inscription se fait uniquement sur invitation d'un Archonte.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+71
-61
@@ -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
|
||||
.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 <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'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>
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user