DD-011: make Google login the only way in

Remove the free player selection from /benvenuto: without a Supabase session
no screen renders, and the VITE_AUTH_OBBLIGATORIA bridge flag is gone.
Admin rights now come only from user_roles, so the hardcoded name list in
crapp-data.ts is deleted along with its tests.

Add migration m4_solo_autenticati, which revokes anon access to the v1.0
tables. Apply it only once the whole team has linked an account.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-31 18:21:34 +02:00
co-authored by Claude Opus 5
parent f325c0485c
commit 1036eb860d
13 changed files with 78 additions and 143 deletions
+3 -8
View File
@@ -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<Session | null>(null);
const [pronta, setPronta] = useState(false);
+2 -7
View File
@@ -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`. */
+3 -13
View File
@@ -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<boolean | null> {
@@ -33,12 +24,11 @@ async function fetchRuoloAdmin(utenteId: string | null): Promise<boolean | null>
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;
}
+8 -3
View File
@@ -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 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 (
<div className="grid min-h-screen place-items-center bg-background">
<TeamLogo className="h-16 w-16 animate-pulse" />
+9 -28
View File
@@ -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() {
>
<LogIn className="h-4 w-4" /> Accedi con Google
</button>
{authObbligatoria() ? null : (
<div className="mt-10 w-full max-w-sm">
<p className="mb-3 text-center text-xs text-muted-foreground">
Oppure entra scegliendo il tuo nome, come prima.
</p>
<div className="space-y-2">
{giocatori.map((g) => (
<Scheda
key={g.id}
titolo={g.nome}
sottotitolo={`#${g.numero} · ${g.ruolo}`}
iniziali={g.iniziali}
onClick={() => impostaGiocatore(g.id)}
/>
))}
</div>
</div>
)}
</>
) : (
<>
+5 -16
View File
@@ -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<HTMLInputElement>(null);
const foto = useAvatar(g?.id);
@@ -230,22 +229,12 @@ function Profilo() {
) : null}
<button
type="button"
onClick={() => resetGiocatore()}
onClick={logout}
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
>
<span className="min-w-0 truncate">Cambia giocatore</span>
<Users className="h-4 w-4 text-muted-foreground" />
<span className="min-w-0 truncate">Esci</span>
<LogOut className="h-4 w-4 text-muted-foreground" />
</button>
{sessione ? (
<button
type="button"
onClick={logout}
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
>
<span className="min-w-0 truncate">Esci</span>
<LogOut className="h-4 w-4 text-muted-foreground" />
</button>
) : null}
</div>
</Section>
</>