V2 + V3 : rôles, points, journal, podium et recadrage photo

V2 — authentification et administration :
- Authentification par email + mot de passe (email confirmé, un compte
  par email), abandon de l'email interne dérivé du pseudo.
- Rôles public/judge sur profiles, section Administration (juges
  uniquement, vérifiée côté serveur) pour gérer les membres.
- Verrou de pseudo : modifiable une fois par son propriétaire puis figé,
  contournable par un juge.
- RLS étendue par un trigger BEFORE UPDATE (enforce_profile_update) pour
  verrouiller les colonnes sensibles (role, points, pseudo_locked, pseudo
  figé) — la RLS seule ne peut pas exprimer une règle par colonne.

V3 — points, journal, podium, progression, photo :
- RPC award_points() (SECURITY DEFINER) : seul point d'écriture de la
  colonne points, vérifie le rôle juge côté serveur, delta signé sans
  plancher à 0, trace chaque opération dans points_log.
- Leaderboard temps réel (Supabase Realtime) avec podium top 3
  (égalités gérées), flèches de progression (previous_rank), et
  contrôles de points juges avec confirmation explicite (plus de
  debounce auto) avant envoi.
- Page /journal ("le crieur") : fil live et public des attributions de
  points.
- Recadrage photo carré + compression client (react-easy-crop + canvas)
  au signup et sur le profil.
- Passe de polish visuel : cartes/boutons cohérents, lien actif dans la
  nav, podium retravaillé.

schema.sql, README.md et CLAUDE.md mis à jour en conséquence (schéma
idempotent, instructions SMTP/rôles/migration, conventions RLS+trigger
documentées pour les futures colonnes sensibles).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Valentin ROBIN
2026-07-11 16:14:10 +02:00
parent 87cd286e5a
commit abbd079cd2
27 changed files with 1528 additions and 354 deletions
+38 -22
View File
@@ -3,42 +3,57 @@
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";
import { AvatarPicker } from "@/components/avatar-picker";
function mapError(message: string): string {
if (message.includes("duplicate key")) {
return "Ce pseudo est déjà pris.";
}
if (message.includes("pseudo is locked")) {
return "Ton pseudo est déjà figé.";
}
return "Une erreur est survenue, réessaie.";
}
export function ProfileForm({
userId,
isJudge,
initialPseudo,
initialAvatarUrl,
initialPseudoLocked,
}: {
userId: string;
isJudge: boolean;
initialPseudo: string;
initialAvatarUrl: string | null;
initialPseudoLocked: boolean;
}) {
const router = useRouter();
const [pseudo, setPseudo] = useState(initialPseudo);
const [pseudoLocked, setPseudoLocked] = useState(initialPseudoLocked);
const [avatarUrl, setAvatarUrl] = useState(initialAvatarUrl);
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [avatarBlob, setAvatarBlob] = useState<Blob | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string | 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;
const pseudoChanged = pseudo.trim() !== initialPseudo;
const canEditPseudo = !pseudoLocked || isJudge;
function handleCropped(blob: Blob) {
setAvatarBlob(blob);
setAvatarPreview(URL.createObjectURL(blob));
}
async function handleSubmit(event: FormEvent) {
event.preventDefault();
setError(null);
setSuccess(false);
const slug = slugify(pseudo);
if (!slug) {
const trimmedPseudo = pseudo.trim();
if (!trimmedPseudo) {
setError("Choisis un pseudo valide.");
return;
}
@@ -48,12 +63,11 @@ export function ProfileForm({
let nextAvatarUrl = avatarUrl;
if (avatarFile) {
const ext = avatarFile.name.split(".").pop() ?? "jpg";
const path = `${userId}.${ext}`;
if (avatarBlob) {
const path = `${userId}.webp`;
const { error: uploadError } = await supabase.storage
.from("avatars")
.upload(path, avatarFile, { upsert: true });
.upload(path, avatarBlob, { upsert: true, contentType: "image/webp" });
if (uploadError) {
setLoading(false);
@@ -67,7 +81,7 @@ export function ProfileForm({
const { error: updateError } = await supabase
.from("profiles")
.update({ pseudo, slug, avatar_url: nextAvatarUrl })
.update({ pseudo: trimmedPseudo, avatar_url: nextAvatarUrl })
.eq("id", userId);
setLoading(false);
@@ -78,24 +92,20 @@ export function ProfileForm({
}
setAvatarUrl(nextAvatarUrl);
setAvatarFile(null);
setAvatarBlob(null);
setAvatarPreview(null);
setSuccess(true);
if (pseudoChanged && !isJudge) {
setPseudoLocked(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>
<Avatar pseudo={pseudo || "?"} avatarUrl={avatarPreview ?? avatarUrl} size="lg" />
<AvatarPicker label="Changer la photo" onCropped={handleCropped} />
</div>
<div className="flex flex-col gap-1">
@@ -106,10 +116,16 @@ export function ProfileForm({
id="pseudo"
type="text"
required
disabled={!canEditPseudo}
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"
className="rounded-md border border-navy/20 bg-white px-3 py-2 text-ink outline-none focus:border-gold disabled:bg-navy/5 disabled:text-ink/50"
/>
{!canEditPseudo && (
<p className="text-xs text-ink/60">
Pseudo figé contacte un juge pour le changer.
</p>
)}
</div>
{error && (