Sostituisco l'admin per nome con un login vero (account Strapi)
Le azioni da referente (/scout, /eventi, sollecito presenze, export CSV scout) non dipendono più da quale giocatore hai scelto di essere in /benvenuto (chiunque poteva scegliere "Ivan Cacciari" senza password), ma da un login reale contro l'account admin di Strapi (già usato per gestire rosa/classifica/storico), verificato via /admin/login. Nuovi src/lib/admin-auth.ts (loginAdmin/logoutAdmin/useAdminSession, sessione locale di 8h) e src/components/crapp/AdminLogin.tsx (form riutilizzabile), raggiungibile da /profilo e dalle schermate riservate. Rimossi adminNomi/ isAdmin da crapp-data.ts. Resta un limite noto: questo protegge solo la UI dell'app, le tabelle Supabase coinvolte hanno ancora RLS aperta e restano scrivibili in diretto con la chiave anon — un fix più ampio (Supabase Auth + RLS reale) è documentato ma non ancora fatto. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import { useState } from "react";
|
||||
import { Loader2, Lock } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { loginAdmin, logoutAdmin, useAdminSession } from "@/lib/admin-auth";
|
||||
|
||||
/** Sblocca le funzioni admin (scout, gestione eventi, sollecito presenze) con la password
|
||||
* dell'account Strapi dei referenti — non più scegliendo semplicemente il proprio nome. */
|
||||
export function AdminLogin() {
|
||||
const { attivo, email } = useAdminSession();
|
||||
const [inputEmail, setInputEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [inCorso, setInCorso] = useState(false);
|
||||
|
||||
if (attivo) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 rounded-2xl bg-secondary/60 p-3">
|
||||
<span className="min-w-0 text-sm">
|
||||
<span className="block font-semibold">Admin attivo</span>
|
||||
<span className="block truncate text-[11px] text-muted-foreground">{email}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
logoutAdmin();
|
||||
toast.success("Uscito dalla modalità admin");
|
||||
}}
|
||||
className="shrink-0 rounded-full bg-card px-3 py-1.5 text-[11px] font-bold uppercase text-foreground shadow-card"
|
||||
>
|
||||
Esci
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function accedi(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!inputEmail || !password || inCorso) return;
|
||||
setInCorso(true);
|
||||
const risultato = await loginAdmin(inputEmail, password);
|
||||
setInCorso(false);
|
||||
if (risultato.ok) {
|
||||
setPassword("");
|
||||
toast.success("Modalità admin attiva");
|
||||
} else {
|
||||
toast.error(risultato.errore);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={accedi} className="space-y-2 rounded-2xl bg-secondary/60 p-3">
|
||||
<p className="inline-flex items-center gap-1.5 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<Lock className="h-3.5 w-3.5" /> Accedi come admin (account Strapi)
|
||||
</p>
|
||||
<input
|
||||
type="email"
|
||||
value={inputEmail}
|
||||
onChange={(e) => setInputEmail(e.target.value)}
|
||||
placeholder="Email"
|
||||
autoComplete="username"
|
||||
className="w-full rounded-xl border border-border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Password"
|
||||
autoComplete="current-password"
|
||||
className="w-full rounded-xl border border-border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={inCorso}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-xl bg-accent-grad py-2.5 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||
>
|
||||
{inCorso ? <Loader2 className="h-4 w-4 animate-spin" /> : null} Accedi
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,9 @@ import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar } from "@/components/crapp/Avatar";
|
||||
import { Barra } from "@/components/motion/Barra";
|
||||
import { isAdmin, statoMeta, type Giocatore, type Stato } from "@/lib/crapp-data";
|
||||
import { statoMeta, type Giocatore, type Stato } from "@/lib/crapp-data";
|
||||
import { useRosaBase } from "@/lib/rosa-base";
|
||||
import { useAdminSession } from "@/lib/admin-auth";
|
||||
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
|
||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
||||
|
||||
@@ -15,6 +16,7 @@ export function RosaPresenze({ eventoId }: { eventoId: string }) {
|
||||
const { risposte, isPending } = usePresenzeEvento(eventoId);
|
||||
const salva = useSalvaPresenza();
|
||||
const io = useGiocatoreCorrente();
|
||||
const admin = useAdminSession();
|
||||
const giocatori = useRosaBase();
|
||||
const [sollecito, setSollecito] = useState(false);
|
||||
|
||||
@@ -108,7 +110,7 @@ export function RosaPresenze({ eventoId }: { eventoId: string }) {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{io && isAdmin(io) ? (
|
||||
{io && admin.attivo ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={sollecita}
|
||||
|
||||
@@ -3,27 +3,36 @@ import { ChevronRight, Lock, Radio } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { sessioneScaduta, usePartitaDiOggi, useSessioneScout } from "@/lib/scout-live";
|
||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
||||
import { isAdmin } from "@/lib/crapp-data";
|
||||
import { useAdminSession } from "@/lib/admin-auth";
|
||||
|
||||
/** Accesso allo scout live: attivo solo il giorno della partita e se nessun altro lo sta usando. */
|
||||
export function ScoutEntry({ variante = "grande" }: { variante?: "grande" | "compatto" }) {
|
||||
const { pronto, partita } = usePartitaDiOggi();
|
||||
const io = useGiocatoreCorrente();
|
||||
const admin = useAdminSession();
|
||||
const { data: sessione } = useSessioneScout(partita?.id ?? null);
|
||||
|
||||
// Strumento tecnico: solo i referenti/allenatori scoutizzano la partita.
|
||||
const abilitato = !!io && isAdmin(io);
|
||||
// Strumento tecnico: solo chi ha effettuato l'accesso admin scoutizza la partita.
|
||||
const abilitato = admin.attivo;
|
||||
|
||||
const attiva = sessione && !sessioneScaduta(sessione) ? sessione : null;
|
||||
const occupato = !!attiva && attiva.giocatore_id !== io?.id;
|
||||
const disponibile = abilitato && pronto && !!partita && !occupato;
|
||||
|
||||
const titolo = !abilitato ? "Scout live" : !partita ? "Scout live non attivo" : occupato ? "Scout occupato" : "Scout live";
|
||||
const sottotitolo = !abilitato ? "Riservato ad allenatori e referenti" : !partita
|
||||
? "Si attiva il giorno della partita"
|
||||
: occupato
|
||||
? `In uso da ${attiva!.giocatore_nome}`
|
||||
: "Segna punti, ace e muri in tempo reale";
|
||||
const titolo = !abilitato
|
||||
? "Scout live"
|
||||
: !partita
|
||||
? "Scout live non attivo"
|
||||
: occupato
|
||||
? "Scout occupato"
|
||||
: "Scout live";
|
||||
const sottotitolo = !abilitato
|
||||
? "Riservato ad allenatori e referenti"
|
||||
: !partita
|
||||
? "Si attiva il giorno della partita"
|
||||
: occupato
|
||||
? `In uso da ${attiva!.giocatore_nome}`
|
||||
: "Segna punti, ace e muri in tempo reale";
|
||||
|
||||
const contenuto = (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
/**
|
||||
* "Sei admin" ora si verifica con una password vera (l'account Strapi dei referenti), non più
|
||||
* scegliendo semplicemente il proprio nome dalla rosa: chi non conosce la password non sblocca
|
||||
* /scout, /eventi, il sollecito presenze né l'export CSV dello scout. Nota: questo protegge solo
|
||||
* la UI dell'app — le tabelle Supabase restano con RLS aperta come oggi, un fix separato.
|
||||
*/
|
||||
const KEY = "crapp-admin-session-v1";
|
||||
const DURATA_ORE = 8;
|
||||
|
||||
type Sessione = { email: string; scadeIl: number };
|
||||
|
||||
let cache: Sessione | null | undefined = undefined;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function leggi(): Sessione | null {
|
||||
if (cache === undefined) {
|
||||
if (typeof window === "undefined") {
|
||||
cache = null;
|
||||
} else {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(KEY);
|
||||
cache = raw ? (JSON.parse(raw) as Sessione) : null;
|
||||
} catch {
|
||||
cache = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cache && cache.scadeIl < Date.now()) cache = null;
|
||||
return cache;
|
||||
}
|
||||
|
||||
function scrivi(next: Sessione | null) {
|
||||
cache = next;
|
||||
try {
|
||||
if (next) window.localStorage.setItem(KEY, JSON.stringify(next));
|
||||
else window.localStorage.removeItem(KEY);
|
||||
} catch {
|
||||
/* storage non disponibile */
|
||||
}
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
function strapiUrl(): string {
|
||||
const url = import.meta.env["VITE_STRAPI_URL"];
|
||||
if (!url) throw new Error("Manca la variabile d'ambiente VITE_STRAPI_URL.");
|
||||
return url;
|
||||
}
|
||||
|
||||
export async function loginAdmin(
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<{ ok: true } | { ok: false; errore: string }> {
|
||||
try {
|
||||
const res = await fetch(`${strapiUrl()}/admin/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
if (!res.ok) return { ok: false, errore: "Email o password errate" };
|
||||
scrivi({ email, scadeIl: Date.now() + DURATA_ORE * 60 * 60_000 });
|
||||
return { ok: true };
|
||||
} catch {
|
||||
return { ok: false, errore: "CMS non raggiungibile, riprova" };
|
||||
}
|
||||
}
|
||||
|
||||
export function logoutAdmin() {
|
||||
scrivi(null);
|
||||
}
|
||||
|
||||
export function useAdminSession(): { attivo: boolean; email: string | null } {
|
||||
const sessione = useSyncExternalStore(
|
||||
(cb) => {
|
||||
listeners.add(cb);
|
||||
return () => listeners.delete(cb);
|
||||
},
|
||||
() => leggi(),
|
||||
() => null,
|
||||
);
|
||||
return { attivo: !!sessione, email: sessione?.email ?? null };
|
||||
}
|
||||
@@ -76,10 +76,3 @@ export function formatData(iso: string) {
|
||||
const d = new Date(iso + "T00:00:00");
|
||||
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(giocatore: Pick<Giocatore, "nome"> | null | undefined) {
|
||||
return Boolean(giocatore && adminNomi.includes(giocatore.nome));
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@ import { Link } from "@tanstack/react-router";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard";
|
||||
import { PageHeader, Section } from "@/components/crapp/ui-bits";
|
||||
import { isAdmin } from "@/lib/crapp-data";
|
||||
import { compleanniEventi, useEventi, type Evento } from "@/lib/eventi";
|
||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
||||
import { useRosaBase } from "@/lib/rosa-base";
|
||||
import { useAdminSession } from "@/lib/admin-auth";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
@@ -98,6 +98,7 @@ function Calendario() {
|
||||
const [giornoSelezionato, setGiornoSelezionato] = useState<number | null>(null);
|
||||
const [drawerAperto, setDrawerAperto] = useState(false);
|
||||
const io = useGiocatoreCorrente();
|
||||
const admin = useAdminSession();
|
||||
const giocatori = useRosaBase();
|
||||
const { eventi } = useEventi();
|
||||
const { anno, mese, precedente, successivo } = useMeseNav();
|
||||
@@ -252,7 +253,7 @@ function Calendario() {
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{io && isAdmin(io) ? (
|
||||
{io && admin.attivo ? (
|
||||
<div className="px-5 pt-4">
|
||||
<Link
|
||||
to="/eventi"
|
||||
|
||||
@@ -4,8 +4,10 @@ import { ArrowLeft, CalendarPlus, Loader2, Pencil, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PageHeader, Section } from "@/components/crapp/ui-bits";
|
||||
import { formatData, isAdmin } from "@/lib/crapp-data";
|
||||
import { formatData } from "@/lib/crapp-data";
|
||||
import { useRosaBase } from "@/lib/rosa-base";
|
||||
import { useAdminSession } from "@/lib/admin-auth";
|
||||
import { AdminLogin } from "@/components/crapp/AdminLogin";
|
||||
import {
|
||||
categoriaEvento,
|
||||
daCategoria,
|
||||
@@ -48,20 +50,22 @@ const tipi: Array<{ id: CategoriaEvento; label: string }> = [
|
||||
|
||||
function GestioneEventi() {
|
||||
const io = useGiocatoreCorrente();
|
||||
const admin = useAdminSession();
|
||||
const giocatori = useRosaBase();
|
||||
const { eventi, isPending } = useEventi();
|
||||
const salva = useSalvaEvento();
|
||||
const elimina = useEliminaEvento();
|
||||
const [bozza, setBozza] = useState<Evento | null>(null);
|
||||
|
||||
if (!io || !isAdmin(io)) {
|
||||
if (!io || !admin.attivo) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader titolo="Gestione eventi" sottotitolo="Area riservata" />
|
||||
<div className="px-5">
|
||||
<div className="px-5 space-y-3">
|
||||
<p className="rounded-3xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
|
||||
Solo i referenti della squadra possono creare o modificare gli eventi.
|
||||
</p>
|
||||
<AdminLogin />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,8 @@ import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { ArrowLeft, MapPin, Clock, Users, Trophy, Swords, Download } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PageHeader, Section } from "@/components/crapp/ui-bits";
|
||||
import { formatData, isAdmin } from "@/lib/crapp-data";
|
||||
import { formatData } from "@/lib/crapp-data";
|
||||
import { useAdminSession } from "@/lib/admin-auth";
|
||||
import { convocatiEvento, useEvento } from "@/lib/eventi";
|
||||
import { Pagelle } from "@/components/crapp/Pagelle";
|
||||
import { SondaggioCacche } from "@/components/crapp/SondaggioCacche";
|
||||
@@ -44,6 +45,7 @@ function PartitaDetail() {
|
||||
const { id } = Route.useParams();
|
||||
const { evento } = useEvento(id);
|
||||
const io = useGiocatoreCorrente();
|
||||
const admin = useAdminSession();
|
||||
const giocatori = useRosaBase();
|
||||
const storicoMatch = useStoricoMatch();
|
||||
const scoutMatches = useScoutMatches();
|
||||
@@ -240,7 +242,7 @@ function PartitaDetail() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{io && isAdmin(io) ? (
|
||||
{io && admin.attivo ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
|
||||
+27
-12
@@ -19,6 +19,7 @@ import {
|
||||
} from "@/lib/push-client";
|
||||
import { resetGiocatore } from "@/lib/user-store";
|
||||
import { Reveal } from "@/components/motion/Reveal";
|
||||
import { AdminLogin } from "@/components/crapp/AdminLogin";
|
||||
|
||||
export const Route = createFileRoute("/profilo")({
|
||||
head: () => ({
|
||||
@@ -29,7 +30,10 @@ export const Route = createFileRoute("/profilo")({
|
||||
content: "Foto, ruolo, statistiche personali, badge e obiettivi del giocatore CRAP Volley.",
|
||||
},
|
||||
{ property: "og:title", content: "Profilo giocatore — CrAPP" },
|
||||
{ property: "og:description", content: "Statistiche personali, badge e presenze della stagione." },
|
||||
{
|
||||
property: "og:description",
|
||||
content: "Statistiche personali, badge e presenze della stagione.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
component: Profilo,
|
||||
@@ -47,7 +51,9 @@ function Profilo() {
|
||||
|
||||
useEffect(() => {
|
||||
setSupportate(pushSupportato());
|
||||
statoNotifiche().then(setNotifiche).catch(() => setNotifiche(false));
|
||||
statoNotifiche()
|
||||
.then(setNotifiche)
|
||||
.catch(() => setNotifiche(false));
|
||||
}, []);
|
||||
|
||||
if (!g) return null;
|
||||
@@ -189,20 +195,25 @@ function Profilo() {
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-8 w-8 shrink-0 place-items-center rounded-xl",
|
||||
notifiche ? "bg-accent-grad text-accent-foreground" : "bg-secondary text-muted-foreground",
|
||||
notifiche
|
||||
? "bg-accent-grad text-accent-foreground"
|
||||
: "bg-secondary text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
</span>
|
||||
</button>
|
||||
{["Notifiche convocazioni", "Promemoria allenamenti", "Cambi orario", "Bacheca squadra"].map(
|
||||
(v) => (
|
||||
<label key={v} className="flex items-center justify-between gap-3 px-4 py-3 text-sm">
|
||||
<span className="min-w-0 truncate">{v}</span>
|
||||
<input type="checkbox" defaultChecked className="h-5 w-9 accent-[var(--accent)]" />
|
||||
</label>
|
||||
),
|
||||
)}
|
||||
{[
|
||||
"Notifiche convocazioni",
|
||||
"Promemoria allenamenti",
|
||||
"Cambi orario",
|
||||
"Bacheca squadra",
|
||||
].map((v) => (
|
||||
<label key={v} className="flex items-center justify-between gap-3 px-4 py-3 text-sm">
|
||||
<span className="min-w-0 truncate">{v}</span>
|
||||
<input type="checkbox" defaultChecked className="h-5 w-9 accent-[var(--accent)]" />
|
||||
</label>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resetGiocatore()}
|
||||
@@ -213,6 +224,10 @@ function Profilo() {
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section titolo="Area admin">
|
||||
<AdminLogin />
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+10
-3
@@ -3,8 +3,10 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Undo2, Save, CheckCircle2, Radio, Lock, CalendarX2, LogOut } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatData, isAdmin } from "@/lib/crapp-data";
|
||||
import { formatData } from "@/lib/crapp-data";
|
||||
import { useRosaBase } from "@/lib/rosa-base";
|
||||
import { useAdminSession } from "@/lib/admin-auth";
|
||||
import { AdminLogin } from "@/components/crapp/AdminLogin";
|
||||
import type { Evento } from "@/lib/eventi";
|
||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
||||
import { usePresenzeEvento } from "@/lib/presenze";
|
||||
@@ -82,6 +84,7 @@ function Blocco({
|
||||
function Scout() {
|
||||
const { pronto, partita } = usePartitaDiOggi();
|
||||
const io = useGiocatoreCorrente();
|
||||
const admin = useAdminSession();
|
||||
const sessione = useSessioneScout(partita?.id ?? null);
|
||||
const statoSalvato = useStatoScout(partita?.id ?? null);
|
||||
const apri = useApriSessioneScout();
|
||||
@@ -100,13 +103,17 @@ function Scout() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [controllo, partita?.id, io?.id]);
|
||||
|
||||
if (io && !isAdmin(io)) {
|
||||
if (!admin.attivo) {
|
||||
return (
|
||||
<Blocco
|
||||
icona={<Lock className="h-6 w-6" />}
|
||||
titolo="Scout riservato"
|
||||
testo="Lo scout live è uno strumento tecnico per allenatori e referenti della squadra."
|
||||
/>
|
||||
>
|
||||
<div className="mt-5 text-left">
|
||||
<AdminLogin />
|
||||
</div>
|
||||
</Blocco>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user