Sposta avatar e documenti profilo su PocketBase (file field)

Sostituisce i due bucket Supabase Storage con campi file PocketBase:

- avatar-giocatori: nuova collection avatar_giocatori separata da
  giocatori_squadra, perché la policy originale ("chiunque autenticato
  carica/sostituisce/elimina qualsiasi avatar, nessun controllo
  proprietario") è più permissiva delle regole di giocatori_squadra —
  mescolarle avrebbe indebolito le une o bloccato l'altra. File pubblico,
  come il bucket originale;
- profili-giocatore: campi file su profili_giocatore stesso, con
  protected: true (vedi sotto).

Trovato e corretto un problema di sicurezza reale: PocketBase rende i
file pubblici di default (si affida solo alla casualità del nome file),
a meno di impostare esplicitamente protected: true sul campo — i
documenti d'identità e i certificati medici sarebbero stati raggiungibili
senza autenticazione, in violazione di DD-016 regola 4. Verificato che
ora servono un token valido (404 senza, 200 con).

La scrittura dei campi testuali del profilo e il caricamento dei file
sono due operazioni separate (PocketBase rifiuta una stringa dove si
aspetta un file): verificato che caricare un documento non cancella i
dati testuali già salvati.

Rimossi da profili-core.ts RigaProfilo/daRigaProfilo/aRigaProfilo
(shape Postgres non più usata da nessun modulo di produzione) e
rimuoviFile (mai chiamato da nessun componente). Avatar.tsx non usa più
un URL deterministico per giocatore: PocketBase genera nomi file
casuali, quindi legge la mappa avatar tramite una query React Query
condivisa tra tutte le istanze del componente.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 14:58:54 +02:00
co-authored by Claude Sonnet 5
parent d125bdfb6a
commit c1744d4087
10 changed files with 229 additions and 198 deletions
+9 -3
View File
@@ -338,19 +338,21 @@ function Documento({
label,
stato,
path,
recordId,
}: {
icona: React.ReactNode;
label: string;
stato: StatoScadenza | "presente" | "assente";
path: string | null;
recordId: string | null;
}) {
const [inCorso, setInCorso] = useState(false);
async function scarica() {
if (!path || inCorso) return;
if (!path || !recordId || inCorso) return;
setInCorso(true);
try {
await scaricaFile(path);
await scaricaFile(recordId, path);
} catch (error) {
toast.error(error instanceof Error ? error.message : "Download non riuscito");
} finally {
@@ -362,7 +364,7 @@ function Documento({
<button
type="button"
onClick={scarica}
disabled={!path || inCorso}
disabled={!path || !recordId || inCorso}
className={cn(
"premi flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold uppercase disabled:opacity-60",
statoClasse[stato],
@@ -431,24 +433,28 @@ function SchedaGiocatore({
label="Doc fronte"
stato={fronte}
path={profilo?.documentoFrontePath ?? null}
recordId={profilo?.id ?? null}
/>
<Documento
icona={<IdCard className="h-3.5 w-3.5" />}
label="Doc retro"
stato={retro}
path={profilo?.documentoRetroPath ?? null}
recordId={profilo?.id ?? null}
/>
<Documento
icona={<FileText className="h-3.5 w-3.5" />}
label="Certificato"
stato={certificato}
path={profilo?.certificatoPath ?? null}
recordId={profilo?.id ?? null}
/>
<Documento
icona={<Image className="h-3.5 w-3.5" />}
label="Foto"
stato={sezioni.foto ? "presente" : "assente"}
path={profilo?.fotoPath ?? null}
recordId={profilo?.id ?? null}
/>
<span
className={cn(