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
+80 -145
View File
@@ -1,178 +1,113 @@
@AGENTS.md
# Le Tribunal — Brief de démarrage V1
# Le Tribunal
> **À l'attention de Claude Code.** Ce document décrit **uniquement la V1**. Reste simple, ne construis rien qui n'est pas listé dans « Périmètre V1 ». Les fonctionnalités futures sont listées en fin de doc **seulement** pour que l'architecture reste extensible — ne les implémente pas maintenant.
Application web pour un groupe d'amis (~12 personnes) qui anime un jeu et des olympiades pendant une semaine de vacances : comptes, rôles, leaderboard en direct, attribution de points par des juges, journal public des événements. Ré-thématisable par édition (un thème différent chaque année).
## 1. État actuel
V1, V2 et V3 sont livrées :
- **V1** : comptes, profils, leaderboard statique.
- **V2** : authentification par email réel (plus de pseudo+email interne), rôles `public`/`judge`, verrou de pseudo (modifiable une fois), section Administration.
- **V3** : attribution de points par les juges (RPC sécurisée, +/- avec confirmation explicite), journal public « le crieur », podium top 3, flèches de progression, recadrage/compression photo côté client.
Hors périmètre pour l'instant (voir roadmap en fin de doc) : éditions/thèmes, le jeu du Tribunal lui-même (Char, roulette, ostracisme, timer du Gardien), mode grand écran.
---
## 1. But du projet
## 2. Stack technique
Application web pour un groupe d'amis (~12 personnes) qui animera un jeu et des olympiades pendant une semaine de vacances. On démarre par le socle : **comptes utilisateurs + leaderboard**. Tout le reste (jeu du « Tribunal », roulette, ostracisme, etc.) viendra ensuite.
- **Framework** : Next.js 16 (App Router) + TypeScript
- **Style** : Tailwind CSS v4 (design tokens en variables CSS, voir `globals.css`)
- **Auth + Base + Stockage + Temps réel** : Supabase (Auth, Postgres, Storage, Realtime)
- **Recadrage photo** : `react-easy-crop` + canvas natif (resize/compression client, jamais d'upload de l'image brute)
- **Déploiement** : Vercel (front) + Supabase (managé) — pas encore déployé, tourne en local pour l'instant
## 2. Périmètre V1 (et seulement ça)
1. **Site responsive** (mobile-first, propre aussi sur ordinateur).
2. **Création de compte** : pseudo + mot de passe + photo de profil.
3. **Connexion / déconnexion**.
4. **Page profil** : voir et modifier son pseudo et sa photo.
5. **Leaderboard** : liste de toutes les personnes inscrites, triée par points décroissants, affichant **rang, photo, pseudo, points**.
### Hors périmètre V1 (ne pas coder maintenant)
Attribution de points depuis l'app, rôles admin/juges, jeu du Char, roulette, ostracisme, timer du Gardien, thèmes/éditions, temps réel. En V1, les points existent en base (défaut `0`) mais **il n'y a pas d'interface pour les modifier** — on les ajustera plus tard (ou manuellement via la console Supabase pour tester).
Next.js 16 a renommé `middleware.ts` en `proxy.ts` (fichier `src/proxy.ts`, fonction `proxy`). `src/lib/supabase/middleware.ts` est un nom de fichier utilitaire interne, sans rapport avec cette convention.
---
## 3. Stack technique
## 3. Modèle de données (`supabase/schema.sql`, source de vérité)
Choisie pour être simple à construire, sécurisée et gratuite à héberger pour 12 utilisateurs :
Le script est **idempotent** : toujours le ré-exécuter en entier après une modification, jamais de migration incrémentale séparée.
- **Framework** : Next.js (App Router) + TypeScript
- **Style** : Tailwind CSS (mobile-first)
- **Auth + Base de données + Stockage photos** : **Supabase**
- Supabase Auth pour la connexion
- Postgres pour les profils
- Supabase Storage (bucket `avatars`) pour les photos
- **Déploiement** : Vercel (front) + Supabase (managé)
### `public.profiles` (liée à `auth.users`, créée automatiquement par un trigger sur `auth.users`)
Pas d'autre dépendance lourde. Composants UI faits main avec Tailwind (pas de librairie de composants pour l'instant).
| Colonne | Notes |
|-----------------|-------------------------------------------------------------------|
| `id` | = `auth.users.id` |
| `pseudo` | affiché partout, unique (insensible à la casse) |
| `avatar_url` | URL publique Storage (`{user_id}.webp`) |
| `points` | jamais modifiable directement — uniquement via RPC `award_points` |
| `role` | `public` (défaut) ou `judge` |
| `pseudo_locked` | passe à `true` automatiquement au 1er changement de pseudo |
| `previous_rank` | repère de classement, mis à jour par `reset_rank_reference()` |
### `public.points_log` (journal, lecture seule côté client)
`id, target_id, judge_id, delta, reason, created_at` — écrit uniquement par la RPC `award_points`.
### Colonnes sensibles : RLS + trigger, jamais confiance au client
La RLS est **au niveau ligne** : elle ne peut pas exprimer « cette colonne seulement si tel rôle ». Le verrouillage fin des colonnes (`role`, `points`, `pseudo_locked`, pseudo figé, `previous_rank`) passe par le trigger `enforce_profile_update()` :
- `points` : bloqué pour **tout le monde**, y compris les juges, en écriture directe. Seule la RPC `award_points()` (SECURITY DEFINER) peut la modifier, via un flag de session (`app.bypass_points_lock`) qui autorise explicitement CETTE écriture précise.
- `role`, `previous_rank` : modifiables uniquement par un `judge`.
- `pseudo` : modifiable une fois par le propriétaire (auto-lock ensuite), ou à tout moment par un `judge`.
- `auth.uid() is null` (SQL Editor, migrations) bypass le trigger : ces accès sont déjà fiables par construction.
Ce pattern (RLS pour l'accès à la ligne + trigger `BEFORE UPDATE` pour l'accès aux colonnes) est la convention du projet — le reproduire pour toute nouvelle colonne sensible plutôt que d'inventer autre chose.
### RPC exposées
- `is_pseudo_taken(p_pseudo)` — anon + authenticated, ne renvoie qu'un booléen (l'anon ne peut pas lire `profiles`).
- `award_points(p_target_id, p_delta, p_reason)` — authenticated, vérifie `role = 'judge'` côté serveur, jamais côté client.
- `reset_rank_reference()` — authenticated, vérifie `role = 'judge'`, fige le classement courant dans `previous_rank`.
---
## 4. Authentification (pseudo + mot de passe)
## 4. Authentification
L'utilisateur se connecte avec **un pseudo et un mot de passe** — pas d'email visible.
**Implémentation :** on utilise Supabase Auth (email/password) en **dérivant un email interne** à partir du pseudo, invisible pour l'utilisateur :
```ts
const internalEmail = `${slugify(pseudo)}@letribunal.test`;
// signup: supabase.auth.signUp({ email: internalEmail, password })
// login : supabase.auth.signInWithPassword({ email: internalEmail, password })
```
- `slugify` : minuscules, sans accents, espaces → `-`, caractères non alphanumériques retirés.
- Le pseudo affiché (avec accents/casse) est stocké dans la table `profiles`.
- Vérifier l'**unicité du pseudo** au signup (le slug sert de clé technique).
- Mots de passe : gérés/hashés par Supabase Auth (ne jamais stocker de mot de passe en clair).
> Règle de sécurité produit : on ne collecte pas d'email réel, c'est un cercle d'amis. Pas de reset par email en V1 (un admin pourra réinitialiser via Supabase si besoin).
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.
---
## 5. Modèle de données
## 5. Pages & navigation
Table `profiles` (liée à `auth.users`) :
| Route | Contenu |
|----------------|---------------------------------------------------------------------------|
| `/` | Redirige vers `/leaderboard` si connecté, sinon `/login` |
| `/login` | Email + mot de passe |
| `/signup` | Email + mot de passe + pseudo + photo (recadrée) ; gère l'attente de confirmation email |
| `/leaderboard` | Podium top 3 + liste, Realtime, contrôles de points pour les juges |
| `/journal` | « Le crieur » — fil live des attributions de points, lecture pour tous |
| `/profile` | Pseudo (verrouillable), photo, déconnexion |
| `/admin` | Juges uniquement, **vérifié côté serveur** (Server Component) — gestion des membres, reset du repère de classement |
| Colonne | Type | Notes |
|--------------|-------------|----------------------------------------------|
| `id` | uuid (PK) | = `auth.users.id` |
| `pseudo` | text | affiché, unique (insensible à la casse) |
| `slug` | text | unique, dérivé du pseudo (clé technique) |
| `avatar_url` | text (null) | URL publique de la photo dans Storage |
| `points` | int | défaut `0` |
| `created_at` | timestamptz | défaut `now()` |
**Storage :** bucket public `avatars`, un fichier par utilisateur (`{user_id}.<ext>`).
**Row Level Security (RLS) — à activer :**
- `SELECT` sur `profiles` : autorisé à tout utilisateur **authentifié** (nécessaire pour afficher le leaderboard de tout le monde).
- `INSERT` : un utilisateur ne peut créer que **sa propre** ligne (`auth.uid() = id`).
- `UPDATE` : un utilisateur ne peut modifier que **sa propre** ligne, et **uniquement** `pseudo`, `slug`, `avatar_url`**pas `points`** (les points seront gérés plus tard côté admin).
- `DELETE` : interdit en V1.
Fournis le SQL des tables + policies dans un fichier `supabase/schema.sql` versionné.
`/leaderboard`, `/profile`, `/admin`, `/journal` sont protégées par `src/proxy.ts` (redirection `/login` si non connecté). L'accès juge-only de `/admin` n'est **pas** géré par le proxy (il n'a pas facilement le rôle) — c'est la page elle-même qui vérifie et redirige.
---
## 6. Pages & navigation
## 6. Look & feel
| Route | Contenu |
|---------------|-------------------------------------------------------------------------|
| `/` | Redirige vers `/leaderboard` si connecté, sinon vers `/login` |
| `/login` | Formulaire pseudo + mot de passe ; lien vers `/signup` |
| `/signup` | Pseudo + mot de passe + upload photo ; crée le compte et le profil |
| `/leaderboard`| Liste triée par points ↓ : rang, photo, pseudo, points (protégée) |
| `/profile` | Voir/modifier son pseudo et sa photo ; bouton déconnexion (protégée) |
Design tokens en variables CSS (`globals.css`) : `--navy #0F2748`, `--gold #C9A227`, `--ivory #F4ECD8`, `--ink #2A2116`. Ne jamais coder une couleur en dur dans un composant — passer par ces tokens pour que le re-thème par édition reste possible.
- Les routes `/leaderboard` et `/profile` sont **protégées** : rediriger vers `/login` si non connecté (via middleware Next.js + session Supabase).
- Une barre de navigation simple (Leaderboard · Profil · Déconnexion) visible une fois connecté.
Convention de carte : `rounded-xl border border-navy/10 bg-white shadow-sm` (formulaires, podium) ou `rounded-lg` pour les lignes de liste. Bouton primaire : `bg-navy text-ivory hover:bg-navy/90`. Bouton secondaire : `border border-navy/20 hover:bg-navy/5`.
---
## 7. Look & feel
## 7. Conventions
Sobre, lisible, **mobile-first**, agréable en vidéoprojection plus tard. Définir les couleurs comme **variables CSS / tokens Tailwind** pour pouvoir re-thématiser facilement par la suite (un thème par édition est prévu plus tard — ne pas coder de thème « en dur » dans les composants).
Palette par défaut (tokens) :
- `--navy: #0F2748` (fond / barres)
- `--gold: #C9A227` (accent, rang 1)
- `--ivory: #F4ECD8` (surfaces claires)
- `--ink: #2A2116` (texte sur clair)
Leaderboard : le **rang 1** est mis en valeur (accent doré). Photos en avatars ronds. Fallback si pas de photo : initiales du pseudo sur fond de couleur.
Qualité minimale : responsive jusqu'au mobile, focus clavier visible, images optimisées, états de chargement et messages d'erreur clairs (« Ce pseudo est déjà pris », « Mot de passe incorrect », etc. — pas de message technique brut).
---
## 8. Plan de construction (ordre conseillé)
1. Scaffolder Next.js + TypeScript + Tailwind.
2. Configurer le client Supabase (`.env.local`) et le middleware de session.
3. Écrire `supabase/schema.sql` (table `profiles` + RLS + bucket `avatars`).
4. Auth : pages `/signup` et `/login` avec la logique pseudo → email interne.
5. Upload de la photo au signup + stockage dans le bucket + `avatar_url` en base.
6. Page `/leaderboard` (lecture de tous les profils, tri par points).
7. Page `/profile` (édition pseudo/photo, déconnexion).
8. Middleware de protection des routes + redirections.
9. Polissage responsive + états vides/chargement/erreurs.
10. `README.md` avec instructions de setup et de déploiement.
---
## 9. Configuration & commandes
Variables d'environnement (`.env.local`) :
```
NEXT_PUBLIC_SUPABASE_URL=...
NEXT_PUBLIC_SUPABASE_ANON_KEY=...
```
Documenter dans le `README.md` : création du projet Supabase, exécution de `schema.sql`, création du bucket `avatars`, lancement (`npm run dev`) et déploiement Vercel.
---
## 10. Definition of Done (V1)
- [ ] On peut créer un compte (pseudo + mot de passe + photo) et être connecté ensuite.
- [ ] On peut se déconnecter puis se reconnecter avec pseudo + mot de passe.
- [ ] Un pseudo déjà pris est refusé avec un message clair.
- [ ] Le leaderboard affiche **tous** les inscrits, triés par points, avec rang/photo/pseudo/points.
- [ ] On peut changer sa photo et son pseudo depuis `/profile`.
- [ ] Les routes protégées redirigent vers `/login` si non connecté.
- [ ] Le site est propre et utilisable sur téléphone **et** ordinateur.
- [ ] RLS activée : personne ne peut modifier les points ni le profil d'autrui.
---
## 11. Roadmap (ne pas coder maintenant — juste pour garder l'archi extensible)
Prévoir que ces éléments s'ajouteront ensuite, sans refonte :
- **Rôles** : juge/admin (`role` sur `profiles`) → attribution de points, modération.
- **Notion d'édition/thème** : une table `editions` (année, nom du thème, mood board, vocabulaire) ; les points deviendront rattachables à une édition.
- **Jeu du Tribunal** : le Char (zone de pénalité), batailles de cul sec, ostracisme, roulette, timer du Gardien.
- **Temps réel** : leaderboard et fil d'événements live via Supabase Realtime, mode « grand écran » pour vidéoprojecteur.
Garde le code modulaire (composants réutilisables, accès aux données isolé) pour que ces ajouts restent simples.
---
## 12. À reporter dans `CLAUDE.md` (conventions durables du repo)
- Stack : Next.js (App Router) + TS + Tailwind + Supabase.
- Auth = pseudo + mot de passe via email interne dérivé (`slug@letribunal.test`).
- Toujours passer par les policies RLS ; ne jamais exposer la `service_role key` côté client.
- Design tokens = variables CSS (thème swappable) ; pas de couleur codée en dur dans les composants.
- Toujours passer par les policies RLS + trigger ; ne jamais exposer la `service_role key` côté client.
- Toute nouvelle colonne sensible sur `profiles` (ou une future table) suit le pattern RLS+trigger de la section 3, pas un `grant`/`revoke` par colonne (qui ne peut pas distinguer les rôles applicatifs).
- Aperçus locaux d'image avant upload (crop, fichier choisi) = `blob:`/`data:` URL → toujours `unoptimized` sur `next/image` ou une balise `<img>` classique (`next/image` ne sait pas résoudre ces schémas côté serveur).
- Commandes : `npm run dev`, `npm run build`, `npm run lint`.
- Garder chaque fonctionnalité future isolée et le schéma SQL versionné dans `supabase/schema.sql`.
- Le schéma SQL (`supabase/schema.sql`) est la seule source de vérité pour la base — le tenir à jour à chaque changement de données/sécurité, et le garder idempotent.
---
## 8. Roadmap (pas encore codé)
- **Éditions & thèmes** : table `editions` (année, thème, mood board, vocabulaire) ; les points deviendront rattachables à une édition.
- **Le jeu du Tribunal** : le Char (zone de pénalité), batailles de cul sec, ostracisme, roulette, timer du Gardien.
- **Mode grand écran** : vue leaderboard optimisée pour vidéoprojecteur.
- **Déploiement** : pas encore fait (Vercel + Supabase managé prévus, voir README).
+82 -34
View File
@@ -1,18 +1,18 @@
# Le Tribunal
Application web pour animer et scorer le jeu **« Le Tribunal »** entre amis pendant une semaine de vacances : comptes, profils et leaderboard. Le socle est **ré-thématisable par édition** (un thème différent chaque année).
> **État actuel : V1** — le périmètre se limite à l'authentification, aux profils et au leaderboard. Le jeu lui-même (le bus, la roulette, l'ostracisme, etc.) arrivera dans des versions suivantes (voir la [roadmap](#roadmap)).
Application web pour animer et scorer le jeu **« Le Tribunal »** entre amis pendant une semaine de vacances : comptes, rôles, leaderboard en direct, attribution de points par les juges et journal public des événements. Le socle est **ré-thématisable par édition** (un thème différent chaque année).
---
## Fonctionnalités (V1)
## Fonctionnalités
- 🔐 **Création de compte** avec pseudo, mot de passe et photo de profil
- 👤 **Connexion / déconnexion** par pseudo + mot de passe (pas d'email visible)
- 🖼️ **Profil éditable** : changer son pseudo et sa photo
- 🏆 **Leaderboard** de tous les inscrits, trié par points (rang · photo · pseudo · points)
- 📱 **Responsive**, pensé mobile-first et propre sur ordinateur
- 🔐 **Création de compte** par email + mot de passe (email confirmé, un compte par email)
- 👤 **Connexion / déconnexion**, pseudo affiché partout (jamais l'email)
- 🖼️ **Profil éditable** : pseudo modifiable **une seule fois** puis figé (sauf par un juge), photo recadrée en rond, redimensionnée et compressée côté client
- 🏆 **Leaderboard en direct** (Supabase Realtime) : podium top 3 (or/argent/bronze, gestion des égalités), flèches ▲▼ de progression depuis le dernier « round »
- ⚖️ **Rôles** : `public` / `judge`. Les juges attribuent des points (+/- avec montant personnalisé et motif, confirmation explicite avant envoi) et gèrent les membres depuis une section **Administration**
- 📣 **Le crieur** : journal public et en direct de toutes les attributions de points
- 📱 **Responsive**, pensé mobile-first et propre en vidéoprojection
---
@@ -20,9 +20,11 @@ Application web pour animer et scorer le jeu **« Le Tribunal »** entre amis pe
| Rôle | Techno |
|--------------------------|---------------------------------|
| Framework | Next.js (App Router) + TypeScript |
| Style | Tailwind CSS |
| Auth · Base · Stockage | Supabase (Auth, Postgres, Storage) |
| Framework | Next.js 16 (App Router) + TypeScript |
| Style | Tailwind CSS v4 |
| Auth · Base · Stockage · Temps réel | Supabase (Auth, Postgres, Storage, Realtime) |
| Recadrage photo | react-easy-crop + canvas (resize/compression client) |
| Email transactionnel | SMTP custom (ex. Resend) branché sur Supabase Auth |
| Déploiement | Vercel + Supabase (managé) |
---
@@ -31,6 +33,7 @@ Application web pour animer et scorer le jeu **« Le Tribunal »** entre amis pe
- [Node.js](https://nodejs.org/) 20.19+ (Next.js 16 / React 19)
- Un compte [Supabase](https://supabase.com/) (offre gratuite suffisante)
- Un fournisseur SMTP gratuit (ex. [Resend](https://resend.com/), 100 emails/jour) — le service email intégré de Supabase a un rate limit trop bas pour un usage réel
- Un compte [Vercel](https://vercel.com/) pour le déploiement (optionnel en local)
---
@@ -48,14 +51,14 @@ npm install
### 2. Créer le projet Supabase
1. Créez un nouveau projet sur [supabase.com](https://supabase.com/).
2. Dans **SQL Editor**, exécutez le contenu de [`supabase/schema.sql`](supabase/schema.sql) (table `profiles`, policies RLS, bucket `avatars`).
2. Dans **SQL Editor**, exécutez tout le contenu de [`supabase/schema.sql`](supabase/schema.sql) (tables `profiles`/`points_log`, 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**, **désactivez "Confirm email"**. L'app utilise des adresses techniques fictives (`pseudo@letribunal.test`) qui ne peuvent recevoir aucun email : si la confirmation reste activée, personne ne peut se connecter après inscription.
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 juge (voir [Rôles & administration](#rôles--administration)).
### 3. Variables d'environnement
Copiez le fichier d'exemple et renseignez vos valeurs :
```bash
cp .env.local.example .env.local
```
@@ -102,18 +105,24 @@ L'application tourne sur [http://localhost:3000](http://localhost:3000).
tribunal-app/
├─ src/
│ ├─ app/
│ │ ├─ login/page.tsx # Connexion (pseudo + mot de passe)
│ │ ├─ signup/page.tsx # Création de compte + upload photo
│ │ ├─ leaderboard/page.tsx # Classement de tous les inscrits
│ │ ├─ profile/ # Édition du profil + déconnexion
│ │ ├─ layout.tsx # Layout + navigation
│ │ page.tsx # Redirection selon l'état de connexion
│ ├─ components/ # Composants UI réutilisables (Avatar, NavBar)
│ │ ├─ login/page.tsx # Connexion (email + mot de passe)
│ │ ├─ signup/page.tsx # Création de compte + photo recadrée
│ │ ├─ leaderboard/
│ │ ├─ page.tsx # Fetch initial + rôle de l'utilisateur
│ │ │ └─ leaderboard-view.tsx # Realtime, podium, liste, contrôles juges
│ │ journal/ # « Le crieur » : fil d'événements en direct
│ ├─ admin/ # Gestion des membres + reset du repère (juges)
│ │ ├─ profile/ # Édition pseudo (verrou) / photo, déconnexion
│ │ ├─ layout.tsx # Layout + navigation
│ │ └─ page.tsx # Redirection selon l'état de connexion
│ ├─ components/ # Avatar, AvatarPicker (recadrage), NavBar, Podium,
│ │ │ # JudgePointControls
│ ├─ lib/
│ │ ├─ auth.ts # slugify / email interne dérivé du pseudo
│ │ supabase/ # Clients Supabase (browser / server / proxy)
│ └─ proxy.ts # Protection des routes + rafraîchissement session
├─ supabase/schema.sql # Schéma + policies RLS + bucket avatars (versionné)
│ │ ├─ ranking.ts # Classement avec égalités + calcul des flèches
│ │ image.ts # Recadrage/compression canvas côté client
│ └─ supabase/ # Clients Supabase (browser / server / proxy)
│ └─ proxy.ts # Protection des routes + rafraîchissement session
├─ supabase/schema.sql # Schéma complet + policies RLS + triggers + RPC (versionné)
├─ .env.local.example
└─ README.md
```
@@ -122,17 +131,57 @@ tribunal-app/
## Authentification
La connexion se fait par **pseudo + mot de passe**, sans email visible. En interne, un email technique est dérivé du pseudo (`slug@letribunal.test`) pour s'appuyer sur Supabase Auth — `.test` est un TLD réservé (RFC 2606), accepté par le validateur d'email de Supabase (`.local` est rejeté). Le pseudo affiché est stocké dans la table `profiles`, et son unicité est vérifiée à l'inscription.
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)).
> Pas de réinitialisation de mot de passe par email en V1 (cercle d'amis, pas d'email réel collecté). Un administrateur peut réinitialiser un mot de passe depuis la console Supabase si nécessaire.
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é).
### Verrou de pseudo
Chaque membre peut changer son pseudo **une seule fois** depuis `/profile`. Dès l'enregistrement, `pseudo_locked` passe à `true` et le champ devient en lecture seule (« pseudo figé — contacte un juge pour le changer »). Un juge peut toujours modifier le pseudo de quiconque et, s'il le souhaite, redéverrouiller un compte depuis **Administration**.
### Rôles & administration
Deux rôles : `public` (défaut) et `judge`. Les 2 premiers juges sont désignés manuellement via le SQL Editor :
```sql
select id, pseudo from public.profiles;
update public.profiles set role = 'judge' where id = '<uuid-du-membre>';
```
Ensuite, un juge peut promouvoir/rétrograder d'autres membres et éditer leur pseudo depuis la page **Administration** (visible uniquement par les juges, et vérifié côté serveur — pas seulement caché en front).
### Email transactionnel
Le service email intégré de Supabase (sans SMTP custom) limite l'envoi à quelques emails par heure — largement insuffisant dès qu'on teste à plusieurs. Configurez un SMTP custom dans **Authentication → Settings → SMTP Settings**, par exemple avec [Resend](https://resend.com/) (gratuit, 100 emails/jour) :
| Champ | Valeur |
|---|---|
| Sender email | `onboarding@resend.dev` (domaine de test Resend) ou une adresse d'un domaine que vous avez vérifié |
| Host | `smtp.resend.com` |
| Port | `465` |
| Username | `resend` (en minuscules) |
| Password | votre clé API Resend |
> Avec le domaine de test `onboarding@resend.dev`, Resend ne livre qu'à l'adresse email du compte Resend lui-même. Pour envoyer à tous vos amis, vérifiez votre propre domaine dans Resend.
---
## Points, journal et classement
- **Attribution de points** : depuis le leaderboard, un juge clique `+`/`` (montant personnalisable, motif optionnel) — le delta reste **en attente** (affiché en couleur) jusqu'à un clic sur **Confirmer** (envoie la RPC `award_points`) ou **Annuler**. Le client n'écrit jamais directement la colonne `points` : la RPC vérifie le rôle juge côté serveur, applique le delta (négatif autorisé, pas de plancher à 0) et journalise l'opération.
- **Le crieur** (`/journal`) : fil de tous les événements (« Untel a donné +5 à Untel — motif »), visible par tous les authentifiés, alimenté par Supabase Realtime.
- **Podium** : top 3 mis en avant (or/argent/bronze), gère proprement les égalités (rangs partagés) et les groupes de moins de 3 membres.
- **Flèches de progression** : ▲/▼/= comparent le rang actuel au dernier repère. Un juge fige un nouveau repère via **Nouveau round** dans **Administration**.
---
## Sécurité
- Mots de passe hachés et gérés par Supabase Auth (jamais stockés en clair).
- **Row Level Security** activée : chacun ne peut modifier que son propre profil, et **les points ne sont pas modifiables** depuis l'application (gestion admin prévue plus tard).
- Lecture des profils réservée aux utilisateurs authentifiés (nécessaire pour le leaderboard).
- **Row Level Security** activée sur `profiles` et `points_log`. La RLS étant au niveau ligne, les colonnes sensibles (`role`, `points`, `pseudo_locked`, pseudo figé) sont verrouillées par un **trigger `BEFORE UPDATE`** qui ne fait jamais confiance à ce qu'envoie le client.
- Les points ne sont modifiables que via la RPC `award_points` (`SECURITY DEFINER`), jamais par une écriture directe — même un juge ne peut pas modifier la colonne `points` à la main.
- `points_log` est en lecture seule pour les clients ; seule la RPC peut y écrire.
- La page `/admin` vérifie le rôle côté serveur (Server Component) avant de rendre quoi que ce soit — pas de simple masquage front.
---
@@ -144,13 +193,12 @@ Les couleurs sont définies comme **design tokens** (variables CSS), afin de pou
## Roadmap
- [ ] **Rôles admin / juges** — attribution de points, modération
- [ ] **Éditions & thèmes** — année, nom du thème, mood board, vocabulaire configurable
- [ ] **Le jeu du Tribunal** — le Char, batailles de cul sec, ostracisme, roulette, timer du Gardien
- [ ] **Temps réel** — leaderboard et fil d'événements live, mode « grand écran » pour vidéoprojecteur
- [ ] **Mode grand écran** vue leaderboard optimisée vidéoprojecteur
---
## Licence
Projet personnel et privé, destiné à un usage entre amis.
Projet personnel et privé, destiné à un usage entre amis.
+21 -1
View File
@@ -12,7 +12,8 @@
"@supabase/supabase-js": "^2.109.0",
"next": "16.2.10",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"react-easy-crop": "^6.2.2"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -5315,6 +5316,12 @@
"node": ">=18"
}
},
"node_modules/normalize-wheel": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz",
"integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==",
"license": "BSD-3-Clause"
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -5678,6 +5685,19 @@
"react": "^19.2.4"
}
},
"node_modules/react-easy-crop": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-6.2.2.tgz",
"integrity": "sha512-b0HOicSvLYoNk1yvZTwjH8sNjF5uD9xLtWrSDnv+fx1dXTbwd8bNLQaP6gjXw//A+tni9Mw5KDmPNOEjKF55NQ==",
"license": "MIT",
"dependencies": {
"normalize-wheel": "^1.0.1"
},
"peerDependencies": {
"react": ">=16.4.0",
"react-dom": ">=16.4.0"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+2 -1
View File
@@ -13,7 +13,8 @@
"@supabase/supabase-js": "^2.109.0",
"next": "16.2.10",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"react-easy-crop": "^6.2.2"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
+146
View File
@@ -0,0 +1,146 @@
"use client";
import { useState } from "react";
import { createClient } from "@/lib/supabase/client";
import { Avatar } from "@/components/avatar";
type Role = "public" | "judge";
type Member = {
id: string;
pseudo: string;
role: string;
pseudo_locked: boolean;
avatar_url: string | null;
};
function MemberRow({ member, isSelf }: { member: Member; isSelf: boolean }) {
const [savedPseudo, setSavedPseudo] = useState(member.pseudo);
const [savedRole, setSavedRole] = useState<Role>(member.role === "judge" ? "judge" : "public");
const [pseudo, setPseudo] = useState(member.pseudo);
const [role, setRole] = useState<Role>(member.role === "judge" ? "judge" : "public");
const [pseudoLocked, setPseudoLocked] = useState(member.pseudo_locked);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const dirty = pseudo.trim() !== savedPseudo || role !== savedRole;
async function handleSave() {
setError(null);
setSuccess(false);
const trimmed = pseudo.trim();
if (!trimmed) {
setError("Pseudo invalide.");
return;
}
setSaving(true);
const supabase = createClient();
const { error: updateError } = await supabase
.from("profiles")
.update({ pseudo: trimmed, role })
.eq("id", member.id);
setSaving(false);
if (updateError) {
setError(
updateError.message.includes("duplicate key")
? "Ce pseudo est déjà pris."
: "Une erreur est survenue.",
);
return;
}
setSavedPseudo(trimmed);
setSavedRole(role);
setSuccess(true);
}
async function handleUnlock() {
setError(null);
setSuccess(false);
setSaving(true);
const supabase = createClient();
const { error: updateError } = await supabase
.from("profiles")
.update({ pseudo_locked: false })
.eq("id", member.id);
setSaving(false);
if (updateError) {
setError("Une erreur est survenue.");
return;
}
setPseudoLocked(false);
}
return (
<li className="flex flex-col gap-2 rounded-lg border border-navy/10 bg-white px-4 py-3 shadow-sm sm:flex-row sm:items-center sm:gap-4">
<Avatar pseudo={member.pseudo} avatarUrl={member.avatar_url} size="sm" />
<input
type="text"
value={pseudo}
onChange={(event) => setPseudo(event.target.value)}
className="flex-1 rounded-md border border-navy/20 bg-white px-2 py-1 text-sm outline-none focus:border-gold"
/>
<select
value={role}
onChange={(event) => setRole(event.target.value as Role)}
disabled={isSelf}
className="rounded-md border border-navy/20 bg-white px-2 py-1 text-sm outline-none focus:border-gold disabled:opacity-50"
>
<option value="public">public</option>
<option value="judge">judge</option>
</select>
{pseudoLocked ? (
<button
type="button"
onClick={handleUnlock}
disabled={saving}
className="whitespace-nowrap rounded-md border border-navy/20 px-2 py-1 text-xs text-navy hover:bg-navy/5 disabled:opacity-50"
>
Déverrouiller le pseudo
</button>
) : (
<span className="whitespace-nowrap text-xs text-ink/50">pseudo libre</span>
)}
<button
type="button"
onClick={handleSave}
disabled={saving || !dirty}
className="whitespace-nowrap rounded-md bg-navy px-3 py-1.5 text-sm font-medium text-ivory transition hover:bg-navy/90 disabled:opacity-40"
>
{saving ? "…" : "Enregistrer"}
</button>
{error && <p className="text-xs text-red-700">{error}</p>}
{success && !dirty && <p className="text-xs text-green-700">OK</p>}
</li>
);
}
export function AdminMembersTable({
members,
currentUserId,
}: {
members: Member[];
currentUserId: string;
}) {
if (members.length === 0) {
return <p className="text-sm text-ink/70">Aucun membre inscrit.</p>;
}
return (
<ul className="flex flex-col gap-2">
{members.map((member) => (
<MemberRow key={member.id} member={member} isSelf={member.id === currentUserId} />
))}
</ul>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { AdminMembersTable } from "./admin-members-table";
import { ResetRoundButton } from "./reset-round-button";
export default async function AdminPage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
redirect("/login");
}
const { data: callerProfile } = await supabase
.from("profiles")
.select("role")
.eq("id", user.id)
.single();
// Vérification côté serveur : un "public" ne doit jamais pouvoir
// atteindre cette page, même en tapant l'URL directement.
if (callerProfile?.role !== "judge") {
redirect("/leaderboard");
}
const { data: members } = await supabase
.from("profiles")
.select("id, pseudo, role, pseudo_locked, avatar_url")
.order("pseudo", { ascending: true });
return (
<div className="mx-auto w-full max-w-3xl px-4 py-8">
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-semibold text-navy">Administration</h1>
<ResetRoundButton />
</div>
<AdminMembersTable members={members ?? []} currentUserId={user.id} />
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { useState } from "react";
import { createClient } from "@/lib/supabase/client";
export function ResetRoundButton() {
const [state, setState] = useState<"idle" | "loading" | "done">("idle");
async function handleClick() {
setState("loading");
const supabase = createClient();
const { error } = await supabase.rpc("reset_rank_reference");
setState(error ? "idle" : "done");
if (!error) {
setTimeout(() => setState("idle"), 2000);
}
}
return (
<button
type="button"
onClick={handleClick}
disabled={state === "loading"}
className="rounded-md border border-navy/20 bg-white px-3 py-1.5 text-sm font-medium text-navy shadow-sm transition hover:bg-navy/5 disabled:opacity-50"
>
{state === "loading" ? "…" : state === "done" ? "Repère mis à jour ✓" : "Nouveau round (réinitialiser le repère)"}
</button>
);
}
+13
View File
@@ -31,3 +31,16 @@ body {
outline: 2px solid var(--color-gold);
outline-offset: 2px;
}
@keyframes points-flash {
0% {
background-color: color-mix(in srgb, var(--color-gold) 55%, transparent);
}
100% {
background-color: transparent;
}
}
.animate-points-flash {
animation: points-flash 900ms ease-out;
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
import { useEffect, useState } from "react";
import { createClient } from "@/lib/supabase/client";
type Entry = {
id: number;
target_id: string;
judge_id: string;
delta: number;
reason: string | null;
created_at: string;
};
function formatTime(iso: string): string {
return new Date(iso).toLocaleString("fr-FR", {
dateStyle: "short",
timeStyle: "short",
timeZone: "Europe/Paris",
});
}
export function JournalView({
initialEntries,
pseudoById,
}: {
initialEntries: Entry[];
pseudoById: Record<string, string>;
}) {
const [entries, setEntries] = useState<Entry[]>(initialEntries);
useEffect(() => {
const supabase = createClient();
const channel = supabase
.channel("journal-points-log")
.on(
"postgres_changes",
{ event: "INSERT", schema: "public", table: "points_log" },
(payload) => {
const row = payload.new as Entry;
setEntries((current) => [row, ...current]);
},
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, []);
if (entries.length === 0) {
return <p className="text-sm text-ink/70">Aucun événement pour l&apos;instant.</p>;
}
return (
<ol className="flex flex-col gap-2">
{entries.map((entry) => {
const judgePseudo = pseudoById[entry.judge_id] ?? "Un juge";
const targetPseudo = pseudoById[entry.target_id] ?? "un membre";
const sign = entry.delta > 0 ? `+${entry.delta}` : `${entry.delta}`;
return (
<li
key={entry.id}
className="rounded-lg border border-navy/10 bg-white px-4 py-3 text-sm shadow-sm"
>
<span className="font-medium text-navy">{judgePseudo}</span> a donné{" "}
<span className={entry.delta >= 0 ? "font-semibold text-green-700" : "font-semibold text-red-700"}>
{sign}
</span>{" "}
à <span className="font-medium text-navy">{targetPseudo}</span>
{entry.reason && <span className="text-ink/70"> {entry.reason}</span>}
<div className="mt-1 text-xs text-ink/50">{formatTime(entry.created_at)}</div>
</li>
);
})}
</ol>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { JournalView } from "./journal-view";
export default async function JournalPage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
redirect("/login");
}
const { data: profiles } = await supabase.from("profiles").select("id, pseudo");
const pseudoById = Object.fromEntries((profiles ?? []).map((p) => [p.id, p.pseudo]));
const { data: entries } = await supabase
.from("points_log")
.select("id, target_id, judge_id, delta, reason, created_at")
.order("created_at", { ascending: false })
.limit(200);
return (
<div className="mx-auto w-full max-w-2xl px-4 py-8">
<h1 className="mb-2 text-2xl font-semibold text-navy">Le crieur</h1>
<p className="mb-6 text-sm text-ink/60">Journal public de toutes les attributions de points.</p>
<JournalView initialEntries={entries ?? []} pseudoById={pseudoById} />
</div>
);
}
+11 -1
View File
@@ -29,13 +29,23 @@ export default async function RootLayout({
data: { user },
} = await supabase.auth.getUser();
let isJudge = false;
if (user) {
const { data: profile } = await supabase
.from("profiles")
.select("role")
.eq("id", user.id)
.single();
isJudge = profile?.role === "judge";
}
return (
<html
lang="fr"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col bg-ivory text-ink">
{user && <NavBar />}
{user && <NavBar isJudge={isJudge} />}
<main className="flex flex-1 flex-col">{children}</main>
</body>
</html>
+122
View File
@@ -0,0 +1,122 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { createClient } from "@/lib/supabase/client";
import { Avatar } from "@/components/avatar";
import { Podium } from "@/components/podium";
import { JudgePointControls } from "@/components/judge-point-controls";
import { computeRanks, computeProgress } from "@/lib/ranking";
type Profile = {
id: string;
pseudo: string;
avatar_url: string | null;
points: number;
previous_rank: number | null;
};
function ProgressBadge({ direction, amount }: { direction: string; amount: number }) {
if (direction === "up") {
return <span className="text-xs font-semibold text-green-700">{amount}</span>;
}
if (direction === "down") {
return <span className="text-xs font-semibold text-red-700">{amount}</span>;
}
if (direction === "same") {
return <span className="text-xs text-ink/40">=</span>;
}
return null;
}
export function LeaderboardView({
initialProfiles,
isJudge,
}: {
initialProfiles: Profile[];
isJudge: boolean;
}) {
const [profiles, setProfiles] = useState<Profile[]>(initialProfiles);
const [flashingId, setFlashingId] = useState<string | null>(null);
const pointsRef = useRef(new Map(initialProfiles.map((p) => [p.id, p.points])));
function applyOptimisticDelta(id: string, delta: number) {
setProfiles((current) =>
current.map((p) => (p.id === id ? { ...p, points: p.points + delta } : p)),
);
}
useEffect(() => {
const supabase = createClient();
const channel = supabase
.channel("leaderboard-profiles")
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "profiles" },
(payload) => {
if (payload.eventType === "DELETE") return;
const row = payload.new as Profile;
setProfiles((current) => {
const exists = current.some((p) => p.id === row.id);
return exists
? current.map((p) => (p.id === row.id ? { ...p, ...row } : p))
: [...current, row];
});
const previousPoints = pointsRef.current.get(row.id);
if (previousPoints !== undefined && previousPoints !== row.points) {
setFlashingId(row.id);
setTimeout(() => setFlashingId((current) => (current === row.id ? null : current)), 900);
}
pointsRef.current.set(row.id, row.points);
},
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, []);
const ranked = useMemo(() => computeRanks(profiles), [profiles]);
const rest = ranked.filter((p) => p.rank > 3);
if (profiles.length === 0) {
return <p className="text-sm text-ink/70">Personne n&apos;est encore inscrit.</p>;
}
return (
<div>
<Podium
profiles={ranked.filter((p) => p.rank <= 3)}
isJudge={isJudge}
onApplyDelta={applyOptimisticDelta}
/>
<ol className="flex flex-col gap-2">
{rest.map((profile) => {
const progress = computeProgress(profile.rank, profile.previous_rank);
return (
<li
key={profile.id}
className={`flex flex-wrap items-center gap-3 rounded-lg border border-navy/10 bg-white px-4 py-3 shadow-sm transition-colors ${
flashingId === profile.id ? "animate-points-flash" : ""
}`}
>
<span className="w-6 shrink-0 text-center font-semibold text-navy/60">
{profile.rank}
</span>
<Avatar pseudo={profile.pseudo} avatarUrl={profile.avatar_url} size="sm" />
<span className="flex-1 truncate font-medium">{profile.pseudo}</span>
<ProgressBadge direction={progress.direction} amount={progress.amount} />
<span className="font-semibold text-navy">{profile.points}</span>
{isJudge && (
<JudgePointControls memberId={profile.id} onApplyDelta={applyOptimisticDelta} />
)}
</li>
);
})}
</ol>
</div>
);
}
+14 -35
View File
@@ -1,13 +1,24 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { Avatar } from "@/components/avatar";
import { LeaderboardView } from "./leaderboard-view";
export default async function LeaderboardPage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
redirect("/login");
}
const { data: profiles, error } = await supabase
.from("profiles")
.select("id, pseudo, avatar_url, points")
.select("id, pseudo, avatar_url, points, previous_rank, role")
.order("points", { ascending: false });
const isJudge = profiles?.find((p) => p.id === user.id)?.role === "judge";
return (
<div className="mx-auto w-full max-w-2xl px-4 py-8">
<h1 className="mb-6 text-2xl font-semibold text-navy">Leaderboard</h1>
@@ -18,39 +29,7 @@ export default async function LeaderboardPage() {
</p>
)}
{!error && profiles?.length === 0 && (
<p className="text-sm text-ink/70">Personne n&apos;est encore inscrit.</p>
)}
{!error && profiles && profiles.length > 0 && (
<ol className="flex flex-col gap-2">
{profiles.map((profile, index) => {
const rank = index + 1;
const isFirst = rank === 1;
return (
<li
key={profile.id}
className={`flex items-center gap-4 rounded-lg border px-4 py-3 ${
isFirst
? "border-gold bg-gold/10"
: "border-navy/10 bg-white"
}`}
>
<span
className={`w-6 shrink-0 text-center font-semibold ${
isFirst ? "text-gold" : "text-navy/60"
}`}
>
{rank}
</span>
<Avatar pseudo={profile.pseudo} avatarUrl={profile.avatar_url} size="sm" />
<span className="flex-1 truncate font-medium">{profile.pseudo}</span>
<span className="font-semibold text-navy">{profile.points}</span>
</li>
);
})}
</ol>
)}
{!error && <LeaderboardView initialProfiles={profiles ?? []} isJudge={isJudge} />}
</div>
);
}
+19 -16
View File
@@ -1,14 +1,18 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, type FormEvent } from "react";
import { createClient } from "@/lib/supabase/client";
import { internalEmail } from "@/lib/auth";
function mapSignInError(message: string): string {
if (message.toLowerCase().includes("email not confirmed")) {
return "Confirme ton adresse email avant de te connecter (vérifie ta boîte mail).";
}
return "Email ou mot de passe incorrect.";
}
export default function LoginPage() {
const router = useRouter();
const [pseudo, setPseudo] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
@@ -20,40 +24,39 @@ export default function LoginPage() {
const supabase = createClient();
const { error: signInError } = await supabase.auth.signInWithPassword({
email: internalEmail(pseudo),
email,
password,
});
setLoading(false);
if (signInError) {
setError("Pseudo ou mot de passe incorrect.");
setError(mapSignInError(signInError.message));
return;
}
router.push("/leaderboard");
router.refresh();
window.location.href = "/leaderboard";
}
return (
<div className="flex flex-1 items-center justify-center px-4 py-12">
<div className="w-full max-w-sm">
<div className="w-full max-w-sm rounded-xl border border-navy/10 bg-white p-6 shadow-sm sm:p-8">
<h1 className="mb-6 text-center text-2xl font-semibold text-navy">
Connexion
</h1>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<label htmlFor="pseudo" className="text-sm font-medium">
Pseudo
<label htmlFor="email" className="text-sm font-medium">
Email
</label>
<input
id="pseudo"
type="text"
autoComplete="username"
id="email"
type="email"
autoComplete="email"
required
value={pseudo}
onChange={(event) => setPseudo(event.target.value)}
value={email}
onChange={(event) => setEmail(event.target.value)}
className="rounded-md border border-navy/20 bg-white px-3 py-2 text-ink outline-none focus:border-gold"
/>
</div>
+11 -6
View File
@@ -14,18 +14,23 @@ export default async function ProfilePage() {
const { data: profile } = await supabase
.from("profiles")
.select("pseudo, avatar_url")
.select("pseudo, avatar_url, pseudo_locked, role")
.eq("id", user.id)
.single();
return (
<div className="mx-auto w-full max-w-sm px-4 py-8">
<h1 className="mb-6 text-2xl font-semibold text-navy">Mon profil</h1>
<ProfileForm
userId={user.id}
initialPseudo={profile?.pseudo ?? ""}
initialAvatarUrl={profile?.avatar_url ?? null}
/>
<div className="rounded-xl border border-navy/10 bg-white p-6 shadow-sm sm:p-8">
<p className="mb-4 text-sm text-ink/60">{user.email}</p>
<ProfileForm
userId={user.id}
isJudge={profile?.role === "judge"}
initialPseudo={profile?.pseudo ?? ""}
initialAvatarUrl={profile?.avatar_url ?? null}
initialPseudoLocked={profile?.pseudo_locked ?? false}
/>
</div>
</div>
);
}
+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 && (
+87 -42
View File
@@ -1,32 +1,43 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, type FormEvent } from "react";
import { createClient } from "@/lib/supabase/client";
import { internalEmail, 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") || message.includes("already registered")) {
return "Ce pseudo est déjà pris.";
function mapSignUpError(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.";
}
return "Une erreur est survenue, réessaie.";
}
export default function SignupPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [pseudo, setPseudo] = useState("");
const [password, setPassword] = useState("");
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 [loading, setLoading] = useState(false);
const [confirmationSent, setConfirmationSent] = useState(false);
function handleCropped(blob: Blob) {
setAvatarBlob(blob);
setAvatarPreview(URL.createObjectURL(blob));
}
async function handleSubmit(event: FormEvent) {
event.preventDefault();
setError(null);
const slug = slugify(pseudo);
if (!slug) {
const trimmedPseudo = pseudo.trim();
if (!trimmedPseudo) {
setError("Choisis un pseudo valide.");
return;
}
@@ -38,59 +49,100 @@ export default function SignupPage() {
setLoading(true);
const supabase = createClient();
const { data: pseudoTaken, error: pseudoCheckError } = await supabase.rpc(
"is_pseudo_taken",
{ p_pseudo: trimmedPseudo },
);
if (!pseudoCheckError && pseudoTaken) {
setLoading(false);
setError("Ce pseudo est déjà pris.");
return;
}
const { data: signUpData, error: signUpError } = await supabase.auth.signUp({
email: internalEmail(pseudo),
email,
password,
options: { data: { pseudo: trimmedPseudo } },
});
if (signUpError || !signUpData.user) {
setLoading(false);
setError(mapError(signUpError?.message ?? ""));
setError(mapSignUpError(signUpError?.message ?? ""));
return;
}
const userId = signUpData.user.id;
let avatarUrl: string | null = null;
if (avatarFile) {
const ext = avatarFile.name.split(".").pop() ?? "jpg";
const path = `${userId}.${ext}`;
// 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`;
const { error: uploadError } = await supabase.storage
.from("avatars")
.upload(path, avatarFile, { upsert: true });
.upload(path, avatarBlob, { upsert: true, contentType: "image/webp" });
if (!uploadError) {
const { data: publicUrlData } = supabase.storage.from("avatars").getPublicUrl(path);
avatarUrl = publicUrlData.publicUrl;
await supabase
.from("profiles")
.update({ avatar_url: publicUrlData.publicUrl })
.eq("id", signUpData.user.id);
}
}
const { error: profileError } = await supabase.from("profiles").insert({
id: userId,
pseudo,
slug,
avatar_url: avatarUrl,
});
setLoading(false);
if (profileError) {
setError(mapError(profileError.message));
if (!signUpData.session) {
setConfirmationSent(true);
return;
}
router.push("/leaderboard");
router.refresh();
window.location.href = "/leaderboard";
}
if (confirmationSent) {
return (
<div className="flex flex-1 items-center justify-center px-4 py-12">
<div className="w-full max-w-sm rounded-xl border border-navy/10 bg-white p-6 text-center shadow-sm sm:p-8">
<h1 className="mb-4 text-2xl font-semibold text-navy">Compte créé !</h1>
<p className="text-sm text-ink/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.
</p>
<Link
href="/login"
className="mt-6 inline-block font-medium text-navy underline hover:text-gold"
>
Aller à la connexion
</Link>
</div>
</div>
);
}
return (
<div className="flex flex-1 items-center justify-center px-4 py-12">
<div className="w-full max-w-sm">
<div className="w-full max-w-sm rounded-xl border border-navy/10 bg-white p-6 shadow-sm sm:p-8">
<h1 className="mb-6 text-center text-2xl font-semibold text-navy">
Créer un compte
</h1>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<label htmlFor="email" className="text-sm font-medium">
Email
</label>
<input
id="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
className="rounded-md border border-navy/20 bg-white px-3 py-2 text-ink outline-none focus:border-gold"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="pseudo" className="text-sm font-medium">
Pseudo
@@ -122,17 +174,10 @@ export default function SignupPage() {
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="avatar" className="text-sm font-medium">
Photo de profil (optionnel)
</label>
<input
id="avatar"
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"
/>
<div className="flex flex-col items-center gap-2">
<span className="self-start text-sm font-medium">Photo de profil (optionnel)</span>
<Avatar pseudo={pseudo || "?"} avatarUrl={avatarPreview} size="lg" />
<AvatarPicker label="Photo de profil" onCropped={handleCropped} />
</div>
{error && (
+105
View File
@@ -0,0 +1,105 @@
"use client";
import { useState } from "react";
import Cropper, { type Area } from "react-easy-crop";
import { cropImageToBlob } from "@/lib/image";
export function AvatarPicker({
label,
onCropped,
}: {
label: string;
onCropped: (blob: Blob) => void;
}) {
const [rawImageSrc, setRawImageSrc] = useState<string | null>(null);
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
const [error, setError] = useState<string | null>(null);
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
setRawImageSrc(reader.result as string);
setCrop({ x: 0, y: 0 });
setZoom(1);
setError(null);
};
reader.readAsDataURL(file);
}
async function handleValidate() {
if (!rawImageSrc || !croppedAreaPixels) return;
try {
const blob = await cropImageToBlob(rawImageSrc, croppedAreaPixels);
onCropped(blob);
setRawImageSrc(null);
} catch {
setError("Impossible de traiter cette image, réessaie.");
}
}
if (rawImageSrc) {
return (
<div className="flex flex-col items-center gap-3">
<div className="relative h-64 w-64 max-w-full overflow-hidden rounded-md bg-navy/10">
<Cropper
image={rawImageSrc}
crop={crop}
zoom={zoom}
aspect={1}
cropShape="round"
showGrid={false}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={(_, pixels) => setCroppedAreaPixels(pixels)}
/>
</div>
<input
type="range"
min={1}
max={3}
step={0.05}
value={zoom}
onChange={(event) => setZoom(Number(event.target.value))}
className="w-48"
aria-label="Zoom"
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => setRawImageSrc(null)}
className="rounded-md border border-navy/20 px-3 py-1.5 text-sm text-navy hover:bg-navy/5"
>
Annuler
</button>
<button
type="button"
onClick={handleValidate}
className="rounded-md bg-navy px-3 py-1.5 text-sm font-medium text-ivory hover:bg-navy/90"
>
Valider le cadrage
</button>
</div>
{error && <p className="text-xs text-red-700">{error}</p>}
</div>
);
}
return (
<label className="text-sm">
<span className="sr-only">{label}</span>
<input
type="file"
accept="image/*"
onChange={handleFileChange}
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"
/>
{error && <p className="text-xs text-red-700">{error}</p>}
</label>
);
}
+5
View File
@@ -22,12 +22,17 @@ export function Avatar({
const sizeClass = SIZE_CLASSES[size];
if (avatarUrl) {
// blob:/data: = aperçu local avant upload (cropper). next/image essaie
// de les faire passer par l'optimiseur serveur, qui ne peut pas les
// résoudre (ils n'existent que dans la mémoire du navigateur).
const isLocalPreview = avatarUrl.startsWith("blob:") || avatarUrl.startsWith("data:");
return (
<Image
src={avatarUrl}
alt={pseudo}
width={96}
height={96}
unoptimized={isLocalPreview}
className={`${sizeClass} rounded-full object-cover`}
/>
);
+118
View File
@@ -0,0 +1,118 @@
"use client";
import { useState } from "react";
import { createClient } from "@/lib/supabase/client";
export function JudgePointControls({
memberId,
onApplyDelta,
compact = false,
}: {
memberId: string;
onApplyDelta: (id: string, delta: number) => void;
compact?: boolean;
}) {
const [amount, setAmount] = useState("1");
const [reason, setReason] = useState("");
const [pending, setPending] = useState(0);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const parsedAmount = Math.max(1, Math.abs(Number.parseInt(amount, 10) || 1));
function handleCancel() {
setPending(0);
setReason("");
setError(null);
}
async function handleConfirm() {
if (pending === 0) return;
setSubmitting(true);
setError(null);
const supabase = createClient();
const { error: rpcError } = await supabase.rpc("award_points", {
p_target_id: memberId,
p_delta: pending,
p_reason: reason.trim() || null,
});
setSubmitting(false);
if (rpcError) {
setError("Échec, réessaie.");
return;
}
onApplyDelta(memberId, pending);
setPending(0);
setReason("");
}
return (
<div className="flex flex-wrap items-center gap-1.5">
<button
type="button"
onClick={() => setPending((p) => p - parsedAmount)}
className="h-7 w-7 shrink-0 rounded-md border border-navy/20 text-sm font-bold text-navy hover:bg-navy/5"
aria-label={`Retirer ${parsedAmount} points`}
>
</button>
<input
type="number"
min={1}
value={amount}
onChange={(event) => setAmount(event.target.value)}
className="h-7 w-12 rounded-md border border-navy/20 px-1 text-center text-xs"
/>
<button
type="button"
onClick={() => setPending((p) => p + parsedAmount)}
className="h-7 w-7 shrink-0 rounded-md border border-navy/20 text-sm font-bold text-navy hover:bg-navy/5"
aria-label={`Ajouter ${parsedAmount} points`}
>
+
</button>
{!compact && pending !== 0 && (
<input
type="text"
value={reason}
onChange={(event) => setReason(event.target.value)}
placeholder="motif (optionnel)"
className="h-7 w-28 rounded-md border border-navy/20 px-2 text-xs sm:w-36"
/>
)}
{pending !== 0 && (
<>
<span
className={`text-sm font-semibold ${pending > 0 ? "text-green-700" : "text-red-700"}`}
>
{pending > 0 ? `+${pending}` : pending}
</span>
<button
type="button"
onClick={handleConfirm}
disabled={submitting}
className="rounded-md bg-navy px-2 py-1 text-xs font-medium text-ivory hover:bg-navy/90 disabled:opacity-50"
>
{submitting ? "…" : "Confirmer"}
</button>
<button
type="button"
onClick={handleCancel}
disabled={submitting}
className="rounded-md border border-navy/20 px-2 py-1 text-xs text-navy hover:bg-navy/5 disabled:opacity-50"
>
Annuler
</button>
</>
)}
{error && <span className="text-xs text-red-700">{error}</span>}
</div>
);
}
+25 -11
View File
@@ -1,11 +1,27 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { usePathname, useRouter } from "next/navigation";
import { useState } from "react";
import { createClient } from "@/lib/supabase/client";
export function NavBar() {
function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
const pathname = usePathname();
const isActive = pathname.startsWith(href);
return (
<Link
href={href}
className={`rounded-md px-2 py-1 transition-colors ${
isActive ? "bg-ivory/10 text-gold" : "hover:text-gold"
}`}
>
{children}
</Link>
);
}
export function NavBar({ isJudge }: { isJudge: boolean }) {
const router = useRouter();
const [signingOut, setSigningOut] = useState(false);
@@ -18,21 +34,19 @@ export function NavBar() {
}
return (
<nav className="bg-navy text-ivory">
<nav className="sticky top-0 z-10 bg-navy text-ivory shadow-md">
<div className="mx-auto flex max-w-3xl items-center justify-between px-4 py-3">
<span className="font-semibold tracking-wide">Le Tribunal</span>
<div className="flex items-center gap-4 text-sm">
<Link href="/leaderboard" className="hover:text-gold">
Leaderboard
</Link>
<Link href="/profile" className="hover:text-gold">
Profil
</Link>
<div className="flex items-center gap-1 text-sm sm:gap-2">
<NavLink href="/leaderboard">Leaderboard</NavLink>
<NavLink href="/journal">Le crieur</NavLink>
<NavLink href="/profile">Profil</NavLink>
{isJudge && <NavLink href="/admin">Administration</NavLink>}
<button
type="button"
onClick={handleSignOut}
disabled={signingOut}
className="hover:text-gold disabled:opacity-50"
className="rounded-md px-2 py-1 transition-colors hover:text-gold disabled:opacity-50"
>
{signingOut ? "…" : "Déconnexion"}
</button>
+80
View File
@@ -0,0 +1,80 @@
import { Avatar } from "@/components/avatar";
import { JudgePointControls } from "@/components/judge-point-controls";
import type { RankedProfile } from "@/lib/ranking";
type PodiumProfile = { id: string; pseudo: string; avatar_url: string | null; points: number };
const SLOT_STYLES: Record<
number,
{ pedestal: string; order: string; ring: string; bg: string }
> = {
1: { pedestal: "h-28 sm:h-36", order: "order-2", ring: "ring-gold", bg: "bg-gold/15" },
2: { pedestal: "h-20 sm:h-28", order: "order-1", ring: "ring-navy/25", bg: "bg-navy/10" },
3: { pedestal: "h-14 sm:h-20", order: "order-3", ring: "ring-navy/15", bg: "bg-navy/5" },
};
export function Podium({
profiles,
isJudge = false,
onApplyDelta,
}: {
profiles: RankedProfile<PodiumProfile>[];
isJudge?: boolean;
onApplyDelta?: (id: string, delta: number) => void;
}) {
const byRank = new Map<number, RankedProfile<PodiumProfile>[]>();
for (const profile of profiles) {
const group = byRank.get(profile.rank) ?? [];
group.push(profile);
byRank.set(profile.rank, group);
}
const topRanks = [...byRank.keys()].sort((a, b) => a - b).slice(0, 3);
if (topRanks.length === 0) {
return null;
}
return (
<div className="mb-8 rounded-xl border border-navy/10 bg-white px-4 py-6 shadow-sm sm:px-8">
<div className="flex items-end justify-center gap-4 sm:gap-8">
{topRanks.map((rank) => {
const members = byRank.get(rank)!;
const style = SLOT_STYLES[rank] ?? SLOT_STYLES[3];
return (
<div key={rank} className={`flex flex-col items-center gap-3 ${style.order}`}>
<div className="flex flex-col items-center gap-3">
{members.map((member) => (
<div key={member.id} className="flex flex-col items-center gap-1">
{rank === 1 && <span className="text-lg leading-none">👑</span>}
<Avatar
pseudo={member.pseudo}
avatarUrl={member.avatar_url}
size={rank === 1 ? "lg" : "md"}
/>
<span className="max-w-[7rem] truncate text-sm font-medium text-ink">
{member.pseudo}
</span>
<span className="text-xs font-medium text-navy/60">{member.points} pts</span>
{isJudge && onApplyDelta && (
<JudgePointControls
memberId={member.id}
onApplyDelta={onApplyDelta}
compact
/>
)}
</div>
))}
</div>
<div
className={`flex w-20 sm:w-28 items-start justify-center rounded-t-md ${style.bg} pt-2 ring-1 sm:ring-2 ${style.pedestal} ${style.ring}`}
>
<span className="text-lg font-bold text-navy sm:text-2xl">{rank}</span>
</div>
</div>
);
})}
</div>
</div>
);
}
-19
View File
@@ -1,19 +0,0 @@
const COMBINING_DIACRITICS = /[̀-ͯ]/g;
export function slugify(pseudo: string): string {
return pseudo
.normalize("NFD")
.replace(COMBINING_DIACRITICS, "")
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
// Supabase Auth rejette le TLD ".local" (email_address_invalid).
// ".test" est réservé par la RFC 2606 pour cet usage et passe la validation.
export function internalEmail(pseudo: string): string {
return `${slugify(pseudo)}@letribunal.test`;
}
+40
View File
@@ -0,0 +1,40 @@
export type PixelCrop = { x: number; y: number; width: number; height: number };
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.addEventListener("load", () => resolve(img));
img.addEventListener("error", () => reject(new Error("Image illisible")));
img.src = src;
});
}
// Découpe la zone sélectionnée, redimensionne (sans jamais agrandir) à
// maxSize et compresse en WebP côté client avant l'envoi vers le bucket.
export async function cropImageToBlob(
imageSrc: string,
crop: PixelCrop,
maxSize = 512,
quality = 0.9,
): Promise<Blob> {
const image = await loadImage(imageSrc);
const outputSize = Math.round(Math.min(maxSize, crop.width, crop.height)) || maxSize;
const canvas = document.createElement("canvas");
canvas.width = outputSize;
canvas.height = outputSize;
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("Canvas non supporté par ce navigateur");
}
ctx.drawImage(image, crop.x, crop.y, crop.width, crop.height, 0, 0, outputSize, outputSize);
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error("Échec de la compression de l'image"))),
"image/webp",
quality,
);
});
}
+29
View File
@@ -0,0 +1,29 @@
export type RankedProfile<T extends { points: number }> = T & { rank: number };
// Classement "compétition" : les ex-aequo partagent le même rang, et le
// rang suivant saute en conséquence (1, 2, 2, 4 — pas 1, 2, 2, 3).
export function computeRanks<T extends { points: number }>(profiles: T[]): RankedProfile<T>[] {
const sorted = [...profiles].sort((a, b) => b.points - a.points);
let lastPoints: number | null = null;
let lastRank = 0;
return sorted.map((profile, index) => {
if (lastPoints === null || profile.points !== lastPoints) {
lastRank = index + 1;
lastPoints = profile.points;
}
return { ...profile, rank: lastRank };
});
}
export type Progress = { direction: "up" | "down" | "same" | "none"; amount: number };
export function computeProgress(currentRank: number, previousRank: number | null): Progress {
if (previousRank == null) {
return { direction: "none", amount: 0 };
}
const amount = previousRank - currentRank;
if (amount > 0) return { direction: "up", amount };
if (amount < 0) return { direction: "down", amount: -amount };
return { direction: "same", amount: 0 };
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
const PROTECTED_PATHS = ["/leaderboard", "/profile"];
const PROTECTED_PATHS = ["/leaderboard", "/profile", "/admin", "/journal"];
const AUTH_PATHS = ["/login", "/signup"];
export async function updateSession(request: NextRequest) {
+299 -20
View File
@@ -1,51 +1,198 @@
-- Le Tribunal — schéma V1 (table profiles + RLS + bucket avatars)
-- Le Tribunal — schéma V2 + V3 (auth par email, rôles, verrou de pseudo,
-- points/journal/podium/indicateurs de progression)
-- À exécuter dans l'éditeur SQL du projet Supabase.
-- ============================================================
-- Migration depuis V1 (authentification pseudo + email interne)
-- ============================================================
-- V1 utilisait un email fictif dérivé du pseudo (slug@letribunal.test).
-- V2 exige un vrai email par utilisateur (unique, confirmé). Les comptes
-- créés sous V1 sont donc invalides pour V2. Comme il ne s'agit que de
-- données de test, on repart de zéro :
--
-- 1. Dashboard Supabase → Authentication → Providers → Email
-- → activer "Confirm email".
-- 2. SQL Editor → exécuter la ligne ci-dessous pour supprimer tous les
-- comptes de test (cascade automatiquement sur public.profiles) :
--
-- delete from auth.users;
--
-- 3. Exécuter tout le script ci-dessous.
-- 4. Storage → bucket "avatars" → vider les fichiers de test existants
-- (les anciens noms de fichiers restent valides, ils ne gênent pas,
-- mais autant repartir propre).
-- ============================================================
-- 1. Table profiles ----------------------------------------------------
create table if not exists public.profiles (
id uuid primary key references auth.users (id) on delete cascade,
pseudo text not null,
slug text not null,
avatar_url text,
points integer not null default 0,
role text not null default 'public' check (role in ('public', 'judge')),
pseudo_locked boolean not null default false,
previous_rank integer,
created_at timestamptz not null default now()
);
-- Unicité du pseudo affiché (insensible à la casse) et du slug technique.
-- Colonnes ajoutées si la table existait déjà depuis une version antérieure
-- (no-op si déjà présentes).
alter table public.profiles add column if not exists role text not null default 'public';
alter table public.profiles add column if not exists pseudo_locked boolean not null default false;
alter table public.profiles add column if not exists previous_rank integer;
alter table public.profiles drop column if exists slug;
alter table public.profiles drop constraint if exists profiles_role_check;
alter table public.profiles add constraint profiles_role_check check (role in ('public', 'judge'));
-- Unicité du pseudo affiché (insensible à la casse).
drop index if exists profiles_slug_key;
create unique index if not exists profiles_pseudo_lower_key on public.profiles (lower(pseudo));
create unique index if not exists profiles_slug_key on public.profiles (slug);
alter table public.profiles enable row level security;
-- Lecture : tout utilisateur authentifié peut voir tous les profils (leaderboard).
-- Pas de grant insert : la création du profil passe exclusivement par le
-- trigger handle_new_user() (SECURITY DEFINER, contourne la RLS).
grant select, update on public.profiles to authenticated;
-- 2. Fonctions & triggers -------------------------------------------------
-- La RLS est au niveau ligne : elle ne peut pas exprimer "cette colonne
-- seulement si tel rôle". On verrouille donc les colonnes sensibles
-- (role, points, pseudo_locked, pseudo figé) via des triggers, qui ne
-- font jamais confiance à ce que le client envoie.
-- À la création d'un profil : on ignore ce que le client a pu envoyer
-- pour role/pseudo_locked/points et on force les valeurs par défaut.
-- Le pseudo initial est repris depuis auth.users si non fourni explicitement.
create or replace function public.enforce_profile_insert()
returns trigger
language plpgsql
as $$
begin
new.role := 'public';
new.pseudo_locked := false;
new.points := 0;
return new;
end;
$$;
drop trigger if exists profiles_before_insert on public.profiles;
create trigger profiles_before_insert
before insert on public.profiles
for each row execute function public.enforce_profile_insert();
-- À la mise à jour : selon le rôle de l'appelant (auth.uid()), on
-- autorise ou rejette les changements de colonnes sensibles.
create or replace function public.enforce_profile_update()
returns trigger
language plpgsql
as $$
declare
caller_role text;
points_bypass boolean := coalesce(current_setting('app.bypass_points_lock', true), 'off') = 'on';
begin
-- auth.uid() est NULL hors contexte d'une requête utilisateur (SQL Editor,
-- migrations, clé service_role). Ces accès sont déjà pleinement fiables
-- par construction (accès direct à la base), donc on les laisse passer.
if auth.uid() is null then
return new;
end if;
-- `points` ne peut être modifié que via la RPC award_points(), jamais
-- directement par le client — même un juge ne peut pas l'écrire à la main.
if new.points is distinct from old.points and not points_bypass then
raise exception 'points must be modified via award_points()';
end if;
select role into caller_role from public.profiles where id = auth.uid();
if caller_role = 'judge' then
-- Un juge peut changer pseudo / pseudo_locked / role / avatar_url /
-- previous_rank de n'importe qui.
return new;
end if;
-- Appelant non-juge : ne peut modifier que sa propre ligne (déjà imposé
-- par la RLS), et seulement pseudo / avatar_url, sous conditions.
if new.role is distinct from old.role then
raise exception 'only judges can change role';
end if;
if new.previous_rank is distinct from old.previous_rank then
raise exception 'only judges can reset the rank reference';
end if;
if new.pseudo_locked is distinct from old.pseudo_locked and new.pseudo_locked = false then
raise exception 'only judges can unlock a pseudo';
end if;
if new.pseudo is distinct from old.pseudo then
if old.pseudo_locked then
raise exception 'pseudo is locked, ask a judge';
end if;
-- Premier changement de pseudo : on le fige automatiquement.
new.pseudo_locked := true;
end if;
return new;
end;
$$;
drop trigger if exists profiles_before_update on public.profiles;
create trigger profiles_before_update
before update on public.profiles
for each row execute function public.enforce_profile_update();
-- Création automatique du profil à l'inscription (auth.users → profiles),
-- indépendamment de l'état de la session (fonctionne même si l'email
-- n'est pas encore confirmé, contrairement à un insert fait depuis le client).
create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = public
as $$
begin
insert into public.profiles (id, pseudo)
values (new.id, coalesce(new.raw_user_meta_data ->> 'pseudo', 'Nouveau membre'));
return new;
end;
$$;
drop trigger if exists on_auth_user_created on auth.users;
create trigger on_auth_user_created
after insert on auth.users
for each row execute function public.handle_new_user();
-- 3. Policies RLS ----------------------------------------------------------
drop policy if exists "Profiles are viewable by authenticated users" on public.profiles;
create policy "Profiles are viewable by authenticated users"
on public.profiles for select
to authenticated
using (true);
-- Création : un utilisateur ne peut créer que sa propre ligne.
create policy "Users can insert their own profile"
on public.profiles for insert
to authenticated
with check (auth.uid() = id);
-- Plus de policy INSERT côté client : la création du profil passe
-- exclusivement par le trigger handle_new_user() (SECURITY DEFINER).
drop policy if exists "Users can insert their own profile" on public.profiles;
-- Modification : un utilisateur ne peut modifier que sa propre ligne.
drop policy if exists "Users can update their own profile" on public.profiles;
create policy "Users can update their own profile"
on public.profiles for update
to authenticated
using (auth.uid() = id)
with check (auth.uid() = id);
-- Aucune policy delete => suppression interdite en V1.
drop policy if exists "Judges can update any profile" on public.profiles;
create policy "Judges can update any profile"
on public.profiles for update
to authenticated
using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'))
with check (true);
-- RLS est au niveau ligne : on verrouille aussi la colonne `points` au niveau
-- colonne pour qu'un utilisateur ne puisse jamais la modifier via l'app,
-- même en modifiant sa propre ligne (les points seront gérés plus tard côté admin).
revoke update on public.profiles from authenticated;
grant update (pseudo, slug, avatar_url) on public.profiles to authenticated;
-- Aucune policy delete => suppression interdite en V2.
-- 2. Bucket avatars -------------------------------------------------------
-- 4. Bucket avatars -------------------------------------------------------
insert into storage.buckets (id, name, public)
values ('avatars', 'avatars', true)
@@ -53,7 +200,7 @@ on conflict (id) do nothing;
-- Bucket public => la lecture se fait via l'URL publique, pas besoin de policy SELECT.
-- Upload : un utilisateur ne peut créer que le fichier `{user_id}.<ext>`.
drop policy if exists "Users can upload their own avatar" on storage.objects;
create policy "Users can upload their own avatar"
on storage.objects for insert
to authenticated
@@ -62,7 +209,7 @@ create policy "Users can upload their own avatar"
and split_part(name, '.', 1) = auth.uid()::text
);
-- Remplacement de la photo (upsert) : même règle en update.
drop policy if exists "Users can update their own avatar" on storage.objects;
create policy "Users can update their own avatar"
on storage.objects for update
to authenticated
@@ -74,3 +221,135 @@ create policy "Users can update their own avatar"
bucket_id = 'avatars'
and split_part(name, '.', 1) = auth.uid()::text
);
-- 5. Vérification du pseudo avant inscription -----------------------------
-- Un visiteur non connecté (rôle anon) n'a pas le droit de lire `profiles`
-- (RLS réservée aux authentifiés). Pour afficher "ce pseudo est déjà pris"
-- avant même de créer le compte, on expose une RPC qui ne renvoie qu'un
-- booléen, sans jamais exposer le contenu de la table aux anonymes.
create or replace function public.is_pseudo_taken(p_pseudo text)
returns boolean
language sql
security definer
set search_path = public
as $$
select exists (select 1 from public.profiles where lower(pseudo) = lower(p_pseudo));
$$;
grant execute on function public.is_pseudo_taken(text) to anon, authenticated;
-- 6. Points, journal ("le crieur") ------------------------------------------
create table if not exists public.points_log (
id bigint generated always as identity primary key,
target_id uuid not null references public.profiles (id) on delete cascade,
judge_id uuid not null references public.profiles (id) on delete cascade,
delta integer not null,
reason text,
created_at timestamptz not null default now()
);
alter table public.points_log enable row level security;
-- Lecture ouverte à tous les authentifiés (transparence du journal).
-- Aucune policy insert/update/delete : seule la RPC award_points()
-- (SECURITY DEFINER, propriétaire de la table) peut écrire.
revoke insert, update, delete on public.points_log from authenticated, anon;
grant select on public.points_log to authenticated;
drop policy if exists "points_log readable by authenticated" on public.points_log;
create policy "points_log readable by authenticated"
on public.points_log for select
to authenticated
using (true);
-- Seul point d'entrée pour modifier les points : vérifie le rôle juge
-- côté serveur, applique le delta (négatif autorisé, pas de plancher),
-- et trace l'opération dans points_log. Le flag app.bypass_points_lock
-- autorise le trigger enforce_profile_update() à laisser passer CETTE
-- écriture précise sur la colonne points.
create or replace function public.award_points(p_target_id uuid, p_delta int, p_reason text default null)
returns void
language plpgsql
security definer
set search_path = public
as $$
declare
caller_role text;
begin
select role into caller_role from public.profiles where id = auth.uid();
if caller_role is distinct from 'judge' then
raise exception 'only judges can award points';
end if;
if not exists (select 1 from public.profiles where id = p_target_id) then
raise exception 'target member not found';
end if;
perform set_config('app.bypass_points_lock', 'on', true);
update public.profiles set points = points + p_delta where id = p_target_id;
perform set_config('app.bypass_points_lock', 'off', true);
insert into public.points_log (target_id, judge_id, delta, reason)
values (p_target_id, auth.uid(), p_delta, p_reason);
end;
$$;
grant execute on function public.award_points(uuid, int, text) to authenticated;
-- 7. Repère de classement (flèches de progression) --------------------------
-- Fige le rang actuel de chacun dans previous_rank ; les flèches côté
-- client comparent le rang courant à cette référence.
create or replace function public.reset_rank_reference()
returns void
language plpgsql
security definer
set search_path = public
as $$
declare
caller_role text;
begin
select role into caller_role from public.profiles where id = auth.uid();
if caller_role is distinct from 'judge' then
raise exception 'only judges can reset the rank reference';
end if;
with ranked as (
select id, rank() over (order by points desc) as r
from public.profiles
)
update public.profiles p
set previous_rank = ranked.r
from ranked
where p.id = ranked.id;
end;
$$;
grant execute on function public.reset_rank_reference() to authenticated;
-- 8. Realtime ----------------------------------------------------------------
-- Permet au leaderboard et au journal de s'actualiser en direct pour tout
-- le monde (Dashboard → Database → Replication fait la même chose).
do $$
begin
alter publication supabase_realtime add table public.profiles;
exception
when duplicate_object then null;
end $$;
do $$
begin
alter publication supabase_realtime add table public.points_log;
exception
when duplicate_object then null;
end $$;
-- 9. Désigner les premiers juges ---------------------------------------------
-- À faire une fois les comptes créés (SQL Editor) :
--
-- update public.profiles set role = 'judge' where id = '<uuid-du-membre>';
--
-- (Récupérer l'uuid via Authentication → Users, ou :
-- select id, pseudo from public.profiles;)