diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 69855eb..2344f21 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -55,19 +55,19 @@ admin) implementati su `develop`, da attivare in produzione seguendo i passaggi ## Autenticazione e dashboard amministratore -Implementate su `develop`, **non ancora attive in produzione**. Il codice è additivo: finché -i passaggi qui sotto non sono fatti, l'app si comporta esattamente come prima. +Implementate su `develop`. **Il login è l'unica via d'accesso** (31/08/2026): la selezione +libera del giocatore non esiste più, senza sessione Google si resta su `/benvenuto`, e i +permessi di amministrazione arrivano solo da `user_roles`. -**Stato in sviluppo (30/08/2026):** il codice c'è ed è completo, ma il login non funziona -ancora. Con `npm run dev`, «Accedi con Google» risponde: +**Attenzione all'ordine:** finché il provider Google è spento in Supabase, «Accedi con +Google» risponde ``` {"code":400,"error_code":"validation_failed","msg":"Unsupported provider: provider is not enabled"} ``` -Non è un difetto dell'app: l'errore arriva da Supabase, dove il provider Google è spento. -È il passo 1 qui sotto, ancora da fare. Tutto il resto dell'app in dev funziona normalmente, -perché l'accesso avviene ancora scegliendo il proprio nome. +e **nessuno entra nell'app**, né in dev né sulla preview di `develop`. Il passo 1 qui sotto +va fatto prima di mandare questa versione in produzione. Passaggi in ordine, nessuno dei quali è reversibile a metà: @@ -88,9 +88,9 @@ Passaggi in ordine, nessuno dei quali è reversibile a metà: `INSERT INTO public.user_roles (user_id, role) SELECT id, 'admin' FROM auth.users WHERE email = '';` 4. **Collegamento dei 17 account**: ciascuno accede con Google e sceglie il proprio nome una volta sola. Uno slot già collegato può essere liberato solo da un admin. -5. **Solo a squadra collegata**: `VITE_AUTH_OBBLIGATORIA=true` su Vercel (fa sparire la - selezione libera del giocatore), poi la migration che rimuove le policy `anon` dalle - tabelle v1.0. È l'unico passo che cambia il comportamento per tutti. +5. **Solo a squadra collegata**: migration `m4_solo_autenticati`, che toglie al ruolo `anon` + l'accesso alle tabelle v1.0. Da lì in poi i dati sono raggiungibili solo con una sessione; + le route in `src/routes/api/public/` usano la service role e continuano a funzionare. Attenzione: dev e produzione condividono lo stesso progetto Supabase. Un account di prova che collega uno slot lo occupa anche in produzione, e va liberato da un admin. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7107d3f..ac21a45 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -46,10 +46,10 @@ docs/ documentazione ufficiale generato) e `client.server.ts` (`supabaseAdmin`, solo server). `types.ts` è generato dallo schema; finché non viene rigenerato, le tabelle introdotte da M1/M2 si usano tramite `client-nuove-tabelle.ts`, con i tipi di riga dichiarati nei moduli di `src/lib/`. -- **Autenticazione**: login Google via Supabase Auth (`src/lib/auth.ts`, DD-011); i permessi - di amministrazione arrivano da `user_roles` (`src/lib/ruoli.ts`). La variabile - `VITE_AUTH_OBBLIGATORIA` decide se `/benvenuto` accetta ancora la selezione libera del - giocatore: finché è spenta, login e vecchio accesso convivono. +- **Autenticazione**: login Google via Supabase Auth (`src/lib/auth.ts`, DD-011). È l'unica + strada di accesso: `__root.tsx` rimanda a `/benvenuto` chi non ha sessione, e l'identità + del giocatore è lo slot di `giocatori_squadra` collegato all'account. I permessi di + amministrazione arrivano solo da `user_roles` (`src/lib/ruoli.ts`). ## Livello dati diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d1311de..f38f9d9 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,9 +11,15 @@ qui: sta in [ROADMAP.md](ROADMAP.md). - Login con Google tramite Supabase Auth (DD-011). Al primo accesso l'account si collega a un giocatore di `giocatori_squadra`, e il collegamento non è più modificabile dal giocatore stesso (DD-016 regola 2). -- I permessi di amministrazione arrivano da `user_roles` (`src/lib/ruoli.ts`) e non più - dalla lista di nomi in `crapp-data.ts`, che resta solo come ponte finché - `VITE_AUTH_OBBLIGATORIA` non viene acceso. +- La selezione libera del giocatore è stata rimossa: `/benvenuto` offre solo l'accesso con + Google e senza sessione non si entra in nessuna schermata. Sparita anche la variabile + `VITE_AUTH_OBBLIGATORIA` (non serve più) e il pulsante «Cambia giocatore» in `/profilo`. +- I permessi di amministrazione arrivano solo da `user_roles` (`src/lib/ruoli.ts`): la lista + di nomi in `crapp-data.ts` è stata eliminata, altrimenti bastava scegliere il nome giusto + per amministrare. +- Migration `m4_solo_autenticati`: toglie al ruolo `anon` l'accesso alle tabelle v1.0. + **Da applicare solo a squadra collegata**, altrimenti chi non ha ancora fatto login vede + l'app vuota. - Profilo giocatore: da `/profilo` ognuno compila i propri dati anagrafici e carica documento, certificato medico e foto tessera con le relative scadenze ([modules/profilo-giocatore.md](modules/profilo-giocatore.md)). diff --git a/docs/TODO.md b/docs/TODO.md index 450677d..8d2839f 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -6,13 +6,11 @@ Solo il lavoro in corso o imminente. L'elenco completo delle funzionalità previ ## In corso - Documentazione tecnica del progetto. -- Autenticazione Google e dashboard amministratore: implementate su `develop`, ma in dev il - login risponde ancora `provider is not enabled` perché il provider Google non è stato - acceso in Supabase. Restano da - fare, in quest'ordine: configurazione del provider Google in Supabase, applicazione delle - migration M2/M3, inserimento del primo admin in `user_roles`, collegamento dei 17 account, - e solo alla fine `VITE_AUTH_OBBLIGATORIA=true` + rimozione delle policy `anon`. - Stato di dettaglio in [PROJECT_STATE.md](../PROJECT_STATE.md). +- Autenticazione Google e dashboard amministratore: il codice è completo su `develop` e il + login è ora l'unica via d'accesso. Restano i passaggi di configurazione, in quest'ordine: + provider Google in Supabase (senza, nessuno entra), migration M2/M3, primo admin in + `user_roles`, collegamento dei 17 account, e infine la migration M4 che chiude gli accessi + `anon`. Stato di dettaglio in [PROJECT_STATE.md](../PROJECT_STATE.md). ## Prossimo diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 307d174..9c90a07 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -3,15 +3,10 @@ import type { Session } from "@supabase/supabase-js"; import { supabase } from "@/integrations/supabase/client"; /** - * Autenticazione reale con Google (DD-011). Il login non ha ancora sostituito la - * selezione del giocatore: finché `VITE_AUTH_OBBLIGATORIA` non è `true`, `/benvenuto` - * offre entrambe le strade, così la produzione continua a funzionare mentre la squadra - * collega gli account. + * Autenticazione reale con Google (DD-011). Il login ha sostituito la selezione del + * giocatore: senza sessione non si entra, e i permessi di amministrazione arrivano solo + * da `user_roles` (vedi `ruoli.ts`). */ -export function authObbligatoria(): boolean { - return import.meta.env["VITE_AUTH_OBBLIGATORIA"] === "true"; -} - export function useSessione() { const [sessione, setSessione] = useState(null); const [pronta, setPronta] = useState(false); diff --git a/src/lib/crapp-data.ts b/src/lib/crapp-data.ts index e91ea33..2890bec 100644 --- a/src/lib/crapp-data.ts +++ b/src/lib/crapp-data.ts @@ -169,10 +169,5 @@ export function formatData(iso: string) { return d.toLocaleDateString("it-IT", { weekday: "short", day: "2-digit", month: "long" }); } -/** Referenti che possono gestire eventi e sollecitare le risposte. */ -export const adminNomi = ["Ivan Cacciari", "Iacopo Ricci", "Cristina Titone"]; - -export function isAdmin(giocatoreId: string) { - const g = giocatori.find((x) => x.id === giocatoreId); - return Boolean(g && adminNomi.includes(g.nome)); -} +/* I permessi di amministrazione stanno in `user_roles` (DD-011), non in una lista di nomi: + vedi `src/lib/ruoli.ts`. */ diff --git a/src/lib/ruoli.ts b/src/lib/ruoli.ts index e37b816..bc1c25c 100644 --- a/src/lib/ruoli.ts +++ b/src/lib/ruoli.ts @@ -1,22 +1,13 @@ import { useQuery } from "@tanstack/react-query"; import { supabase } from "@/integrations/supabase/client"; -import { isAdmin as nomeInListaAdmin } from "./crapp-data"; import { useSessione } from "./auth"; -import { useGiocatoreBase } from "./user-store"; export const RUOLI_KEY = ["ruolo-admin"] as const; /** - * Permessi di amministrazione. La fonte è `user_roles` nel database (DD-011): la lista di - * nomi in `crapp-data.ts` resta solo come ponte per chi non ha ancora collegato l'account, - * e sparisce quando `VITE_AUTH_OBBLIGATORIA` viene acceso in produzione. - * - * ponytail: doppia fonte temporanea, si riduce a `ruoloDb` appena l'auth è obbligatoria. + * Permessi di amministrazione: unica fonte è `user_roles` nel database (DD-011). + * Nessuna lista di nomi, altrimenti basterebbe scegliere il nome giusto per amministrare. */ -export function risolviAdmin(ruoloDb: boolean | null, giocatoreId: string | null): boolean { - if (ruoloDb !== null) return ruoloDb; - return giocatoreId ? nomeInListaAdmin(giocatoreId) : false; -} /** `null` = nessuna sessione, quindi il database non ha una risposta da dare. */ async function fetchRuoloAdmin(utenteId: string | null): Promise { @@ -33,12 +24,11 @@ async function fetchRuoloAdmin(utenteId: string | null): Promise export function useIsAdmin(): boolean { const { utenteId } = useSessione(); - const io = useGiocatoreBase(); // Il ruolo cambia solo quando un admin lo assegna: una lettura per sessione basta. const query = useQuery({ queryKey: [...RUOLI_KEY, utenteId], queryFn: () => fetchRuoloAdmin(utenteId), staleTime: 30 * 60_000, }); - return risolviAdmin(query.data ?? null, io?.id ?? null); + return query.data === true; } diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index ea6c32d..2a8db4d 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -18,6 +18,7 @@ import { CelebrazioneBadge } from "../components/crapp/CelebrazioneBadge"; import { Toaster } from "../components/ui/sonner"; import { TeamLogo } from "../components/crapp/ui-bits"; import { useGiocatoreBase } from "../lib/user-store"; +import { useSessione } from "../lib/auth"; function NotFoundComponent() { return ( @@ -145,17 +146,21 @@ function RootComponent() { const navigate = useNavigate(); const location = useLocation(); const giocatore = useGiocatoreBase(); + const { pronta, utenteId } = useSessione(); const [mounted, setMounted] = useState(false); const isBenvenuto = location.pathname === "/benvenuto"; + // Senza sessione Google non si entra: l'identità la dà il login, non la scelta del nome + // (DD-011). Si aspetta `pronta`, altrimenti il primo render sloggato rimbalzerebbe fuori + // chi ha già la sessione in localStorage. useEffect(() => { setMounted(true); - if (!giocatore && !isBenvenuto) { + if (pronta && (!giocatore || !utenteId) && !isBenvenuto) { navigate({ to: "/benvenuto" }); } - }, [giocatore, isBenvenuto, navigate]); + }, [giocatore, utenteId, pronta, isBenvenuto, navigate]); - if (!mounted) { + if (!mounted || !pronta) { return (
diff --git a/src/routes/benvenuto.tsx b/src/routes/benvenuto.tsx index d4ab6cd..aaab088 100644 --- a/src/routes/benvenuto.tsx +++ b/src/routes/benvenuto.tsx @@ -3,8 +3,7 @@ import { useEffect, useState } from "react"; import { LogIn } from "lucide-react"; import { toast } from "sonner"; import { TeamLogo } from "@/components/crapp/ui-bits"; -import { giocatori } from "@/lib/crapp-data"; -import { accediConGoogle, authObbligatoria, useSessione } from "@/lib/auth"; +import { accediConGoogle, useSessione } from "@/lib/auth"; import { nomeCompleto, slotDi, @@ -13,7 +12,7 @@ import { useGiocatoriSquadra, type GiocatoreSquadra, } from "@/lib/giocatori-squadra"; -import { impostaGiocatore, useGiocatoreCorrente } from "@/lib/user-store"; +import { impostaGiocatore, resetGiocatore, useGiocatoreCorrente } from "@/lib/user-store"; export const Route = createFileRoute("/benvenuto")({ head: () => ({ @@ -65,23 +64,24 @@ function Benvenuto() { const navigate = useNavigate(); const giocatore = useGiocatoreCorrente(); const { pronta, utenteId } = useSessione(); - const { righe } = useGiocatoriSquadra(); + const { righe, daDatabase } = useGiocatoriSquadra(); const collega = useCollegaGiocatore(); const [inCorso, setInCorso] = useState(false); const mioSlot = slotDi(righe, utenteId); - // Con l'auth obbligatoria si entra solo da loggati; finché non lo è, la selezione - // diretta resta come ponte per chi non ha ancora collegato l'account (DD-011). - const puoEntrare = !!giocatore && (!!utenteId || !authObbligatoria()); + // Si entra solo da loggati e con uno slot collegato (DD-011). + const puoEntrare = !!giocatore && !!utenteId; useEffect(() => { if (puoEntrare) navigate({ to: "/" }); }, [puoEntrare, navigate]); - // L'account è già collegato a uno slot: nessuna scelta da fare. + // Chi sei lo dice lo slot collegato all'account, non quello che c'è in localStorage: + // senza slot la scelta salvata dalla vecchia selezione libera va buttata. useEffect(() => { if (mioSlot) impostaGiocatore(mioSlot.id); - }, [mioSlot]); + else if (utenteId && daDatabase) resetGiocatore(); + }, [mioSlot, utenteId, daDatabase]); async function accedi() { setInCorso(true); @@ -125,25 +125,6 @@ function Benvenuto() { > Accedi con Google - - {authObbligatoria() ? null : ( -
-

- Oppure entra scegliendo il tuo nome, come prima. -

-
- {giocatori.map((g) => ( - impostaGiocatore(g.id)} - /> - ))} -
-
- )} ) : ( <> diff --git a/src/routes/profilo.tsx b/src/routes/profilo.tsx index 91fafa2..4887b73 100644 --- a/src/routes/profilo.tsx +++ b/src/routes/profilo.tsx @@ -1,7 +1,7 @@ import { createFileRoute, Link } from "@tanstack/react-router"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; -import { Flame, Camera, Users, Trash2, Bell, LogOut, ShieldCheck } from "lucide-react"; +import { Flame, Camera, Trash2, Bell, LogOut, ShieldCheck } from "lucide-react"; import { cn } from "@/lib/utils"; import { PageHeader, Section, StatTile } from "@/components/crapp/ui-bits"; import { Avatar } from "@/components/crapp/Avatar"; @@ -19,7 +19,7 @@ import { statoNotifiche, } from "@/lib/push-client"; import { resetGiocatore } from "@/lib/user-store"; -import { esci, useSessione } from "@/lib/auth"; +import { esci } from "@/lib/auth"; import { useIsAdmin } from "@/lib/ruoli"; import { Reveal } from "@/components/motion/Reveal"; @@ -42,7 +42,6 @@ function Profilo() { const votiSocial = useVotiSocial(); const g = useIo(); const admin = useIsAdmin(); - const { sessione } = useSessione(); const ultimoMese = usePresenzeUltimoMese(g?.id); const inputRef = useRef(null); const foto = useAvatar(g?.id); @@ -230,22 +229,12 @@ function Profilo() { ) : null} - {sessione ? ( - - ) : null}
diff --git a/supabase/migrations/20260831120000_m4_solo_autenticati.sql b/supabase/migrations/20260831120000_m4_solo_autenticati.sql new file mode 100644 index 0000000..6ca74c7 --- /dev/null +++ b/supabase/migrations/20260831120000_m4_solo_autenticati.sql @@ -0,0 +1,19 @@ +-- M4 — Chiusura degli accessi anonimi alle tabelle v1.0 (DD-011). +-- +-- Da applicare SOLO quando tutti hanno collegato l'account Google: da qui in poi il +-- ruolo `anon` non legge né scrive più nulla, quindi chi non ha fatto login vede l'app +-- vuota. Le route in `src/routes/api/public/` usano la service role e non sono toccate. +-- +-- Le policy restano dichiarate `TO anon, authenticated`: senza GRANT il ruolo anon non +-- arriva comunque alla tabella, e le policy continuano a valere per gli autenticati. + +REVOKE ALL ON public.turni_palloni FROM anon; +REVOKE ALL ON public.push_subscriptions FROM anon; +REVOKE ALL ON public.badge_social_voti FROM anon; +REVOKE ALL ON public.scout_sessioni FROM anon; +REVOKE ALL ON public.scout_live FROM anon; +REVOKE ALL ON public.mvp_voti FROM anon; +REVOKE ALL ON public.risposte_presenze FROM anon; +REVOKE ALL ON public.eventi_app FROM anon; +REVOKE ALL ON public.pagelle_voti FROM anon; +REVOKE ALL ON public.cacche_partita FROM anon; diff --git a/test/unit/crapp-data.test.ts b/test/unit/crapp-data.test.ts index 8092981..c83b0a0 100644 --- a/test/unit/crapp-data.test.ts +++ b/test/unit/crapp-data.test.ts @@ -1,14 +1,6 @@ /** Check dei dati di base della rosa: `bun test/unit/crapp-data.test.ts`. */ import assert from "node:assert/strict"; -import { - adminNomi, - classifica, - formatData, - giocatori, - isAdmin, - statoMeta, - storicoMatch, -} from "@/lib/crapp-data"; +import { classifica, formatData, giocatori, statoMeta, storicoMatch } from "@/lib/crapp-data"; // --- rosa -------------------------------------------------------------------- assert.ok(giocatori.length > 0, "la rosa non è vuota"); @@ -32,17 +24,6 @@ for (const g of giocatori) { assert.ok(g.presenze <= g.totaliEventi, `${g.id}: presenze mai oltre gli eventi totali`); } -// --- amministratori ---------------------------------------------------------- -for (const nome of adminNomi) { - const admin = giocatori.find((g) => g.nome === nome); - assert.ok(admin, `l'amministratore ${nome} è in rosa`); - assert.equal(isAdmin(admin.id), true); -} -const nonAdmin = giocatori.find((g) => !adminNomi.includes(g.nome))!; -assert.equal(isAdmin(nonAdmin.id), false); -assert.equal(isAdmin("g999"), false, "un id inesistente non è amministratore"); -assert.equal(isAdmin(""), false); - // --- formatData -------------------------------------------------------------- assert.equal(formatData("2026-09-01"), "mar 01 settembre"); assert.equal(formatData("2026-01-31"), "sab 31 gennaio"); diff --git a/test/unit/ruoli.test.ts b/test/unit/ruoli.test.ts deleted file mode 100644 index 90d80df..0000000 --- a/test/unit/ruoli.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** Check dei permessi di amministrazione: `bun test/unit/ruoli.test.ts`. */ -import assert from "node:assert/strict"; -import { risolviAdmin } from "@/lib/ruoli"; -import { adminNomi, giocatori } from "@/lib/crapp-data"; - -const referente = giocatori.find((g) => adminNomi.includes(g.nome))!; -const chiunque = giocatori.find((g) => !adminNomi.includes(g.nome))!; - -// Con una sessione attiva decide il database, e basta: la lista di nomi non conta più. -assert.equal(risolviAdmin(true, chiunque.id), true, "il ruolo nel database concede"); -assert.equal( - risolviAdmin(false, referente.id), - false, - "il ruolo nel database nega anche a chi è nella lista dei nomi", -); -assert.equal(risolviAdmin(true, null), true); - -// Senza sessione (null) resta il ponte temporaneo sulla lista di nomi. -assert.equal(risolviAdmin(null, referente.id), true); -assert.equal(risolviAdmin(null, chiunque.id), false); -assert.equal(risolviAdmin(null, null), false, "nessuna identità, nessun permesso"); -assert.equal(risolviAdmin(null, "gXX"), false, "un id sconosciuto non concede nulla"); - -console.log("ruoli: ok");