diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b91fb14 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Supabase (optional). Without these the app runs entirely as a guest: +# every mode stays playable, only the worldwide leaderboard needs an account. +# Create a project on https://supabase.com, then copy Settings > API. +VITE_SUPABASE_URL=https://xxxxxxxxxxxxxxxx.supabase.co +VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... diff --git a/.gitignore b/.gitignore index 330445b..e90ad7a 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,7 @@ coverage # Private specification — kept locally, never committed cahier-des-charges.html + +# Supabase CLI local artefacts +supabase/.temp +supabase/.branches diff --git a/.prettierignore b/.prettierignore index f63a484..aa673f2 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,3 +6,7 @@ cahier-des-charges.html # Vendored Stockfish build — never reformat public/stockfish + +# Supabase CLI local artefacts +supabase/.temp +supabase/.branches diff --git a/README.md b/README.md index 236a0ca..fa00124 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,97 @@ npm run dev The app is served on http://localhost:5173. +## Backend setup (optional) + +Every mode is playable without a backend: the app detects that no Supabase +project is configured and runs in guest mode, keeping progress in +`localStorage`. Only accounts and the worldwide leaderboard need the steps +below. + +### 1. Create the project + +1. Sign in on [supabase.com](https://supabase.com) and create a new project. + Note the database password somewhere safe; it is shown only once. +2. Open **Project Settings > API** and copy **Project URL** and the **anon + public** key. +3. Copy `.env.example` to `.env.local` and paste both values: + + ```bash + cp .env.example .env.local + ``` + + The anon key is meant to be public — it is shipped in the browser bundle, + and Row Level Security is what actually protects the data. Never put the + `service_role` key in this file. + +### 2. Create the tables + +`supabase/migrations/` holds the whole schema: tables, Row Level Security +policies, privileges, the trigger that creates a profile on sign-up, and the +realtime publication the leaderboard subscribes to. + +The quickest way is the dashboard: open **SQL Editor**, paste the contents of +`supabase/migrations/20260711000000_init.sql`, and run it. The script is +idempotent, so running it twice is harmless. + +With the [Supabase CLI](https://supabase.com/docs/guides/cli) instead: + +```bash +npx supabase link --project-ref +npx supabase db push +``` + +Check it worked under **Table Editor**: `profiles`, `scores`, +`puzzle_progress` and `achievements` should be listed, each marked _RLS +enabled_. + +### 3. Set the URLs + +Under **Authentication > URL Configuration**: + +- **Site URL** — `http://localhost:5173` while developing, the deployed + address once online. +- **Redirect URLs** — add `http://localhost:5173/profile` and, once deployed, + `https:///profile`. Sign-in sends the player back to `/profile` + and this list is matched exactly, so a missing entry fails the sign-in. + +Under **Authentication > Providers > Email**, turn **Confirm email** off while +testing, unless you want to click a confirmation link for every test account. + +### 4. Google sign-in (optional) + +The button is shown regardless; it only works once this is done. + +1. In [Google Cloud Console](https://console.cloud.google.com), create a + project, then **APIs & Services > OAuth consent screen**: choose + **External**, fill in the app name and your email, and add your own address + under **Test users** so you can sign in before the app is published. +2. **Credentials > Create credentials > OAuth client ID**, type **Web + application**. Under **Authorised redirect URIs**, paste the callback shown + by Supabase in **Authentication > Providers > Google** — it looks like + `https://.supabase.co/auth/v1/callback`. Add + `http://127.0.0.1:54321/auth/v1/callback` as well if you intend to sign in + against a local stack; that is where its own auth service listens, and + Google rejects any callback not listed here. +3. Copy the generated **Client ID** and **Client secret** into that same + Supabase Google provider panel, enable it, and save. + +Nothing changes in the app itself: the provider is read from the project. + +### Running the backend locally + +Docker is required. `supabase/config.toml` is already set up for this app — +port 5173, and the `/profile` callback in the allow-list. + +```bash +npx supabase start # applies supabase/migrations automatically +npx supabase status # prints the local URL and anon key for .env.local +``` + +For local Google sign-in, put the same credentials in `supabase/.env` (git +ignored, see `supabase/.env.example`). Without it the stack still starts and +email sign-in works; only the Google button is inert. + ## Scripts | Script | Purpose | diff --git a/package-lock.json b/package-lock.json index e53b401..8195b17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@fontsource/inter": "^5.2.8", "@fontsource/playfair-display": "^5.2.8", + "@supabase/supabase-js": "^2.110.8", "chess.js": "^1.4.0", "framer-motion": "^11.18.2", "react": "^18.3.1", @@ -1945,6 +1946,108 @@ "node": ">=6" } }, + "node_modules/@supabase/auth-js": { + "version": "2.110.8", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.110.8.tgz", + "integrity": "sha512-TQ5neTUDX2C2WmyYa03yGhLMkhdE/SkHXtK8/qxO/APUy3rsymsJCBP48p4jcN6iO2G0ow6RRexQd2mX+dSyJg==", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/auth-js/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "node_modules/@supabase/functions-js": { + "version": "2.110.8", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.110.8.tgz", + "integrity": "sha512-5yB9TLYzvv2oSQxwb0gamEvIAsuH66pVt7AM/pz03S7wN6ehD34GNgbShrccetqPedXQSz7e/1hAJ9NeEhoZVg==", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/functions-js/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz", + "integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.110.8", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.110.8.tgz", + "integrity": "sha512-QeRROxl1PpOZw5Jzi7BwdN9icsycMrLlCCvsjS0hYLW+nZoaT46zdagz/glJirj8jHF4jSd5Jyipuae2cBClCw==", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/postgrest-js/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "node_modules/@supabase/realtime-js": { + "version": "2.110.8", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.110.8.tgz", + "integrity": "sha512-mwX7ituX6O31fLf+0g65rpLlNxqgnMaPltPsQwzox6jfmbfVl3tCxXrfr3HEsQcCRjpjuJG1+A0vFzP1yVjKHA==", + "dependencies": { + "@supabase/phoenix": "0.4.5", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/realtime-js/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "node_modules/@supabase/storage-js": { + "version": "2.110.8", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.110.8.tgz", + "integrity": "sha512-CcfhkZFBLxsthgUabZKxwfsoXdrikIGsL3LsGoV3FZTqCMx/s1y49taT4jT/oya5+1IuB0sFFHw6pF0o0iJniQ==", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/storage-js/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "node_modules/@supabase/supabase-js": { + "version": "2.110.8", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.110.8.tgz", + "integrity": "sha512-E5qzoe74zhJRv4wRcbO9eMYzeQDb/+h6c603pL8shcxLGBjTKsIF7XXj05IcNj23TLDgJN1WkMw7mwAPyu5dZg==", + "dependencies": { + "@supabase/auth-js": "2.110.8", + "@supabase/functions-js": "2.110.8", + "@supabase/postgrest-js": "2.110.8", + "@supabase/realtime-js": "2.110.8", + "@supabase/storage-js": "2.110.8" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -4995,6 +5098,14 @@ "node": ">= 14" } }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", diff --git a/package.json b/package.json index c3281d5..1c0e231 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "dependencies": { "@fontsource/inter": "^5.2.8", "@fontsource/playfair-display": "^5.2.8", + "@supabase/supabase-js": "^2.110.8", "chess.js": "^1.4.0", "framer-motion": "^11.18.2", "react": "^18.3.1", diff --git a/scripts/check-bundle-size.mjs b/scripts/check-bundle-size.mjs index f4cd47c..c0138aa 100644 --- a/scripts/check-bundle-size.mjs +++ b/scripts/check-bundle-size.mjs @@ -27,7 +27,11 @@ const BUDGETS = [ { label: 'Initial JavaScript', match: (path) => path.endsWith('.js'), - maxGzipKb: 200, + // Raised from 200 kB for M10: the Supabase client is about 65 kB gzipped + // and is a hard requirement of the accounts and leaderboard of section 2.6. + // M9 should bring this back down with route-level lazy loading, which the + // specification assigns to it; until then the guard still catches growth. + maxGzipKb: 230, }, { label: 'CSS', diff --git a/src/App.test.tsx b/src/App.test.tsx index 705ed27..48cb88d 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -22,7 +22,9 @@ describe('routing', () => { [ROUTES.battle, /Affrontement/], [ROUTES.puzzle, 'Puzzles'], [ROUTES.hunt, /Chasse aux Pièces/], - [ROUTES.leaderboard, /Classement mondial/], + // Tests run with no backend configured, so the guard explains the + // leaderboard is unavailable rather than rendering it. + [ROUTES.leaderboard, /Classement indisponible/], [ROUTES.profile, 'Profil'], ])('renders the expected page at %s', (path, heading) => { renderAt(path) diff --git a/src/App.tsx b/src/App.tsx index 2fb4262..0d8ef14 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,9 @@ +import { useEffect } from 'react' import { Route, Routes } from 'react-router-dom' import AppLayout from '@/components/Layout/AppLayout' +import LoginPage from '@/features/auth/LoginPage' +import RegisterPage from '@/features/auth/RegisterPage' +import RequireAuth from '@/features/auth/RequireAuth' import BattlePage from '@/features/battle/BattlePage' import CoachPage from '@/features/coach/CoachPage' import HomePage from '@/features/home/HomePage' @@ -9,8 +13,14 @@ import NotFoundPage from '@/features/NotFoundPage' import ProfilePage from '@/features/profile/ProfilePage' import PuzzlePage from '@/features/puzzle/PuzzlePage' import { ROUTES } from '@/routes' +import { useAuthStore } from '@/store/useAuthStore' export default function App() { + const initialise = useAuthStore((state) => state.initialise) + + // Restores the stored session and follows sign-in/sign-out for the whole app. + useEffect(() => initialise(), [initialise]) + return ( }> @@ -19,7 +29,16 @@ export default function App() { } /> } /> } /> - } /> + + + + } + /> + } /> + } /> } /> } /> diff --git a/src/components/Layout/navigation.test.ts b/src/components/Layout/navigation.test.ts index 402ebe4..296adf4 100644 --- a/src/components/Layout/navigation.test.ts +++ b/src/components/Layout/navigation.test.ts @@ -3,10 +3,12 @@ import { BOTTOM_BAR_ITEMS, NAV_ITEMS } from '@/components/Layout/navigation' import { ROUTES } from '@/routes' describe('navigation', () => { - it('exposes one entry per SPA route', () => { - const navPaths = NAV_ITEMS.map((item) => item.path).sort() - const routePaths = Object.values(ROUTES).sort() - expect(navPaths).toEqual(routePaths) + it('exposes one entry per navigable route', () => { + // The auth screens are reached from a guard or a link, never from the menu. + const navigable = Object.values(ROUTES).filter( + (route) => route !== ROUTES.login && route !== ROUTES.register, + ) + expect(NAV_ITEMS.map((item) => item.path).sort()).toEqual(navigable.sort()) }) it('contains no duplicate route', () => { diff --git a/src/features/auth/AuthLayout.tsx b/src/features/auth/AuthLayout.tsx new file mode 100644 index 0000000..6ec078d --- /dev/null +++ b/src/features/auth/AuthLayout.tsx @@ -0,0 +1,68 @@ +import type { ReactNode } from 'react' +import { Link } from 'react-router-dom' +import { Button, Card } from '@/components/UI' +import { isSupabaseConfigured } from '@/lib/supabase' +import { useAuthStore } from '@/store/useAuthStore' + +interface AuthLayoutProps { + title: string + subtitle: string + children: ReactNode + footer: ReactNode +} + +/** Shell shared by the sign-in and sign-up screens (spec section 2.6). */ +export default function AuthLayout({ title, subtitle, children, footer }: AuthLayoutProps) { + const error = useAuthStore((state) => state.error) + const signInWithGoogle = useAuthStore((state) => state.signInWithGoogle) + + if (!isSupabaseConfigured) { + return ( + + +

Comptes indisponibles

+

+ Aucun serveur n'est configuré sur cette installation. Tous les modes restent jouables en + invité ; seul le classement mondial demande un compte. +

+ + Retour à l'accueil + +
+ ) + } + + return ( + +
+

{title}

+

{subtitle}

+
+ + {error && ( +

+ {error} +

+ )} + + {children} + +
+ + ou + +
+ + + +

{footer}

+
+ ) +} diff --git a/src/features/auth/LoginPage.tsx b/src/features/auth/LoginPage.tsx new file mode 100644 index 0000000..c3ae14d --- /dev/null +++ b/src/features/auth/LoginPage.tsx @@ -0,0 +1,69 @@ +import { useState, type FormEvent } from 'react' +import { Link, useLocation, useNavigate } from 'react-router-dom' +import { Button } from '@/components/UI' +import AuthLayout from '@/features/auth/AuthLayout' +import { ROUTES } from '@/routes' +import { useAuthStore } from '@/store/useAuthStore' + +export default function LoginPage() { + const signIn = useAuthStore((state) => state.signIn) + const navigate = useNavigate() + const location = useLocation() + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + + // Send the player back where the guard intercepted them. + const redirectTo = (location.state as { from?: string } | null)?.from ?? ROUTES.leaderboard + + const submit = async (event: FormEvent) => { + event.preventDefault() + setIsSubmitting(true) + const ok = await signIn({ email, password }) + setIsSubmitting(false) + if (ok) navigate(redirectTo, { replace: true }) + } + + return ( + + Pas encore de compte ?{' '} + + Créer un compte + + + } + > +
+ + + +
+
+ ) +} diff --git a/src/features/auth/RegisterPage.tsx b/src/features/auth/RegisterPage.tsx new file mode 100644 index 0000000..01a258d --- /dev/null +++ b/src/features/auth/RegisterPage.tsx @@ -0,0 +1,81 @@ +import { useState, type FormEvent } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { Button } from '@/components/UI' +import AuthLayout from '@/features/auth/AuthLayout' +import { ROUTES } from '@/routes' +import { useAuthStore } from '@/store/useAuthStore' + +export default function RegisterPage() { + const signUp = useAuthStore((state) => state.signUp) + const navigate = useNavigate() + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [username, setUsername] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + + const submit = async (event: FormEvent) => { + event.preventDefault() + setIsSubmitting(true) + const ok = await signUp({ email, password, username }) + setIsSubmitting(false) + if (ok) navigate(ROUTES.profile, { replace: true }) + } + + return ( + + Déjà inscrit ?{' '} + + Se connecter + + + } + > +
+ + + + +
+
+ ) +} diff --git a/src/features/auth/RequireAuth.tsx b/src/features/auth/RequireAuth.tsx new file mode 100644 index 0000000..ff0f802 --- /dev/null +++ b/src/features/auth/RequireAuth.tsx @@ -0,0 +1,44 @@ +import type { ReactNode } from 'react' +import { Navigate, useLocation } from 'react-router-dom' +import { Card, Spinner } from '@/components/UI' +import { isSupabaseConfigured } from '@/lib/supabase' +import { ROUTES } from '@/routes' +import { useAuthStore } from '@/store/useAuthStore' + +/** + * Route guard for the pages that need an account (spec section 2.6). Only the + * leaderboard is behind it: every game mode stays open to guests. + */ +export default function RequireAuth({ children }: { children: ReactNode }) { + const isReady = useAuthStore((state) => state.isReady) + const session = useAuthStore((state) => state.session) + const location = useLocation() + + if (!isSupabaseConfigured) { + return ( + +

Classement indisponible

+

+ Le classement mondial a besoin d'un serveur, qui n'est pas configuré ici. Vos scores + restent enregistrés localement dans le mode Chasse. +

+
+ ) + } + + // Wait for the stored session before deciding, or a signed-in player would be + // bounced to the login screen on every refresh. + if (!isReady) { + return ( +
+ +
+ ) + } + + if (!session) { + return + } + + return <>{children} +} diff --git a/src/features/hunt/HuntPage.tsx b/src/features/hunt/HuntPage.tsx index f76b9f7..cb6637c 100644 --- a/src/features/hunt/HuntPage.tsx +++ b/src/features/hunt/HuntPage.tsx @@ -1,4 +1,5 @@ -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useState } from 'react' +import { Link } from 'react-router-dom' import { Badge, Button, Card, PageHeader } from '@/components/UI' import HuntBoard from '@/features/hunt/HuntBoard' import { CHAMPION_DESCRIPTIONS, CHAMPION_LABELS, type ChampionType } from '@/features/hunt/board' @@ -11,6 +12,9 @@ import { } from '@/features/hunt/scoring' import { useHuntGame } from '@/features/hunt/useHuntGame' import { useLocalStorage } from '@/hooks/useLocalStorage' +import { supabase } from '@/lib/supabase' +import { ROUTES } from '@/routes' +import { useAuthStore } from '@/store/useAuthStore' import { useProgressionStore } from '@/store/useProgressionStore' import { cn } from '@/utils/cn' @@ -34,6 +38,7 @@ export default function HuntPage() { const game = useHuntGame() const [scoreboard, setScoreboard] = useLocalStorage('chesstrainer.hunt.scores', {}) const recordHunt = useProgressionStore((state) => state.recordHunt) + const session = useAuthStore((state) => state.session) // Record the round once, when it ends. const recorded = useRef(false) @@ -64,6 +69,36 @@ export default function HuntPage() { ) }, [game.phase, game.champion, game.score, game.captures, scoreboard, setScoreboard, recordHunt]) + // Submitting to the worldwide table is tracked apart from the local record: + // the session is restored asynchronously, so a short round can end before it + // exists. Sharing one flag would drop the score for good; this effect simply + // runs again once the session arrives. + const submitted = useRef(false) + const [submitFailed, setSubmitFailed] = useState(false) + useEffect(() => { + if (game.phase !== 'over') { + submitted.current = false + setSubmitFailed(false) + return + } + if (submitted.current || !supabase || !session || !game.champion || game.score <= 0) return + submitted.current = true + + const client = supabase + const round = { piece: game.champion, score: game.score, captures: game.captures } + void client + .from('scores') + .insert({ user_id: session.user.id, ...round }) + .then(({ error }) => { + // A silently dropped score looks like the leaderboard is broken, so the + // failure is surfaced and a retry is allowed. + if (error) { + submitted.current = false + setSubmitFailed(true) + } + }) + }, [game.phase, game.champion, game.score, game.captures, session]) + if (game.phase === 'setup') { return (
@@ -126,6 +161,23 @@ export default function HuntPage() { {encouragement(game.score, previousBest, game.captures)}

+ {submitFailed && ( +

+ Score enregistré localement, mais son envoi au classement mondial a échoué. Il sera + renvoyé à la prochaine manche. +

+ )} + + {!session && game.score > 0 && ( +

+ Ce score pourrait figurer au classement mondial —{' '} + + créez un compte + {' '} + pour l'y inscrire. +

+ )} +

Meilleurs scores — {CHAMPION_LABELS[champion]} diff --git a/src/features/leaderboard/LeaderboardPage.tsx b/src/features/leaderboard/LeaderboardPage.tsx index db49d2a..bf8e8a4 100644 --- a/src/features/leaderboard/LeaderboardPage.tsx +++ b/src/features/leaderboard/LeaderboardPage.tsx @@ -1,12 +1,152 @@ -import ModuleStub from '@/features/ModuleStub' +import { useState } from 'react' +import { Badge, Card, PageHeader, Spinner } from '@/components/UI' +import { CHAMPION_LABELS, type ChampionType } from '@/features/hunt/board' +import { + useLeaderboard, + type LeaderboardPeriod, + type LeaderboardPiece, +} from '@/features/leaderboard/useLeaderboard' +import { AVATAR_GLYPHS, type AvatarPiece } from '@/lib/supabase' +import { useAuthStore } from '@/store/useAuthStore' +import { cn } from '@/utils/cn' +const PIECES: { value: LeaderboardPiece; label: string }[] = [ + { value: 'all', label: 'Toutes' }, + ...(['q', 'r', 'b', 'n'] as ChampionType[]).map((piece) => ({ + value: piece as LeaderboardPiece, + label: CHAMPION_LABELS[piece], + })), +] + +const PERIODS: { value: LeaderboardPeriod; label: string }[] = [ + { value: 'today', label: "Aujourd'hui" }, + { value: 'week', label: 'Cette semaine' }, + { value: 'all', label: 'Tous les temps' }, +] + +function Segmented({ + options, + value, + onChange, + label, +}: { + options: { value: T; label: string }[] + value: T + onChange: (value: T) => void + label: string +}) { + return ( +
+ {options.map((option) => ( + + ))} +
+ ) +} + +/** + * Worldwide Piece Hunt leaderboard (spec section 2.6): top ten per piece or + * overall, filtered by period, refreshed live, with the signed-in player + * highlighted. + */ export default function LeaderboardPage() { + const [piece, setPiece] = useState('all') + const [period, setPeriod] = useState('all') + const { rows, isLoading, error } = useLeaderboard(piece, period) + const session = useAuthStore((state) => state.session) + return ( - +
+ Temps réel} + /> + + +
+ + +
+ + {error && ( +

+ {error} +

+ )} + + {isLoading ? ( +
+ +
+ ) : rows.length === 0 ? ( +

+ Aucun score sur cette période. Lancez une manche de Chasse pour ouvrir le classement. +

+ ) : ( +
    + {rows.map((row, index) => { + const isMe = session?.user.id === row.userId + return ( +
  1. + + {index + 1} + + + + + {row.username} + {isMe && vous} + + + {CHAMPION_LABELS[row.piece as ChampionType] ?? row.piece} · {row.captures}{' '} + captures · {new Date(row.playedAt).toLocaleDateString('fr-FR')} + + + + {row.score} + +
  2. + ) + })} +
+ )} +
+
) } diff --git a/src/features/leaderboard/useLeaderboard.test.ts b/src/features/leaderboard/useLeaderboard.test.ts new file mode 100644 index 0000000..c2982eb --- /dev/null +++ b/src/features/leaderboard/useLeaderboard.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { + bestPerPlayer, + periodStart, + type LeaderboardRow, +} from '@/features/leaderboard/useLeaderboard' + +const row = (id: number, userId: string, score: number, captures = 0): LeaderboardRow => ({ + id, + userId, + username: userId, + avatarPiece: 'n', + piece: 'q', + score, + captures, + playedAt: '2026-07-11T10:00:00.000Z', +}) + +describe('periodStart', () => { + const now = new Date('2026-07-11T15:30:00') + + it('has no lower bound for all time', () => { + expect(periodStart('all', now)).toBeNull() + }) + + it('starts today at midnight', () => { + const start = new Date(periodStart('today', now)!) + expect(start.getDate()).toBe(11) + expect(start.getHours()).toBe(0) + }) + + it('covers the last seven days for the week', () => { + const start = new Date(periodStart('week', now)!) + expect(start.getDate()).toBe(5) + expect(start.getHours()).toBe(0) + }) +}) + +describe('bestPerPlayer', () => { + it('keeps only each player’s best round', () => { + const rows = bestPerPlayer([row(1, 'alice', 100), row(2, 'alice', 300), row(3, 'bob', 200)]) + expect(rows.map((r) => r.userId)).toEqual(['alice', 'bob']) + expect(rows[0]!.score).toBe(300) + }) + + it('sorts by score, then by captures', () => { + const rows = bestPerPlayer([row(1, 'a', 100, 2), row(2, 'b', 100, 9), row(3, 'c', 150)]) + expect(rows.map((r) => r.userId)).toEqual(['c', 'b', 'a']) + }) + + it('caps the table at the top ten', () => { + const many = Array.from({ length: 25 }, (_, i) => row(i, `p${i}`, i * 10)) + expect(bestPerPlayer(many)).toHaveLength(10) + }) +}) diff --git a/src/features/leaderboard/useLeaderboard.ts b/src/features/leaderboard/useLeaderboard.ts new file mode 100644 index 0000000..13e340c --- /dev/null +++ b/src/features/leaderboard/useLeaderboard.ts @@ -0,0 +1,126 @@ +import { useCallback, useEffect, useState } from 'react' +import { supabase } from '@/lib/supabase' + +export type LeaderboardPeriod = 'today' | 'week' | 'all' +/** 'all' shows the best score whatever the piece (spec section 2.6). */ +export type LeaderboardPiece = 'all' | 'q' | 'r' | 'b' | 'n' + +export interface LeaderboardRow { + id: number + userId: string + username: string + avatarPiece: string + piece: string + score: number + captures: number + playedAt: string +} + +/** Oldest timestamp a period accepts, or null for all time. */ +export function periodStart(period: LeaderboardPeriod, now: Date = new Date()): string | null { + if (period === 'all') return null + const start = new Date(now) + if (period === 'today') { + start.setHours(0, 0, 0, 0) + } else { + // "This week" counts the last seven days, midnight-aligned. + start.setHours(0, 0, 0, 0) + start.setDate(start.getDate() - 6) + } + return start.toISOString() +} + +/** Keeps only each player's best row, so one person cannot fill the table. */ +export function bestPerPlayer(rows: LeaderboardRow[], limit = 10): LeaderboardRow[] { + const best = new Map() + for (const row of rows) { + const current = best.get(row.userId) + if (!current || row.score > current.score) best.set(row.userId, row) + } + return [...best.values()] + .sort((a, b) => b.score - a.score || b.captures - a.captures) + .slice(0, limit) +} + +interface ScoreWithProfile { + id: number + user_id: string + piece: string + score: number + captures: number + played_at: string + profiles: { username: string; avatar_piece: string } | null +} + +/** + * The worldwide Piece Hunt leaderboard (spec section 2.6): top ten per piece or + * overall, filtered by period, and refreshed live through Supabase Realtime. + */ +export function useLeaderboard(piece: LeaderboardPiece, period: LeaderboardPeriod) { + const [rows, setRows] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + const load = useCallback(async () => { + if (!supabase) { + setIsLoading(false) + return + } + setIsLoading(true) + + let query = supabase + .from('scores') + .select('id, user_id, piece, score, captures, played_at, profiles(username, avatar_piece)') + .order('score', { ascending: false }) + .limit(200) + + if (piece !== 'all') query = query.eq('piece', piece) + const since = periodStart(period) + if (since) query = query.gte('played_at', since) + + const { data, error: queryError } = await query + if (queryError) { + setError("Le classement n'a pas pu être chargé.") + setIsLoading(false) + return + } + + setError(null) + setRows( + bestPerPlayer( + ((data ?? []) as unknown as ScoreWithProfile[]).map((row) => ({ + id: row.id, + userId: row.user_id, + username: row.profiles?.username ?? 'Joueur', + avatarPiece: row.profiles?.avatar_piece ?? 'n', + piece: row.piece, + score: row.score, + captures: row.captures, + playedAt: row.played_at, + })), + ), + ) + setIsLoading(false) + }, [piece, period]) + + useEffect(() => { + void load() + }, [load]) + + // Live updates: any new score reloads the board (spec section 2.6). + useEffect(() => { + if (!supabase) return + const channel = supabase + .channel('scores-feed') + .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'scores' }, () => { + void load() + }) + .subscribe() + + return () => { + void supabase?.removeChannel(channel) + } + }, [load]) + + return { rows, isLoading, error, reload: load } +} diff --git a/src/features/profile/ProfilePage.tsx b/src/features/profile/ProfilePage.tsx index 6cda3ee..c68e788 100644 --- a/src/features/profile/ProfilePage.tsx +++ b/src/features/profile/ProfilePage.tsx @@ -1,7 +1,16 @@ -import { Badge, Card, PageHeader } from '@/components/UI' +import { Link } from 'react-router-dom' +import { Badge, Button, Card, PageHeader } from '@/components/UI' import LevelBar from '@/features/progression/LevelBar' import { BADGES } from '@/features/progression/badges' import { levelFromXp } from '@/features/progression/levels' +import { + AVATAR_GLYPHS, + AVATAR_PIECES, + isSupabaseConfigured, + type AvatarPiece, +} from '@/lib/supabase' +import { ROUTES } from '@/routes' +import { useAuthStore } from '@/store/useAuthStore' import { useProgressionStore } from '@/store/useProgressionStore' import { cn } from '@/utils/cn' @@ -14,6 +23,11 @@ export default function ProfilePage() { const xp = useProgressionStore((state) => state.xp) const stats = useProgressionStore((state) => state.stats) const unlocked = useProgressionStore((state) => state.unlockedBadges) + const isReady = useAuthStore((state) => state.isReady) + const session = useAuthStore((state) => state.session) + const authProfile = useAuthStore((state) => state.profile) + const updateProfile = useAuthStore((state) => state.updateProfile) + const signOut = useAuthStore((state) => state.signOut) const progress = levelFromXp(xp) const winRate = @@ -41,6 +55,85 @@ export default function ProfilePage() {
+ +

Compte

+ {!isSupabaseConfigured ? ( +

+ Aucun serveur n'est configuré : vous jouez en invité et votre progression reste sur + cet appareil. +

+ ) : !isReady || (session && !authProfile) ? ( + // The stored session, then the profile, are both fetched on + // start-up. Falling through to the guest block meanwhile would + // invite a signed-in player to create a second account. +

Chargement de votre compte…

+ ) : session && authProfile ? ( + <> +
+ + + {authProfile.username} + + Inscrit le {new Date(authProfile.created_at).toLocaleDateString('fr-FR')} + + +
+ +
+

Avatar

+
+ {AVATAR_PIECES.map((piece: AvatarPiece) => ( + + ))} +
+
+ + + + ) : ( + <> +

+ Vous jouez en invité. Un compte vous ouvre le classement mondial et retrouve vos + progrès sur tous vos appareils. +

+
+ + Créer un compte + + + Se connecter + +
+ + )} +
+

{xp} XP au total

diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts new file mode 100644 index 0000000..b31893c --- /dev/null +++ b/src/lib/supabase.ts @@ -0,0 +1,54 @@ +import { createClient, type SupabaseClient } from '@supabase/supabase-js' + +const url = import.meta.env.VITE_SUPABASE_URL +const anonKey = import.meta.env.VITE_SUPABASE_ANON_KEY + +/** + * Whether a backend is configured. Everything auth- or leaderboard-related is + * optional: without credentials the app still runs entirely as a guest, which + * is what section 2.6 asks for ("mode invité autorisé pour tous les modes") and + * what lets the build and CI work with no secrets at all. + */ +export const isSupabaseConfigured = Boolean(url && anonKey) + +export const supabase: SupabaseClient | null = isSupabaseConfigured + ? createClient(url!, anonKey!, { + auth: { + persistSession: true, + // The JWT refreshes itself client-side (specification section 2.6). + autoRefreshToken: true, + detectSessionInUrl: true, + }, + }) + : null + +/** The six piece symbols an avatar can be (specification section 2.6). */ +export const AVATAR_PIECES = ['k', 'q', 'r', 'b', 'n', 'p'] as const +export type AvatarPiece = (typeof AVATAR_PIECES)[number] + +export const AVATAR_GLYPHS: Record = { + k: '♚', + q: '♛', + r: '♜', + b: '♝', + n: '♞', + p: '♟', +} + +export interface Profile { + id: string + username: string + avatar_piece: AvatarPiece + xp: number + level: number + created_at: string +} + +export interface ScoreRow { + id: number + user_id: string + piece: string + score: number + captures: number + played_at: string +} diff --git a/src/routes.ts b/src/routes.ts index 18294d2..6f2b592 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -1,7 +1,4 @@ -/** - * SPA route table (specification, section 4.3). - * Authentication routes (/login, /register) are added in module M10. - */ +/** SPA route table (specification, sections 4.3 and 2.6). */ export const ROUTES = { home: '/', coach: '/coach', @@ -10,6 +7,8 @@ export const ROUTES = { hunt: '/hunt', leaderboard: '/leaderboard', profile: '/profile', + login: '/login', + register: '/register', } as const export type RoutePath = (typeof ROUTES)[keyof typeof ROUTES] diff --git a/src/store/useAuthStore.ts b/src/store/useAuthStore.ts new file mode 100644 index 0000000..d31aa97 --- /dev/null +++ b/src/store/useAuthStore.ts @@ -0,0 +1,148 @@ +import type { Session } from '@supabase/supabase-js' +import { create } from 'zustand' +import { isSupabaseConfigured, supabase, type AvatarPiece, type Profile } from '@/lib/supabase' + +interface AuthState { + /** False until the stored session has been read, so guards do not flash. */ + isReady: boolean + session: Session | null + profile: Profile | null + error: string | null + + initialise: () => () => void + signUp: (input: { email: string; password: string; username: string }) => Promise + signIn: (input: { email: string; password: string }) => Promise + signInWithGoogle: () => Promise + signOut: () => Promise + updateProfile: (patch: { username?: string; avatar_piece?: AvatarPiece }) => Promise + clearError: () => void +} + +/** Supabase messages are technical and in English; these are for the player. */ +function friendlyError(message: string): string { + const text = message.toLowerCase() + if (text.includes('invalid login credentials')) return 'Email ou mot de passe incorrect.' + if (text.includes('already registered')) return 'Un compte existe déjà avec cet email.' + if (text.includes('password')) return 'Le mot de passe doit faire au moins 6 caractères.' + if (text.includes('email')) return 'Adresse email invalide.' + if (text.includes('duplicate key') || text.includes('profiles_username')) + return 'Ce pseudo est déjà pris.' + return 'Une erreur est survenue. Réessayez dans un instant.' +} + +/** + * Authentication and the signed-in profile (spec section 2.6). Every action is + * a no-op when no backend is configured, so guest play is never blocked. + */ +export const useAuthStore = create()((set, get) => ({ + isReady: !isSupabaseConfigured, + session: null, + profile: null, + error: null, + + initialise: () => { + // Captured once so the closures below keep the non-null narrowing. + const client = supabase + if (!client) { + set({ isReady: true }) + return () => {} + } + + // Sign-out, then sign-in, can overlap: without this token a slow first + // request could land last and hand the new session the old profile. + let latestRequest = 0 + + const loadProfile = async (session: Session | null) => { + const request = ++latestRequest + if (!session) { + set({ profile: null }) + return + } + const { data } = await client + .from('profiles') + .select('*') + .eq('id', session.user.id) + .maybeSingle() + if (request !== latestRequest) return + set({ profile: (data as Profile | null) ?? null }) + } + + void client.auth.getSession().then(async ({ data }) => { + set({ session: data.session }) + await loadProfile(data.session) + set({ isReady: true }) + }) + + const { data: subscription } = client.auth.onAuthStateChange((_event, session) => { + set({ session }) + void loadProfile(session) + }) + + return () => subscription.subscription.unsubscribe() + }, + + signUp: async ({ email, password, username }) => { + if (!supabase) return false + set({ error: null }) + // The username travels in the metadata; a database trigger creates the + // profile, so an account can never exist without one. + const { error } = await supabase.auth.signUp({ + email, + password, + options: { data: { username } }, + }) + if (error) { + set({ error: friendlyError(error.message) }) + return false + } + return true + }, + + signIn: async ({ email, password }) => { + if (!supabase) return false + set({ error: null }) + const { error } = await supabase.auth.signInWithPassword({ email, password }) + if (error) { + set({ error: friendlyError(error.message) }) + return false + } + return true + }, + + signInWithGoogle: async () => { + if (!supabase) return + set({ error: null }) + const { error } = await supabase.auth.signInWithOAuth({ + provider: 'google', + options: { redirectTo: `${window.location.origin}/profile` }, + }) + if (error) set({ error: friendlyError(error.message) }) + }, + + signOut: async () => { + if (!supabase) return + await supabase.auth.signOut() + set({ session: null, profile: null }) + }, + + updateProfile: async (patch) => { + const { session } = get() + if (!supabase || !session) return false + set({ error: null }) + const { data, error } = await supabase + .from('profiles') + .update(patch) + .eq('id', session.user.id) + .select() + .single() + + if (error) { + set({ error: friendlyError(error.message) }) + return false + } + set({ profile: data as Profile }) + return true + }, + + clearError: () => set({ error: null }), +})) diff --git a/supabase/.env.example b/supabase/.env.example new file mode 100644 index 0000000..6868b02 --- /dev/null +++ b/supabase/.env.example @@ -0,0 +1,6 @@ +# Local-only Google OAuth credentials, read by supabase/config.toml. +# Copy to supabase/.env (git ignored) and fill in with a Google Cloud OAuth +# client. Without this file the stack still starts and email sign-in works; +# only the Google button is inert. See the README, "Backend setup". +SUPABASE_AUTH_GOOGLE_CLIENT_ID= +SUPABASE_AUTH_GOOGLE_SECRET= diff --git a/supabase/.gitignore b/supabase/.gitignore new file mode 100644 index 0000000..ad9264f --- /dev/null +++ b/supabase/.gitignore @@ -0,0 +1,8 @@ +# Supabase +.branches +.temp + +# dotenvx +.env.keys +.env.local +.env.*.local diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 0000000..a89d1ed --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1,435 @@ +# For detailed configuration reference documentation, visit: +# https://supabase.com/docs/guides/local-development/cli/config +# A string used to distinguish different Supabase projects on the same host. Defaults to the +# working directory name when running `supabase init`. +project_id = "ChessTrainer" + +[api] +enabled = true +# Port to use for the API URL. +port = 54321 +# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API +# endpoints. `public` and `graphql_public` schemas are included by default. +schemas = ["public", "graphql_public"] +# Extra schemas to add to the search_path of every request. +extra_search_path = ["public", "extensions"] +# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size +# for accidental or malicious requests. +max_rows = 1000 +# Controls whether new tables, views, sequences and functions created in the `public` schema by +# `postgres` are reachable through the Data API roles (`anon`, `authenticated`, `service_role`) +# without explicit GRANTs. When unset, new entities are NOT auto-exposed, matching the new cloud +# default. Set to `true` to keep the legacy behaviour of auto-exposing new entities; this is +# deprecated and the field is removed on 2026-10-30 once the always-revoked behaviour is permanent. +# auto_expose_new_tables = true + +[api.tls] +# Enable HTTPS endpoints locally using a self-signed certificate. +enabled = false +# Paths to self-signed certificate pair. +# cert_path = "../certs/my-cert.pem" +# key_path = "../certs/my-key.pem" + +[db] +# Port to use for the local database URL. +port = 54322 +# Port used by db diff command to initialize the shadow database. +shadow_port = 54320 +# Maximum amount of time to wait for health check when starting the local database. +health_timeout = "2m" +# The database major version to use. This has to be the same as your remote database's. Run `SHOW +# server_version;` on the remote database to check. +major_version = 17 + +[db.pooler] +enabled = false +# Port to use for the local connection pooler. +port = 54329 +# Specifies when a server connection can be reused by other clients. +# Configure one of the supported pooler modes: `transaction`, `session`. +pool_mode = "transaction" +# How many server connections to allow per user/database pair. +default_pool_size = 20 +# Maximum number of client connections allowed. +max_client_conn = 100 + +# [db.vault] +# secret_key = "env(SECRET_VALUE)" + +[db.migrations] +# If disabled, migrations will be skipped during a db push or reset. +enabled = true +# Specifies an ordered list of schema files, directories, or glob patterns that describe your database. +# Supports paths relative to supabase directory: "./schemas/*.sql", "./database". +schema_paths = [] + +[db.seed] +# If enabled, seeds the database after migrations during a db reset. +enabled = true +# Specifies an ordered list of seed files to load during db reset. +# Supports glob patterns relative to supabase directory: "./seeds/*.sql" +sql_paths = ["./seed.sql"] + +[db.network_restrictions] +# Enable management of network restrictions. +enabled = false +# List of IPv4 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv4 connections. Set empty array to block all IPs. +allowed_cidrs = ["0.0.0.0/0"] +# List of IPv6 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv6 connections. Set empty array to block all IPs. +allowed_cidrs_v6 = ["::/0"] + +# Uncomment to reject non-secure connections to the database. +# [db.ssl_enforcement] +# enabled = true + +[realtime] +enabled = true +# Bind realtime via either IPv4 or IPv6. (default: IPv4) +# ip_version = "IPv6" +# The maximum length in bytes of HTTP request headers. (default: 4096) +# max_header_length = 4096 + +[studio] +enabled = true +# Port to use for Supabase Studio. +port = 54323 +# External URL of the API server that frontend connects to. +api_url = "http://127.0.0.1" +# OpenAI API Key to use for Supabase AI in the Supabase Studio. +openai_api_key = "env(OPENAI_API_KEY)" + +# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they +# are monitored, and you can view the emails that would have been sent from the web interface. +[local_smtp] +enabled = true +# Port to use for the email testing server web interface. +port = 54324 +# Uncomment to expose additional ports for testing user applications that send emails. +# smtp_port = 54325 +# pop3_port = 54326 +# admin_email = "admin@email.com" +# sender_name = "Admin" + +[storage] +enabled = true +# The maximum file size allowed (e.g. "5MB", "500KB"). +file_size_limit = "50MiB" + +# Uncomment to configure local storage buckets +# [storage.buckets.images] +# public = false +# file_size_limit = "50MiB" +# allowed_mime_types = ["image/png", "image/jpeg"] +# objects_path = "./images" + +# Allow connections via S3 compatible clients +[storage.s3_protocol] +enabled = true + +# Image transformation API is available to Supabase Pro plan. +# [storage.image_transformation] +# enabled = true + +# Store analytical data in S3 for running ETL jobs over Iceberg Catalog +# This feature is only available on the hosted platform. +[storage.analytics] +enabled = false +max_namespaces = 5 +max_tables = 10 +max_catalogs = 2 + +# Analytics Buckets is available to Supabase Pro plan. +# [storage.analytics.buckets.my-warehouse] + +# Store vector embeddings in S3 for large and durable datasets +[storage.vector] +enabled = true +max_buckets = 10 +max_indexes = 5 + +# Vector Buckets is available to Supabase Pro plan. +# [storage.vector.buckets.documents-openai] + +[auth] +enabled = true +# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used +# in emails. +# Port 5173 is the Vite dev server of this app, not the Supabase default of 3000. +site_url = "http://127.0.0.1:5173" +# The public URL that Auth serves on. Defaults to the API external URL with `/auth/v1` appended. +# external_url = "" +# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +# signInWithOAuth() sends the player to `${window.location.origin}/profile`, and +# this list is matched *exactly* — without the /profile entries Google sign-in +# fails locally with "requested path is invalid". Both hostnames are listed +# because the dev server answers on either. +additional_redirect_urls = [ + "http://127.0.0.1:5173", + "http://127.0.0.1:5173/profile", + "http://localhost:5173", + "http://localhost:5173/profile", +] +# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). +jwt_expiry = 3600 +# JWT issuer URL. If not set, defaults to auth.external_url. +# jwt_issuer = "" +# Path to JWT signing key. DO NOT commit your signing keys file to git. +# signing_keys_path = "./signing_keys.json" +# If disabled, the refresh token will never expire. +enable_refresh_token_rotation = true +# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. +# Requires enable_refresh_token_rotation = true. +refresh_token_reuse_interval = 10 +# Allow/disallow new user signups to your project. +enable_signup = true +# Allow/disallow anonymous sign-ins to your project. +enable_anonymous_sign_ins = false +# Allow/disallow testing manual linking of accounts +enable_manual_linking = false +# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. +minimum_password_length = 6 +# Passwords that do not meet the following requirements will be rejected as weak. Supported values +# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` +password_requirements = "" + +# Configure passkey sign-ins. +# [auth.passkey] +# enabled = false + +# Configure WebAuthn relying party settings (required when passkey is enabled). +# [auth.webauthn] +# rp_display_name = "Supabase" +# rp_id = "localhost" +# rp_origins = ["http://127.0.0.1:3000"] + +[auth.rate_limit] +# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled. +email_sent = 2 +# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled. +sms_sent = 30 +# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true. +anonymous_users = 30 +# Number of sessions that can be refreshed in a 5 minute interval per IP address. +token_refresh = 150 +# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users). +sign_in_sign_ups = 30 +# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address. +token_verifications = 30 +# Number of Web3 logins that can be made in a 5 minute interval per IP address. +web3 = 30 + +# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`. +# [auth.captcha] +# enabled = true +# provider = "hcaptcha" +# secret = "" + +[auth.email] +# Allow/disallow new user signups via email to your project. +enable_signup = true +# If enabled, a user will be required to confirm any email change on both the old, and new email +# addresses. If disabled, only the new email is required to confirm. +double_confirm_changes = true +# If enabled, users need to confirm their email address before signing in. +enable_confirmations = false +# If enabled, users will need to reauthenticate or have logged in recently to change their password. +secure_password_change = false +# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. +max_frequency = "1s" +# Number of characters used in the email OTP. +otp_length = 6 +# Number of seconds before the email OTP expires (defaults to 1 hour). +otp_expiry = 3600 + +# Use a production-ready SMTP server +# [auth.email.smtp] +# enabled = true +# host = "smtp.sendgrid.net" +# port = 587 +# user = "apikey" +# pass = "env(SENDGRID_API_KEY)" +# admin_email = "admin@email.com" +# sender_name = "Admin" + +# Uncomment to customize email template +# [auth.email.template.invite] +# subject = "You have been invited" +# content_path = "./supabase/templates/invite.html" + +# Uncomment to customize notification email template +# [auth.email.notification.password_changed] +# enabled = true +# subject = "Your password has been changed" +# content_path = "./templates/password_changed_notification.html" + +[auth.sms] +# Allow/disallow new user signups via SMS to your project. +enable_signup = false +# If enabled, users need to confirm their phone number before signing in. +enable_confirmations = false +# Template for sending OTP to users +template = "Your code is {{ `{{ .Code }}` }}" +# Controls the minimum amount of time that must pass before sending another sms otp. +max_frequency = "5s" + +# Use pre-defined map of phone number to OTP for testing. +# [auth.sms.test_otp] +# 4152127777 = "123456" + +# Configure logged in session timeouts. +# [auth.sessions] +# Force log out after the specified duration. +# timebox = "24h" +# Force log out if the user has been inactive longer than the specified duration. +# inactivity_timeout = "8h" + +# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object. +# [auth.hook.before_user_created] +# enabled = true +# uri = "pg-functions://postgres/auth/before-user-created-hook" + +# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. +# [auth.hook.custom_access_token] +# enabled = true +# uri = "pg-functions:////" + +# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. +[auth.sms.twilio] +enabled = false +account_sid = "" +message_service_sid = "" +# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: +auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" + +# Multi-factor-authentication is available to Supabase Pro plan. +[auth.mfa] +# Control how many MFA factors can be enrolled at once per user. +max_enrolled_factors = 10 + +# Control MFA via App Authenticator (TOTP) +[auth.mfa.totp] +enroll_enabled = false +verify_enabled = false + +# Configure MFA via Phone Messaging +[auth.mfa.phone] +enroll_enabled = false +verify_enabled = false +otp_length = 6 +template = "Your code is {{ `{{ .Code }}` }}" +max_frequency = "5s" + +# Configure MFA via WebAuthn +# [auth.mfa.web_authn] +# enroll_enabled = true +# verify_enabled = true + +# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, +# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, +# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`. +[auth.external.apple] +enabled = false +client_id = "" +# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: +secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" +# Overrides the default auth callback URL derived from auth.external_url. +redirect_uri = "" +# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, +# or any other third-party OIDC providers. +url = "" +# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. +skip_nonce_check = false +# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address. +email_optional = false + +# Google sign-in, offered next to the email form (specification section 2.6). +# The credentials come from a Google Cloud OAuth client and stay out of the repo; +# set them in supabase/.env before running `supabase start`. +[auth.external.google] +enabled = true +client_id = "env(SUPABASE_AUTH_GOOGLE_CLIENT_ID)" +secret = "env(SUPABASE_AUTH_GOOGLE_SECRET)" +redirect_uri = "http://127.0.0.1:54321/auth/v1/callback" +# Google issues no nonce against a local callback. +skip_nonce_check = true + +# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard. +# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting. +[auth.web3.solana] +enabled = false + +# Use Firebase Auth as a third-party provider alongside Supabase Auth. +[auth.third_party.firebase] +enabled = false +# project_id = "my-firebase-project" + +# Use Auth0 as a third-party provider alongside Supabase Auth. +[auth.third_party.auth0] +enabled = false +# tenant = "my-auth0-tenant" +# tenant_region = "us" + +# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. +[auth.third_party.aws_cognito] +enabled = false +# user_pool_id = "my-user-pool-id" +# user_pool_region = "us-east-1" + +# Use Clerk as a third-party provider alongside Supabase Auth. +[auth.third_party.clerk] +enabled = false +# Obtain from https://clerk.com/setup/supabase +# domain = "example.clerk.accounts.dev" + +# OAuth server configuration +[auth.oauth_server] +# Enable OAuth server functionality +enabled = false +# Path for OAuth consent flow UI +authorization_url_path = "/oauth/consent" +# Allow dynamic client registration +allow_dynamic_registration = false + +[edge_runtime] +enabled = true +# Supported request policies: `oneshot`, `per_worker`. +# `per_worker` (default) — enables hot reload during local development. +# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks). +policy = "per_worker" +# Port to attach the Chrome inspector for debugging edge functions. +inspector_port = 8083 +# The Deno major version to use. +deno_version = 2 + +# [edge_runtime.secrets] +# secret_key = "env(SECRET_VALUE)" + +[analytics] +enabled = true +port = 54327 +# Configure one of the supported backends: `postgres`, `bigquery`. +backend = "postgres" + +# Experimental features may be deprecated any time +[experimental] +# Configures Postgres storage engine to use OrioleDB (S3) +orioledb_version = "" +# Configures S3 bucket URL, eg. .s3-.amazonaws.com +s3_host = "env(S3_HOST)" +# Configures S3 bucket region, eg. us-east-1 +s3_region = "env(S3_REGION)" +# Configures AWS_ACCESS_KEY_ID for S3 bucket +s3_access_key = "env(S3_ACCESS_KEY)" +# Configures AWS_SECRET_ACCESS_KEY for S3 bucket +s3_secret_key = "env(S3_SECRET_KEY)" + +# pg-delta is the schema diff engine for db diff / db pull / db remote commit. +# Set enabled = false to fall back to the legacy migra engine. +[experimental.pgdelta] +enabled = true +# Directory under `supabase/` where declarative files are written. +# declarative_schema_path = "./database" +# JSON string passed through to pg-delta SQL formatting. +# format_options = "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}" diff --git a/supabase/migrations/20260711000000_init.sql b/supabase/migrations/20260711000000_init.sql new file mode 100644 index 0000000..b8c62b3 --- /dev/null +++ b/supabase/migrations/20260711000000_init.sql @@ -0,0 +1,203 @@ +-- ChessTrainer — data model of specification section 2.6. +-- +-- Row Level Security is enabled on every table and no policy is permissive by +-- default: section 06 of the specification rates a misconfigured RLS as a high +-- impact risk, so each table states exactly who may read and who may write. + +-- ---------------------------------------------------------------- profiles -- +-- One row per account, created automatically on sign-up by the trigger below. +create table if not exists public.profiles ( + id uuid primary key references auth.users on delete cascade, + username text not null check (char_length(trim(username)) between 3 and 24), + avatar_piece text not null default 'n' check (avatar_piece in ('k','q','r','b','n','p')), + xp integer not null default 0 check (xp >= 0), + level integer not null default 1 check (level between 1 and 30), + created_at timestamptz not null default now() +); + +create unique index if not exists profiles_username_key on public.profiles (lower(username)); + +alter table public.profiles enable row level security; + +-- Profiles are public: the leaderboard has to show who holds a score. +drop policy if exists "profiles are readable by everyone" on public.profiles; +create policy "profiles are readable by everyone" + on public.profiles for select + using (true); + +drop policy if exists "a user creates only their own profile" on public.profiles; +create policy "a user creates only their own profile" + on public.profiles for insert + to authenticated + with check (auth.uid() = id); + +-- Ownership only; which columns may change is enforced by the column grants +-- below, because a policy cannot restrict columns. +drop policy if exists "a user edits only their own profile" on public.profiles; +create policy "a user edits only their own profile" + on public.profiles for update + to authenticated + using (auth.uid() = id) + with check (auth.uid() = id); + +-- ------------------------------------------------------------------ scores -- +-- One row per finished Piece Hunt round. +create table if not exists public.scores ( + id bigint generated always as identity primary key, + user_id uuid not null references public.profiles (id) on delete cascade, + piece text not null check (piece in ('q','r','b','n','k')), + score integer not null check (score >= 0), + captures integer not null default 0 check (captures >= 0), + played_at timestamptz not null default now() +); + +-- The leaderboard reads "best scores for a piece", and filters by period. +create index if not exists scores_piece_score_idx on public.scores (piece, score desc); +create index if not exists scores_played_at_idx on public.scores (played_at desc); + +alter table public.scores enable row level security; + +-- The leaderboard is worldwide, so reading is open even to guests. +drop policy if exists "scores are readable by everyone" on public.scores; +create policy "scores are readable by everyone" + on public.scores for select + using (true); + +-- A score can only ever be filed under its own author. +drop policy if exists "a user submits only their own scores" on public.scores; +create policy "a user submits only their own scores" + on public.scores for insert + to authenticated + with check (auth.uid() = user_id); + +-- Deliberately no update or delete policy: a submitted score is final. + +-- ---------------------------------------------------------- puzzle_progress -- +create table if not exists public.puzzle_progress ( + user_id uuid not null references public.profiles (id) on delete cascade, + puzzle_id text not null, + solved boolean not null default false, + attempts integer not null default 0 check (attempts >= 0), + solved_at timestamptz, + primary key (user_id, puzzle_id) +); + +alter table public.puzzle_progress enable row level security; + +-- Private to its owner: nobody needs to see someone else's puzzle history. +drop policy if exists "puzzle progress is private" on public.puzzle_progress; +create policy "puzzle progress is private" + on public.puzzle_progress for select + to authenticated + using (auth.uid() = user_id); + +drop policy if exists "a user writes only their own puzzle progress" on public.puzzle_progress; +create policy "a user writes only their own puzzle progress" + on public.puzzle_progress for insert + to authenticated + with check (auth.uid() = user_id); + +drop policy if exists "a user updates only their own puzzle progress" on public.puzzle_progress; +create policy "a user updates only their own puzzle progress" + on public.puzzle_progress for update + to authenticated + using (auth.uid() = user_id) + with check (auth.uid() = user_id); + +-- ------------------------------------------------------------ achievements -- +create table if not exists public.achievements ( + user_id uuid not null references public.profiles (id) on delete cascade, + badge_id text not null, + unlocked_at timestamptz not null default now(), + primary key (user_id, badge_id) +); + +alter table public.achievements enable row level security; + +-- Badges are public so a profile can be shown off. +drop policy if exists "achievements are readable by everyone" on public.achievements; +create policy "achievements are readable by everyone" + on public.achievements for select + using (true); + +drop policy if exists "a user unlocks only their own badges" on public.achievements; +create policy "a user unlocks only their own badges" + on public.achievements for insert + to authenticated + with check (auth.uid() = user_id); + +-- ----------------------------------------------------------- privileges ---- +-- RLS filters *rows*; a GRANT opens the *table*. Both are needed, and relying +-- on Supabase's default privileges is not portable — without these grants every +-- request fails with "permission denied" (42501) however correct the policies. +-- Each role gets only what its policies allow, and nothing more. +grant usage on schema public to anon, authenticated; + +-- Readable by everyone, including guests browsing the leaderboard. +grant select on public.profiles, public.scores, public.achievements to anon, authenticated; + +-- Column-level, deliberately: RLS filters rows, not columns. Granting update on +-- the whole row would let a player set their own xp and level to anything. +grant insert (id, username, avatar_piece) on public.profiles to authenticated; +grant update (username, avatar_piece) on public.profiles to authenticated; +-- Insert only: a submitted score is final, so no update or delete is granted. +grant insert on public.scores to authenticated; +grant insert on public.achievements to authenticated; +-- Private to its owner, so guests get nothing at all. +grant select, insert, update on public.puzzle_progress to authenticated; + +grant usage, select on all sequences in schema public to authenticated; + +-- ------------------------------------------------------- profile on signup -- +-- Creating the profile from the client would need a second round trip that can +-- fail after the account exists, leaving an account with no profile. +create or replace function public.handle_new_user() +returns trigger +language plpgsql +security definer +set search_path = public +as $$ +declare + requested text := coalesce(new.raw_user_meta_data ->> 'username', split_part(new.email, '@', 1)); + candidate text := left(regexp_replace(requested, '[^A-Za-z0-9_-]', '', 'g'), 24); + avatar text := coalesce(new.raw_user_meta_data ->> 'avatar_piece', 'n'); + attempts integer := 0; +begin + if char_length(candidate) < 3 then + candidate := 'joueur' || left(replace(new.id::text, '-', ''), 6); + end if; + + -- Metadata comes from the client and could hold anything; an unexpected value + -- would violate the check constraint and abort the whole signup. + if avatar not in ('k', 'q', 'r', 'b', 'n', 'p') then + avatar := 'n'; + end if; + + -- Retry on collision rather than check-then-insert: two signups racing on the + -- same fallback name would both pass a prior existence check and one would + -- fail, taking that signup down with it. + loop + begin + insert into public.profiles (id, username, avatar_piece) + values (new.id, candidate, avatar); + return new; + exception + when unique_violation then + attempts := attempts + 1; + if attempts > 5 then + raise exception 'could not allocate a username for %', new.id; + end if; + candidate := left(candidate, 16) || left(replace(gen_random_uuid()::text, '-', ''), 8); + end; + end loop; +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(); + +-- --------------------------------------------------------------- realtime -- +-- The leaderboard subscribes to score inserts (specification section 2.6). +alter publication supabase_realtime add table public.scores; diff --git a/vite.config.ts b/vite.config.ts index f891dc6..6409f55 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -15,6 +15,9 @@ export default defineConfig({ }, test: { environment: 'jsdom', + // Tests always run as if no backend were configured: that is what CI sees + // (it holds no secrets) and it keeps them off the network entirely. + env: { VITE_SUPABASE_URL: '', VITE_SUPABASE_ANON_KEY: '' }, setupFiles: ['./src/test/setup.ts'], include: ['src/**/*.{test,spec}.{ts,tsx}'], coverage: {