Compare commits
3 Commits
eff6f48425
...
e64e8d3ff2
| Author | SHA1 | Date | |
|---|---|---|---|
| e64e8d3ff2 | |||
| 36adb143ca | |||
| a807c4c85e |
@@ -76,7 +76,7 @@ Ce pattern (RLS pour l'accès à la ligne + trigger `BEFORE UPDATE` pour l'accè
|
|||||||
|
|
||||||
## 4. Authentification
|
## 4. Authentification
|
||||||
|
|
||||||
Email + mot de passe (Supabase Auth impose l'unicité de l'email). Confirmation d'email **activée** — nécessite un SMTP custom configuré côté Supabase (le service intégré est trop limité en volume, voir README). Le pseudo est un simple nom d'affichage stocké dans `profiles`, jamais l'email n'est montré aux autres membres.
|
**Sur invitation uniquement**, plus d'auto-inscription publique : "Allow new users to sign up" est désactivé côté Supabase. Un Archonte invite via Authentication → Users → Invite user ; le lien reçu établit une session directement sur `/signup`, qui sert alors à finaliser le compte (mot de passe, pseudo, photo) — sans session valide, la page affiche un message « invitation requise ». Le profil est créé dès l'invitation par le trigger `handle_new_user` (pseudo temporaire `"Nouveau membre"` jusqu'à finalisation). Nécessite un SMTP custom côté Supabase (le service intégré est trop limité en volume, voir README). Le pseudo est un simple nom d'affichage stocké dans `profiles`, jamais l'email n'est montré aux autres membres.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -56,9 +56,10 @@ npm install
|
|||||||
1. Créez un nouveau projet sur [supabase.com](https://supabase.com/).
|
1. Créez un nouveau projet sur [supabase.com](https://supabase.com/).
|
||||||
2. Dans **SQL Editor**, exécutez tout le contenu de [`supabase/schema.sql`](supabase/schema.sql) (tables `profiles`/`points_log`/`days`/`events`/`settings`, policies RLS, triggers, RPC, bucket `avatars`, Realtime). Le script est idempotent, vous pouvez le ré-exécuter sans risque après une mise à jour.
|
2. Dans **SQL Editor**, exécutez tout le contenu de [`supabase/schema.sql`](supabase/schema.sql) (tables `profiles`/`points_log`/`days`/`events`/`settings`, policies RLS, triggers, RPC, bucket `avatars`, Realtime). Le script est idempotent, vous pouvez le ré-exécuter sans risque après une mise à jour.
|
||||||
3. Récupérez l'URL du projet et la clé `anon` dans **Project Settings → API**.
|
3. Récupérez l'URL du projet et la clé `anon` dans **Project Settings → API**.
|
||||||
4. Dans **Authentication → Providers → Email**, **activez "Confirm email"** (un vrai email par personne, condition d'unicité des comptes).
|
4. Dans **Authentication → Providers → Email**, **désactivez "Allow new users to sign up"** — l'inscription publique est fermée, seules les invitations créent un compte (voir [Authentification](#authentification)).
|
||||||
5. Dans **Authentication → Settings → SMTP Settings**, branchez un fournisseur SMTP custom (ex. Resend) — le service email par défaut de Supabase est bridé à quelques emails/heure. Voir [Email transactionnel](#email-transactionnel) ci-dessous.
|
5. Dans **Authentication → URL Configuration**, mettez **Site URL** sur `http://localhost:3000/signup` (puis votre domaine + `/signup` une fois déployé), et ajoutez la même valeur dans **Redirect URLs**. C'est la page où atterrit un lien d'invitation.
|
||||||
6. Une fois votre propre compte créé et confirmé via `/signup`, désignez-vous Archonte (voir [Rôles & administration](#rôles--administration)).
|
6. Dans **Authentication → Settings → SMTP Settings**, branchez un fournisseur SMTP custom (ex. Resend) — le service email par défaut de Supabase est bridé à quelques emails/heure. Voir [Email transactionnel](#email-transactionnel) ci-dessous.
|
||||||
|
7. Invitez-vous vous-même depuis **Authentication → Users → Invite user**, finalisez votre compte via le lien reçu, puis désignez-vous Archonte (voir [Rôles & administration](#rôles--administration)).
|
||||||
|
|
||||||
### 3. Variables d'environnement
|
### 3. Variables d'environnement
|
||||||
|
|
||||||
@@ -109,7 +110,7 @@ tribunal-app/
|
|||||||
├─ src/
|
├─ src/
|
||||||
│ ├─ app/
|
│ ├─ app/
|
||||||
│ │ ├─ login/page.tsx # Connexion (email + mot de passe)
|
│ │ ├─ login/page.tsx # Connexion (email + mot de passe)
|
||||||
│ │ ├─ signup/page.tsx # Création de compte + photo recadrée
|
│ │ ├─ signup/page.tsx # Finalisation d'un compte invité (mot de passe, pseudo, photo)
|
||||||
│ │ ├─ leaderboard/
|
│ │ ├─ leaderboard/
|
||||||
│ │ │ ├─ page.tsx # Fetch initial + rôle de l'utilisateur
|
│ │ │ ├─ page.tsx # Fetch initial + rôle de l'utilisateur
|
||||||
│ │ │ └─ leaderboard-view.tsx # Realtime, podium, liste, contrôles juges
|
│ │ │ └─ leaderboard-view.tsx # Realtime, podium, liste, contrôles juges
|
||||||
@@ -139,9 +140,11 @@ tribunal-app/
|
|||||||
|
|
||||||
## Authentification
|
## Authentification
|
||||||
|
|
||||||
La connexion se fait par **email + mot de passe** ; Supabase impose l'unicité de l'email, ce qui empêche la création de comptes multiples. Le pseudo est purement un nom d'affichage (leaderboard, profil, journal) : l'email n'est jamais montré aux autres membres. La confirmation d'email est obligatoire (voir [SMTP](#email-transactionnel)).
|
**Sur invitation uniquement** : l'auto-inscription publique est désactivée côté Supabase (Authentication → Providers → Email → "Allow new users to sign up" décoché). Il n'y a plus de bouton « Créer un compte » sur `/login`.
|
||||||
|
|
||||||
Le profil (`public.profiles`) est créé automatiquement à l'inscription via un trigger sur `auth.users` (fonctionne même si l'email n'est pas encore confirmé).
|
Pour ajouter un membre : **Authentication → Users → Invite user** (email). Supabase lui envoie un email (via le SMTP configuré) contenant un lien d'invitation qui, une fois cliqué, établit directement une session sur `/signup` — c'est cette page qui sert alors à finaliser le compte (choix du mot de passe, du pseudo, upload de la photo). Sans session active (lien absent, invalide ou expiré), `/signup` affiche un message « invitation requise » plutôt qu'un formulaire d'inscription.
|
||||||
|
|
||||||
|
Le profil (`public.profiles`) est créé automatiquement dès l'invitation via un trigger sur `auth.users` (pseudo temporaire `"Nouveau membre"` tant que la finalisation n'a pas eu lieu). Le pseudo est purement un nom d'affichage (leaderboard, profil, journal) : l'email n'est jamais montré aux autres membres.
|
||||||
|
|
||||||
### Verrou de pseudo
|
### Verrou de pseudo
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 138 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
@@ -1,18 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="#C9A227">
|
|
||||||
<rect x="0" y="0" width="100" height="100" rx="20" fill="#0A1B33" />
|
|
||||||
<ellipse cx="44.18" cy="85.39" rx="7.00" ry="2.80" transform="rotate(-168.00 44.18 85.39)"/>
|
|
||||||
<ellipse cx="31.70" cy="81.14" rx="6.71" ry="2.68" transform="rotate(-141.67 31.70 81.14)"/>
|
|
||||||
<ellipse cx="21.98" cy="71.26" rx="6.42" ry="2.57" transform="rotate(-115.33 21.98 71.26)"/>
|
|
||||||
<ellipse cx="17.50" cy="57.43" rx="6.13" ry="2.45" transform="rotate(-89.00 17.50 57.43)"/>
|
|
||||||
<ellipse cx="19.80" cy="42.39" rx="5.83" ry="2.33" transform="rotate(-62.67 19.80 42.39)"/>
|
|
||||||
<ellipse cx="28.97" cy="29.40" rx="5.54" ry="2.22" transform="rotate(-36.33 28.97 29.40)"/>
|
|
||||||
<ellipse cx="43.58" cy="21.56" rx="5.25" ry="2.10" transform="rotate(-10.00 43.58 21.56)"/>
|
|
||||||
<ellipse cx="55.82" cy="85.39" rx="7.00" ry="2.80" transform="rotate(-168.00 55.82 85.39)"/>
|
|
||||||
<ellipse cx="68.30" cy="81.14" rx="6.71" ry="2.68" transform="rotate(-141.67 68.30 81.14)"/>
|
|
||||||
<ellipse cx="78.02" cy="71.26" rx="6.42" ry="2.57" transform="rotate(-115.33 78.02 71.26)"/>
|
|
||||||
<ellipse cx="82.50" cy="57.43" rx="6.13" ry="2.45" transform="rotate(-89.00 82.50 57.43)"/>
|
|
||||||
<ellipse cx="80.20" cy="42.39" rx="5.83" ry="2.33" transform="rotate(-62.67 80.20 42.39)"/>
|
|
||||||
<ellipse cx="71.03" cy="29.40" rx="5.54" ry="2.22" transform="rotate(-36.33 71.03 29.40)"/>
|
|
||||||
<ellipse cx="56.42" cy="21.56" rx="5.25" ry="2.10" transform="rotate(-10.00 56.42 21.56)"/>
|
|
||||||
<circle cx="50" cy="85" r="2.4"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,6 +1,7 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { createClient } from "@/lib/supabase/server";
|
||||||
import { LeaderboardView } from "./leaderboard-view";
|
import { LeaderboardView } from "./leaderboard-view";
|
||||||
|
import { AgoraLogo } from "@/components/agora-logo";
|
||||||
|
|
||||||
export default async function LeaderboardPage() {
|
export default async function LeaderboardPage() {
|
||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
@@ -21,11 +22,9 @@ export default async function LeaderboardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-2xl px-4 py-8">
|
<div className="mx-auto w-full max-w-2xl px-4 py-8">
|
||||||
<div className="mb-6 text-center">
|
<div className="mb-6 flex flex-col items-center text-center">
|
||||||
<h1 className="font-heading text-2xl tracking-[0.1em] text-gold-bright uppercase">
|
<AgoraLogo className="h-32 w-32 sm:h-40 sm:w-40" />
|
||||||
L'Agora
|
<p className="mt-2 font-serif text-sm text-marble/60 italic">Le classement des Citoyens</p>
|
||||||
</h1>
|
|
||||||
<p className="font-serif text-sm text-marble/60 italic">Le classement des Citoyens</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useState, type FormEvent } from "react";
|
import { useState, type FormEvent } from "react";
|
||||||
import { createClient } from "@/lib/supabase/client";
|
import { createClient } from "@/lib/supabase/client";
|
||||||
import { LaurelWreath } from "@/components/laurel-wreath";
|
import { AgoraLogo } from "@/components/agora-logo";
|
||||||
|
|
||||||
function mapSignInError(message: string): string {
|
function mapSignInError(message: string): string {
|
||||||
if (message.toLowerCase().includes("email not confirmed")) {
|
if (message.toLowerCase().includes("email not confirmed")) {
|
||||||
@@ -43,7 +42,7 @@ export default function LoginPage() {
|
|||||||
<div className="flex flex-1 items-center justify-center px-4 py-12">
|
<div className="flex flex-1 items-center justify-center px-4 py-12">
|
||||||
<div className="marble-surface w-full max-w-sm rounded-2xl border border-gold/40 p-6 shadow-xl sm:p-8">
|
<div className="marble-surface w-full max-w-sm rounded-2xl border border-gold/40 p-6 shadow-xl sm:p-8">
|
||||||
<div className="mb-4 flex flex-col items-center gap-2">
|
<div className="mb-4 flex flex-col items-center gap-2">
|
||||||
<LaurelWreath className="h-10 w-10" color="#A5342A" />
|
<AgoraLogo className="h-24 w-24" />
|
||||||
<h1 className="font-heading text-xl tracking-[0.1em] text-text-marble uppercase">
|
<h1 className="font-heading text-xl tracking-[0.1em] text-text-marble uppercase">
|
||||||
Connexion
|
Connexion
|
||||||
</h1>
|
</h1>
|
||||||
@@ -99,10 +98,7 @@ export default function LoginPage() {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="mt-6 text-center text-sm text-text-mut">
|
<p className="mt-6 text-center text-sm text-text-mut">
|
||||||
Pas encore de compte ?{" "}
|
L'inscription se fait uniquement sur invitation d'un Archonte.
|
||||||
<Link href="/signup" className="font-medium text-oxblood underline hover:text-gold">
|
|
||||||
Créer un compte
|
|
||||||
</Link>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+71
-61
@@ -1,32 +1,65 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState, type FormEvent } from "react";
|
import { useEffect, useState, type FormEvent } from "react";
|
||||||
import { createClient } from "@/lib/supabase/client";
|
import { createClient } from "@/lib/supabase/client";
|
||||||
import { Avatar } from "@/components/avatar";
|
import { Avatar } from "@/components/avatar";
|
||||||
import { AvatarPicker } from "@/components/avatar-picker";
|
import { AvatarPicker } from "@/components/avatar-picker";
|
||||||
import { LaurelWreath } from "@/components/laurel-wreath";
|
import { AgoraLogo } from "@/components/agora-logo";
|
||||||
|
|
||||||
function mapSignUpError(message: string): string {
|
// Plus d'auto-inscription publique : cette page ne fonctionne que via un
|
||||||
|
// lien d'invitation envoyé par un Archonte (Supabase Authentication → Users
|
||||||
|
// → Invite user). Le clic sur ce lien établit une session ; cette page sert
|
||||||
|
// alors à finaliser le compte (mot de passe, pseudo, photo).
|
||||||
|
|
||||||
|
type Status = "checking" | "no-invite" | "ready" | "done";
|
||||||
|
|
||||||
|
function mapError(message: string): string {
|
||||||
const lower = message.toLowerCase();
|
const lower = message.toLowerCase();
|
||||||
if (lower.includes("already registered")) {
|
if (lower.includes("password")) {
|
||||||
return "Un compte existe déjà avec cet email.";
|
return "Le mot de passe doit faire au moins 6 caractères.";
|
||||||
}
|
|
||||||
if (lower.includes("rate limit")) {
|
|
||||||
return "Trop d'emails envoyés récemment (limite Supabase), réessaie dans quelques minutes.";
|
|
||||||
}
|
}
|
||||||
return "Une erreur est survenue, réessaie.";
|
return "Une erreur est survenue, réessaie.";
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SignupPage() {
|
export default function SignupPage() {
|
||||||
const [email, setEmail] = useState("");
|
const [status, setStatus] = useState<Status>("checking");
|
||||||
|
const [userId, setUserId] = useState<string | null>(null);
|
||||||
const [pseudo, setPseudo] = useState("");
|
const [pseudo, setPseudo] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [avatarBlob, setAvatarBlob] = useState<Blob | null>(null);
|
const [avatarBlob, setAvatarBlob] = useState<Blob | null>(null);
|
||||||
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [confirmationSent, setConfirmationSent] = useState(false);
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function checkInvite() {
|
||||||
|
const supabase = createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
setStatus("no-invite");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: profile } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select("pseudo")
|
||||||
|
.eq("id", user.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (profile && profile.pseudo !== "Nouveau membre") {
|
||||||
|
window.location.href = "/leaderboard";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUserId(user.id);
|
||||||
|
setStatus("ready");
|
||||||
|
}
|
||||||
|
checkInvite();
|
||||||
|
}, []);
|
||||||
|
|
||||||
function handleCropped(blob: Blob) {
|
function handleCropped(blob: Blob) {
|
||||||
setAvatarBlob(blob);
|
setAvatarBlob(blob);
|
||||||
@@ -46,6 +79,7 @@ export default function SignupPage() {
|
|||||||
setError("Le mot de passe doit faire au moins 6 caractères.");
|
setError("Le mot de passe doit faire au moins 6 caractères.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
@@ -60,58 +94,56 @@ export default function SignupPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: signUpData, error: signUpError } = await supabase.auth.signUp({
|
const { error: passwordError } = await supabase.auth.updateUser({ password });
|
||||||
email,
|
if (passwordError) {
|
||||||
password,
|
|
||||||
options: { data: { pseudo: trimmedPseudo } },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (signUpError || !signUpData.user) {
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setError(mapSignUpError(signUpError?.message ?? ""));
|
setError(mapError(passwordError.message));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sans session active (confirmation email en attente), impossible
|
let avatarUrl: string | null = null;
|
||||||
// d'uploader la photo : la policy Storage exige un utilisateur
|
if (avatarBlob) {
|
||||||
// authentifié. On l'ajoutera depuis /profile après la 1ère connexion.
|
const path = `${userId}.webp`;
|
||||||
if (signUpData.session && avatarBlob) {
|
|
||||||
const path = `${signUpData.user.id}.webp`;
|
|
||||||
const { error: uploadError } = await supabase.storage
|
const { error: uploadError } = await supabase.storage
|
||||||
.from("avatars")
|
.from("avatars")
|
||||||
.upload(path, avatarBlob, { upsert: true, contentType: "image/webp" });
|
.upload(path, avatarBlob, { upsert: true, contentType: "image/webp" });
|
||||||
|
|
||||||
if (!uploadError) {
|
if (!uploadError) {
|
||||||
const { data: publicUrlData } = supabase.storage.from("avatars").getPublicUrl(path);
|
const { data: publicUrlData } = supabase.storage.from("avatars").getPublicUrl(path);
|
||||||
await supabase
|
avatarUrl = publicUrlData.publicUrl;
|
||||||
.from("profiles")
|
|
||||||
.update({ avatar_url: publicUrlData.publicUrl })
|
|
||||||
.eq("id", signUpData.user.id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { error: profileError } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.update({ pseudo: trimmedPseudo, ...(avatarUrl ? { avatar_url: avatarUrl } : {}) })
|
||||||
|
.eq("id", userId);
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
||||||
if (!signUpData.session) {
|
if (profileError) {
|
||||||
setConfirmationSent(true);
|
setError(mapError(profileError.message));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.location.href = "/leaderboard";
|
window.location.href = "/leaderboard";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (confirmationSent) {
|
if (status === "checking") {
|
||||||
|
return <div className="flex flex-1 items-center justify-center px-4 py-12" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "no-invite") {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 items-center justify-center px-4 py-12">
|
<div className="flex flex-1 items-center justify-center px-4 py-12">
|
||||||
<div className="marble-surface w-full max-w-sm rounded-2xl border border-gold/40 p-6 text-center shadow-xl sm:p-8">
|
<div className="marble-surface w-full max-w-sm rounded-2xl border border-gold/40 p-6 text-center shadow-xl sm:p-8">
|
||||||
<LaurelWreath className="mx-auto mb-3 h-10 w-10" color="#5E6B3B" />
|
<AgoraLogo className="mx-auto mb-3 h-16 w-16" />
|
||||||
<h1 className="mb-4 font-heading text-xl tracking-[0.1em] text-text-marble uppercase">
|
<h1 className="mb-4 font-heading text-xl tracking-[0.1em] text-text-marble uppercase">
|
||||||
Compte créé !
|
Invitation requise
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-text-marble/80">
|
<p className="text-sm text-text-marble/80">
|
||||||
Vérifie ta boîte mail (et les spams) pour confirmer ton adresse, puis
|
La création de compte se fait uniquement sur invitation d'un
|
||||||
connecte-toi. Tu pourras ajouter ta photo de profil ensuite depuis ton
|
Archonte. Ce lien est invalide ou a expiré.
|
||||||
profil.
|
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
href="/login"
|
||||||
@@ -128,9 +160,9 @@ export default function SignupPage() {
|
|||||||
<div className="flex flex-1 items-center justify-center px-4 py-12">
|
<div className="flex flex-1 items-center justify-center px-4 py-12">
|
||||||
<div className="marble-surface w-full max-w-sm rounded-2xl border border-gold/40 p-6 shadow-xl sm:p-8">
|
<div className="marble-surface w-full max-w-sm rounded-2xl border border-gold/40 p-6 shadow-xl sm:p-8">
|
||||||
<div className="mb-4 flex flex-col items-center gap-2">
|
<div className="mb-4 flex flex-col items-center gap-2">
|
||||||
<LaurelWreath className="h-10 w-10" color="#A5342A" />
|
<AgoraLogo className="h-24 w-24" />
|
||||||
<h1 className="font-heading text-xl tracking-[0.1em] text-text-marble uppercase">
|
<h1 className="font-heading text-xl tracking-[0.1em] text-text-marble uppercase">
|
||||||
Créer un compte
|
Bienvenue, Citoyen
|
||||||
</h1>
|
</h1>
|
||||||
<p className="font-serif text-sm text-text-mut italic">
|
<p className="font-serif text-sm text-text-mut italic">
|
||||||
« Que ton nom soit gravé parmi les Citoyens. »
|
« Que ton nom soit gravé parmi les Citoyens. »
|
||||||
@@ -138,21 +170,6 @@ export default function SignupPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label htmlFor="email" className="text-sm font-medium text-text-marble">
|
|
||||||
Email
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
autoComplete="email"
|
|
||||||
required
|
|
||||||
value={email}
|
|
||||||
onChange={(event) => setEmail(event.target.value)}
|
|
||||||
className="rounded-md border border-gold/30 bg-white/50 px-3 py-2 text-text-marble outline-none focus:border-gold"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label htmlFor="pseudo" className="text-sm font-medium text-text-marble">
|
<label htmlFor="pseudo" className="text-sm font-medium text-text-marble">
|
||||||
Pseudo
|
Pseudo
|
||||||
@@ -170,7 +187,7 @@ export default function SignupPage() {
|
|||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label htmlFor="password" className="text-sm font-medium text-text-marble">
|
<label htmlFor="password" className="text-sm font-medium text-text-marble">
|
||||||
Mot de passe
|
Choisis un mot de passe
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
@@ -209,16 +226,9 @@ export default function SignupPage() {
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="mt-2 rounded-md bg-ink-2 px-4 py-2 font-heading text-sm tracking-wide text-gold-bright uppercase transition hover:bg-ink disabled:opacity-50"
|
className="mt-2 rounded-md bg-ink-2 px-4 py-2 font-heading text-sm tracking-wide text-gold-bright uppercase transition hover:bg-ink disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{loading ? "Création…" : "Créer mon compte"}
|
{loading ? "Enregistrement…" : "Rejoindre le Tribunal"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="mt-6 text-center text-sm text-text-mut">
|
|
||||||
Déjà un compte ?{" "}
|
|
||||||
<Link href="/login" className="font-medium text-oxblood underline hover:text-gold">
|
|
||||||
Se connecter
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
// unoptimized : l'optimiseur d'images de Next.js aplatit systématiquement la
|
||||||
|
// transparence de ce fichier (PNG ou WebP) lors du redimensionnement à la
|
||||||
|
// volée — on sert donc le fichier statique tel quel, sa transparence réelle
|
||||||
|
// est alors préservée par le navigateur.
|
||||||
|
export function AgoraLogo({ className = "h-9 w-9" }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<span className={`relative block shrink-0 ${className}`}>
|
||||||
|
<Image
|
||||||
|
src="/logo-agora.webp"
|
||||||
|
alt="L'Agora"
|
||||||
|
fill
|
||||||
|
priority
|
||||||
|
unoptimized
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import Cropper, { type Area } from "react-easy-crop";
|
import Cropper, { type Area } from "react-easy-crop";
|
||||||
import { cropImageToBlob } from "@/lib/image";
|
import { cropImageToBlob } from "@/lib/image";
|
||||||
import { IconCamera, IconClose } from "@/components/icons";
|
import { IconClose } from "@/components/icons";
|
||||||
|
import { AgoraLogo } from "@/components/agora-logo";
|
||||||
|
|
||||||
export function AvatarPicker({
|
export function AvatarPicker({
|
||||||
hasExistingPhoto = false,
|
hasExistingPhoto = false,
|
||||||
@@ -126,7 +127,7 @@ export function AvatarPicker({
|
|||||||
onClick={() => inputRef.current?.click()}
|
onClick={() => inputRef.current?.click()}
|
||||||
className="flex items-center gap-2 rounded-md bg-ink-2 px-3 py-1.5 text-sm font-medium text-gold-bright hover:bg-ink"
|
className="flex items-center gap-2 rounded-md bg-ink-2 px-3 py-1.5 text-sm font-medium text-gold-bright hover:bg-ink"
|
||||||
>
|
>
|
||||||
<IconCamera className="h-4 w-4" />
|
<AgoraLogo className="h-4 w-4" />
|
||||||
{fileName || hasExistingPhoto ? "Changer ma photo" : "Choisir une photo"}
|
{fileName || hasExistingPhoto ? "Changer ma photo" : "Choisir une photo"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { createClient } from "@/lib/supabase/client";
|
import { createClient } from "@/lib/supabase/client";
|
||||||
import { LaurelWreath } from "@/components/laurel-wreath";
|
import { LaurelWreath } from "@/components/laurel-wreath";
|
||||||
import { Avatar } from "@/components/avatar";
|
import { Avatar } from "@/components/avatar";
|
||||||
|
import { AgoraLogo } from "@/components/agora-logo";
|
||||||
import {
|
import {
|
||||||
IconWheel,
|
IconWheel,
|
||||||
IconScroll,
|
IconScroll,
|
||||||
@@ -106,9 +107,9 @@ export function Header({
|
|||||||
<header className="sticky top-0 z-10 bg-ink-2/95 backdrop-blur">
|
<header className="sticky top-0 z-10 bg-ink-2/95 backdrop-blur">
|
||||||
<div className="mx-auto flex max-w-4xl items-center justify-between gap-2 px-2 py-2.5 sm:px-4 sm:py-3">
|
<div className="mx-auto flex max-w-4xl items-center justify-between gap-2 px-2 py-2.5 sm:px-4 sm:py-3">
|
||||||
<Link href="/leaderboard" className="flex min-w-0 items-center gap-1.5 sm:gap-2">
|
<Link href="/leaderboard" className="flex min-w-0 items-center gap-1.5 sm:gap-2">
|
||||||
<LaurelWreath className="h-5 w-5 shrink-0 sm:h-7 sm:w-7" color="#E7C560" />
|
<AgoraLogo className="h-7 w-7 sm:h-9 sm:w-9" />
|
||||||
<span className="whitespace-nowrap font-heading text-[0.6rem] tracking-[0.08em] text-gold-bright uppercase sm:text-sm sm:tracking-[0.15em]">
|
<span className="whitespace-nowrap font-heading text-[0.6rem] tracking-[0.08em] text-gold-bright uppercase sm:text-sm sm:tracking-[0.15em]">
|
||||||
Le Tribunal
|
L'Agora
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { createServerClient } from "@supabase/ssr";
|
|||||||
import { NextResponse, type NextRequest } from "next/server";
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
|
||||||
const PROTECTED_PATHS = ["/leaderboard", "/profile", "/admin", "/journal", "/roulette", "/calendrier"];
|
const PROTECTED_PATHS = ["/leaderboard", "/profile", "/admin", "/journal", "/roulette", "/calendrier"];
|
||||||
const AUTH_PATHS = ["/login", "/signup"];
|
// /signup n'est PAS dans AUTH_PATHS : un compte fraîchement invité a déjà une
|
||||||
|
// session (établie par le lien d'invitation) mais doit pouvoir rester sur
|
||||||
|
// /signup pour finaliser mot de passe/pseudo/photo sans être renvoyé au
|
||||||
|
// leaderboard par ce redirect avant d'avoir terminé.
|
||||||
|
const AUTH_PATHS = ["/login"];
|
||||||
|
|
||||||
export async function updateSession(request: NextRequest) {
|
export async function updateSession(request: NextRequest) {
|
||||||
let response = NextResponse.next({ request });
|
let response = NextResponse.next({ request });
|
||||||
|
|||||||
Reference in New Issue
Block a user