DD-018: collegamento automatico giocatore-account per email
Al primo accesso l'app collega da sola l'account Google al giocatore la cui email registrata coincide (case-insensitive), invece di far scegliere il nome da un elenco. Senza corrispondenza compare solo un messaggio d'errore con un pulsante per uscire e riprovare con un altro account: nessuna scelta manuale di ripiego. - Migration m5: colonna `email` su giocatori_squadra, seed per Ivan Cacciari e Davide Grilli, trigger esteso per richiedere anche il match email oltre allo slot libero. - slotPerEmail() sostituisce slotLiberi() (rimossa, senza più chiamanti in produzione). Co-Authored-By: Claude Mythos <noreply@anthropic.com>
This commit is contained in:
+9
-3
@@ -36,7 +36,9 @@ admin) implementati su `develop`, da attivare in produzione seguendo i passaggi
|
|||||||
## Database
|
## Database
|
||||||
|
|
||||||
- Schema v1.0 + M1 applicati al nuovo Supabase
|
- Schema v1.0 + M1 applicati al nuovo Supabase
|
||||||
- `public.giocatori_squadra`: 17 giocatori iniziali presenti
|
- `public.giocatori_squadra`: 17 giocatori iniziali presenti; solo 2 hanno l'email
|
||||||
|
registrata (`email`, migration `m5_email_giocatori_squadra`, DD-018) — le altre 15
|
||||||
|
arriveranno con una migration futura
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -86,8 +88,12 @@ Passaggi in ordine, nessuno dei quali è reversibile a metà:
|
|||||||
produzione senza toccare il comportamento attuale.
|
produzione senza toccare il comportamento attuale.
|
||||||
3. **Primo admin**, dopo il primo login (l'ID esiste solo da quel momento):
|
3. **Primo admin**, dopo il primo login (l'ID esiste solo da quel momento):
|
||||||
`INSERT INTO public.user_roles (user_id, role) SELECT id, 'admin' FROM auth.users WHERE email = '<mail>';`
|
`INSERT INTO public.user_roles (user_id, role) SELECT id, 'admin' FROM auth.users WHERE email = '<mail>';`
|
||||||
4. **Collegamento dei 17 account**: ciascuno accede con Google e sceglie il proprio nome una
|
4. **Collegamento dei 17 account**: ciascuno accede con Google e viene collegato in
|
||||||
volta sola. Uno slot già collegato può essere liberato solo da un admin.
|
automatico al proprio giocatore per email (DD-018, migration
|
||||||
|
`m5_email_giocatori_squadra`) — nessuna scelta manuale. Finché l'email di un giocatore
|
||||||
|
non è impostata (oggi solo 2 dei 17 la hanno), il suo accesso mostra un errore e va
|
||||||
|
sbloccato aggiungendo l'email con una nuova migration. Uno slot già collegato può essere
|
||||||
|
liberato solo da un admin.
|
||||||
5. **Solo a squadra collegata**: migration `m4_solo_autenticati`, che toglie al ruolo `anon`
|
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;
|
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.
|
le route in `src/routes/api/public/` usano la service role e continuano a funzionare.
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ qui: sta in [ROADMAP.md](ROADMAP.md).
|
|||||||
di nomi in `crapp-data.ts` è stata eliminata, altrimenti bastava scegliere il nome giusto
|
di nomi in `crapp-data.ts` è stata eliminata, altrimenti bastava scegliere il nome giusto
|
||||||
per amministrare.
|
per amministrare.
|
||||||
- Migration `m4_solo_autenticati`: toglie al ruolo `anon` l'accesso alle tabelle v1.0.
|
- Migration `m4_solo_autenticati`: toglie al ruolo `anon` l'accesso alle tabelle v1.0.
|
||||||
|
- Collegamento automatico al proprio giocatore per email (DD-018, migration
|
||||||
|
`m5_email_giocatori_squadra`): niente più scelta manuale da un elenco, `/benvenuto`
|
||||||
|
confronta l'email dell'account Google con `giocatori_squadra.email` e collega da solo.
|
||||||
|
Senza corrispondenza compare solo un messaggio d'errore, con un pulsante per uscire e
|
||||||
|
riprovare con un altro account.
|
||||||
**Da applicare solo a squadra collegata**, altrimenti chi non ha ancora fatto login vede
|
**Da applicare solo a squadra collegata**, altrimenti chi non ha ancora fatto login vede
|
||||||
l'app vuota.
|
l'app vuota.
|
||||||
- Profilo giocatore: da `/profilo` ognuno compila i propri dati anagrafici e carica
|
- Profilo giocatore: da `/profilo` ognuno compila i propri dati anagrafici e carica
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@ non in questo file.
|
|||||||
|
|
||||||
| Tabella | Scopo | Note |
|
| Tabella | Scopo | Note |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `giocatori_squadra` | Anagrafica operativa della squadra, con ID testuali (`g1`…`gN`), dati gestiti dagli admin (nome, cognome, numero, ruolo) e collegamento all'account (`auth_user_id`). | Introdotta dalla migration `m1_giocatori_squadra`, già popolata (17 giocatori) ma **non ancora letta dal codice**: la rosa arriva tuttora da `src/lib/crapp-data.ts`, che resta il fallback anche dopo il passaggio. Destinata a diventare la source of truth. Vedi DD-015 e DD-016. |
|
| `giocatori_squadra` | Anagrafica operativa della squadra, con ID testuali (`g1`…`gN`), dati gestiti dagli admin (nome, cognome, numero, ruolo), collegamento all'account (`auth_user_id`) ed email registrata (`email`). | Introdotta dalla migration `m1_giocatori_squadra`, già popolata (17 giocatori) ma **non ancora letta dal codice**: la rosa arriva tuttora da `src/lib/crapp-data.ts`, che resta il fallback anche dopo il passaggio. Destinata a diventare la source of truth. Vedi DD-015 e DD-016. La colonna `email` (migration `m5_email_giocatori_squadra`) è la chiave del collegamento automatico account↔giocatore al primo accesso (DD-018): NULL finché non nota, oggi impostata solo per 2 dei 17 giocatori. |
|
||||||
| `giocatori` | Anagrafica giocatori con UUID. | Presente ma **non usata** dal codice attuale: la convergenza è rinviata (DD-012, DD-014). |
|
| `giocatori` | Anagrafica giocatori con UUID. | Presente ma **non usata** dal codice attuale: la convergenza è rinviata (DD-012, DD-014). |
|
||||||
| `profili_giocatore` | Dati personali, metadati del documento d'identità, certificato medico e path dei file, in relazione 1:1 con `giocatori_squadra`. | Creata dalla migration `m2_profili_giocatore` (DD-016). Letta da `src/lib/profili.ts`; le policy mostrano al giocatore solo il proprio profilo e all'admin tutti. I file non stanno qui: la tabella conserva i path nel bucket. |
|
| `profili_giocatore` | Dati personali, metadati del documento d'identità, certificato medico e path dei file, in relazione 1:1 con `giocatori_squadra`. | Creata dalla migration `m2_profili_giocatore` (DD-016). Letta da `src/lib/profili.ts`; le policy mostrano al giocatore solo il proprio profilo e all'admin tutti. I file non stanno qui: la tabella conserva i path nel bucket. |
|
||||||
| `user_roles` | Ruoli applicativi (es. amministratore, giocatore). | Fonte dei permessi di amministrazione, letta da `src/lib/ruoli.ts` (DD-011). Il primo admin va inserito a mano; vedi [PROJECT_STATE.md](../PROJECT_STATE.md). |
|
| `user_roles` | Ruoli applicativi (es. amministratore, giocatore). | Fonte dei permessi di amministrazione, letta da `src/lib/ruoli.ts` (DD-011). Il primo admin va inserito a mano; vedi [PROJECT_STATE.md](../PROJECT_STATE.md). |
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ Serve a rispondere a domande del tipo:
|
|||||||
| [DD-013](#dd-013--portabilità-lapp-non-deve-dipendere-da-servizi-esclusivi) | Portabilità dello stack |
|
| [DD-013](#dd-013--portabilità-lapp-non-deve-dipendere-da-servizi-esclusivi) | Portabilità dello stack |
|
||||||
| [DD-016](#dd-016--schema-dati-profilo-giocatore-v11-f0) | Schema dati Profilo Giocatore v1.1 |
|
| [DD-016](#dd-016--schema-dati-profilo-giocatore-v11-f0) | Schema dati Profilo Giocatore v1.1 |
|
||||||
| [DD-017](#dd-017--lamministratore-può-compilare-i-dati-al-posto-del-giocatore) | L'admin scrive al posto del giocatore |
|
| [DD-017](#dd-017--lamministratore-può-compilare-i-dati-al-posto-del-giocatore) | L'admin scrive al posto del giocatore |
|
||||||
|
| [DD-018](#dd-018--collegamento-automatico-giocatoreaccount-per-email) | Collegamento automatico per email |
|
||||||
|
|
||||||
**In valutazione**
|
**In valutazione**
|
||||||
|
|
||||||
@@ -474,6 +475,32 @@ Restano fuori, e non cambiano:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### DD-018 — Collegamento automatico giocatore↔account per email
|
||||||
|
|
||||||
|
**Data:** settembre 2026
|
||||||
|
**Stato:** Accettata
|
||||||
|
|
||||||
|
**Contesto**
|
||||||
|
DD-016 regola 2 prevedeva che, al primo accesso, il giocatore scegliesse manualmente il proprio slot libero da un elenco (`/benvenuto`). In pratica ogni giocatore ha un'email nota (o presto nota), quindi far scegliere un nome da una lista è un passaggio superfluo e un rischio: un giocatore può selezionare per errore lo slot di un compagno, e nulla nel flusso lo impedisce a livello di prodotto.
|
||||||
|
|
||||||
|
**Decisione**
|
||||||
|
Al primo accesso, `giocatori_squadra` viene interrogata per email (case-insensitive, tramite la nuova colonna `email`) invece di mostrare un elenco di slot liberi. Se l'email dell'account Google corrisponde a una riga libera, il collegamento avviene automaticamente. Se non corrisponde a nessuna riga (email non ancora nota, o nessun profilo per quella persona), l'utente vede solo un messaggio d'errore che invita a contattare un amministratore, con un pulsante per uscire e riprovare con un altro account — nessuna selezione manuale di ripiego. Le email sono popolate via migration (`m5_email_giocatori_squadra`), non tramite un'interfaccia amministrativa in questa iterazione. Il trigger `enforce_giocatori_squadra_update` (DD-016) viene esteso per richiedere anche la corrispondenza email, non solo lo slot libero: il vincolo resta nel database, non solo nella UI.
|
||||||
|
|
||||||
|
**Alternative scartate**
|
||||||
|
- Mantenere la selezione manuale come ripiego quando l'email non trova corrispondenza → scartata: vanificherebbe la garanzia "ognuno collega solo il proprio profilo" e reintrodurrebbe il rischio di scelta errata che questa decisione vuole eliminare.
|
||||||
|
- Un'interfaccia admin per scrivere l'email dei giocatori → rimandata: non necessaria finché le email arrivano da migration; si può aggiungere in futuro senza toccare questa decisione.
|
||||||
|
|
||||||
|
**Conseguenze**
|
||||||
|
- Le righe senza email nota restano bloccate — nessuno può collegarle, nemmeno per errore — finché una migration futura non la imposta. Oggi solo 2 dei 17 giocatori hanno l'email registrata.
|
||||||
|
- `slotLiberi` (funzione ed elenco "slot liberi" in `/benvenuto`) è stato rimosso: non aveva più chiamanti in produzione dopo il cambio.
|
||||||
|
- Un utente che accede con l'account Google sbagliato resta bloccato su `/benvenuto` finché non esce e riprova con l'account giusto.
|
||||||
|
|
||||||
|
**Riesame**
|
||||||
|
- Quando tutte le 17 email saranno note e verificate.
|
||||||
|
- Se in futuro serve un'assistenza admin diretta dal flusso di login invece che da `/admin`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Decisioni in valutazione
|
## Decisioni in valutazione
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ responsabilità del giocatore che li fornisce.
|
|||||||
1. Login tramite Google oppure Email. *Implementato con il solo Google: la squadra ha tutti
|
1. Login tramite Google oppure Email. *Implementato con il solo Google: la squadra ha tutti
|
||||||
un account Google, e un secondo metodo è additivo (un bottone in più sulla stessa
|
un account Google, e un secondo metodo è additivo (un bottone in più sulla stessa
|
||||||
schermata) il giorno che serve.*
|
schermata) il giorno che serve.*
|
||||||
2. Selezione del proprio giocatore.
|
2. Collegamento automatico al proprio giocatore, confrontando l'email dell'account Google
|
||||||
|
con l'email registrata in `giocatori_squadra` (DD-018). Nessuna scelta manuale: se
|
||||||
|
l'email non corrisponde a nessun profilo, l'accesso si ferma con un messaggio che invita
|
||||||
|
a contattare un amministratore.
|
||||||
3. Accesso alla Home.
|
3. Accesso alla Home.
|
||||||
|
|
||||||
Se il profilo non è completo compare automaticamente un widget di completamento.
|
Se il profilo non è completo compare automaticamente un widget di completamento.
|
||||||
|
|||||||
+6
-1
@@ -39,7 +39,12 @@ export function useSessione() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { sessione, pronta, utenteId: sessione?.user.id ?? null };
|
return {
|
||||||
|
sessione,
|
||||||
|
pronta,
|
||||||
|
utenteId: sessione?.user.id ?? null,
|
||||||
|
emailUtente: sessione?.user.email ?? null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function accediConGoogle(): Promise<void> {
|
export async function accediConGoogle(): Promise<void> {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type GiocatoreSquadra = {
|
|||||||
ruolo: string;
|
ruolo: string;
|
||||||
authUserId: string | null;
|
authUserId: string | null;
|
||||||
attivo: boolean;
|
attivo: boolean;
|
||||||
|
email: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RigaGiocatoreSquadra = {
|
type RigaGiocatoreSquadra = {
|
||||||
@@ -25,6 +26,7 @@ type RigaGiocatoreSquadra = {
|
|||||||
ruolo: string;
|
ruolo: string;
|
||||||
auth_user_id: string | null;
|
auth_user_id: string | null;
|
||||||
attivo: boolean;
|
attivo: boolean;
|
||||||
|
email: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SQUADRA_KEY = ["giocatori-squadra"] as const;
|
export const SQUADRA_KEY = ["giocatori-squadra"] as const;
|
||||||
@@ -45,6 +47,7 @@ export function rosaFallback(): GiocatoreSquadra[] {
|
|||||||
ruolo: g.ruolo,
|
ruolo: g.ruolo,
|
||||||
authUserId: null,
|
authUserId: null,
|
||||||
attivo: true,
|
attivo: true,
|
||||||
|
email: null,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,14 +64,20 @@ export function slotDi(
|
|||||||
return righe.find((g) => g.authUserId === utenteId) ?? null;
|
return righe.find((g) => g.authUserId === utenteId) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function slotLiberi(righe: GiocatoreSquadra[]): GiocatoreSquadra[] {
|
/** Lo slot libero la cui email coincide con quella dell'account Google (case-insensitive). */
|
||||||
return righe.filter((g) => g.attivo && !g.authUserId);
|
export function slotPerEmail(
|
||||||
|
righe: GiocatoreSquadra[],
|
||||||
|
email: string | null,
|
||||||
|
): GiocatoreSquadra | null {
|
||||||
|
if (!email) return null;
|
||||||
|
const cercata = email.trim().toLowerCase();
|
||||||
|
return righe.find((g) => !g.authUserId && g.email?.trim().toLowerCase() === cercata) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchSquadra(): Promise<GiocatoreSquadra[]> {
|
async function fetchSquadra(): Promise<GiocatoreSquadra[]> {
|
||||||
const { data, error } = await supabaseNuoveTabelle
|
const { data, error } = await supabaseNuoveTabelle
|
||||||
.from("giocatori_squadra")
|
.from("giocatori_squadra")
|
||||||
.select("id, nome, cognome, numero, ruolo, auth_user_id, attivo")
|
.select("id, nome, cognome, numero, ruolo, auth_user_id, attivo, email")
|
||||||
.order("id");
|
.order("id");
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
const righe = (data ?? []) as RigaGiocatoreSquadra[];
|
const righe = (data ?? []) as RigaGiocatoreSquadra[];
|
||||||
@@ -80,6 +89,7 @@ async function fetchSquadra(): Promise<GiocatoreSquadra[]> {
|
|||||||
ruolo: r.ruolo,
|
ruolo: r.ruolo,
|
||||||
authUserId: r.auth_user_id,
|
authUserId: r.auth_user_id,
|
||||||
attivo: r.attivo,
|
attivo: r.attivo,
|
||||||
|
email: r.email,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+41
-59
@@ -1,16 +1,14 @@
|
|||||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { LogIn } from "lucide-react";
|
import { LogIn, LogOut } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { TeamLogo } from "@/components/crapp/ui-bits";
|
import { TeamLogo } from "@/components/crapp/ui-bits";
|
||||||
import { accediConGoogle, useSessione } from "@/lib/auth";
|
import { accediConGoogle, esci, useSessione } from "@/lib/auth";
|
||||||
import {
|
import {
|
||||||
nomeCompleto,
|
|
||||||
slotDi,
|
slotDi,
|
||||||
slotLiberi,
|
slotPerEmail,
|
||||||
useCollegaGiocatore,
|
useCollegaGiocatore,
|
||||||
useGiocatoriSquadra,
|
useGiocatoriSquadra,
|
||||||
type GiocatoreSquadra,
|
|
||||||
} from "@/lib/giocatori-squadra";
|
} from "@/lib/giocatori-squadra";
|
||||||
import { impostaGiocatore, resetGiocatore, useGiocatoreCorrente } from "@/lib/user-store";
|
import { impostaGiocatore, resetGiocatore, useGiocatoreCorrente } from "@/lib/user-store";
|
||||||
|
|
||||||
@@ -32,43 +30,17 @@ export const Route = createFileRoute("/benvenuto")({
|
|||||||
component: Benvenuto,
|
component: Benvenuto,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Scheda({
|
|
||||||
titolo,
|
|
||||||
sottotitolo,
|
|
||||||
onClick,
|
|
||||||
iniziali,
|
|
||||||
}: {
|
|
||||||
titolo: string;
|
|
||||||
sottotitolo: string;
|
|
||||||
onClick: () => void;
|
|
||||||
iniziali: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
className="flex w-full items-center gap-4 rounded-2xl bg-card p-4 shadow-card transition-transform active:scale-[0.98]"
|
|
||||||
>
|
|
||||||
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-secondary font-display text-lg">
|
|
||||||
{iniziali}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1 text-left">
|
|
||||||
<p className="font-semibold leading-tight">{titolo}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">{sottotitolo}</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Benvenuto() {
|
function Benvenuto() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const giocatore = useGiocatoreCorrente();
|
const giocatore = useGiocatoreCorrente();
|
||||||
const { pronta, utenteId } = useSessione();
|
const { pronta, utenteId, emailUtente } = useSessione();
|
||||||
const { righe, daDatabase } = useGiocatoriSquadra();
|
const { righe, daDatabase } = useGiocatoriSquadra();
|
||||||
const collega = useCollegaGiocatore();
|
const collega = useCollegaGiocatore();
|
||||||
const [inCorso, setInCorso] = useState(false);
|
const [inCorso, setInCorso] = useState(false);
|
||||||
|
const [tentato, setTentato] = useState(false);
|
||||||
|
|
||||||
const mioSlot = slotDi(righe, utenteId);
|
const mioSlot = slotDi(righe, utenteId);
|
||||||
|
const slotEmail = slotPerEmail(righe, emailUtente);
|
||||||
// Si entra solo da loggati e con uno slot collegato (DD-011).
|
// Si entra solo da loggati e con uno slot collegato (DD-011).
|
||||||
const puoEntrare = !!giocatore && !!utenteId;
|
const puoEntrare = !!giocatore && !!utenteId;
|
||||||
|
|
||||||
@@ -83,6 +55,18 @@ function Benvenuto() {
|
|||||||
else if (utenteId && daDatabase) resetGiocatore();
|
else if (utenteId && daDatabase) resetGiocatore();
|
||||||
}, [mioSlot, utenteId, daDatabase]);
|
}, [mioSlot, utenteId, daDatabase]);
|
||||||
|
|
||||||
|
// Collegamento automatico per email (DD-018): un solo tentativo, mai su dati di
|
||||||
|
// fallback. Se fallisce o non trova corrispondenza resta lo stato d'errore, senza
|
||||||
|
// scelta manuale di ripiego.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!utenteId || !daDatabase || mioSlot || !slotEmail || tentato) return;
|
||||||
|
setTentato(true);
|
||||||
|
collega
|
||||||
|
.mutateAsync({ giocatoreId: slotEmail.id, utenteId })
|
||||||
|
.then(() => impostaGiocatore(slotEmail.id))
|
||||||
|
.catch(() => toast.error("Collegamento non riuscito. Riprova o contatta un amministratore."));
|
||||||
|
}, [utenteId, daDatabase, mioSlot, slotEmail, tentato, collega]);
|
||||||
|
|
||||||
async function accedi() {
|
async function accedi() {
|
||||||
setInCorso(true);
|
setInCorso(true);
|
||||||
try {
|
try {
|
||||||
@@ -93,17 +77,19 @@ function Benvenuto() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reclama(g: GiocatoreSquadra) {
|
async function esciERiprova() {
|
||||||
if (!utenteId) return;
|
|
||||||
try {
|
try {
|
||||||
await collega.mutateAsync({ giocatoreId: g.id, utenteId });
|
await esci();
|
||||||
impostaGiocatore(g.id);
|
} catch (error) {
|
||||||
} catch {
|
toast.error(error instanceof Error ? error.message : "Uscita non riuscita");
|
||||||
toast.error("Profilo già collegato a un altro account. Chiedi a un amministratore.");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const liberi = slotLiberi(righe);
|
// Errore: email senza corrispondenza, oppure trovata ma il tentativo di collegamento
|
||||||
|
// è fallito (slot nel frattempo reclamato da altri, o errore di rete).
|
||||||
|
const erroreCollegamento =
|
||||||
|
!!utenteId && daDatabase && !mioSlot && (!slotEmail || collega.isError);
|
||||||
|
const inAttesaCollegamento = !!utenteId && !mioSlot && !erroreCollegamento;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col items-center justify-center px-6 py-12">
|
<div className="flex min-h-screen flex-col items-center justify-center px-6 py-12">
|
||||||
@@ -126,29 +112,25 @@ function Benvenuto() {
|
|||||||
<LogIn className="h-4 w-4" /> Accedi con Google
|
<LogIn className="h-4 w-4" /> Accedi con Google
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : erroreCollegamento ? (
|
||||||
<>
|
<>
|
||||||
<p className="mt-2 text-center text-sm text-muted-foreground">
|
<p className="mt-2 text-center text-sm text-muted-foreground">
|
||||||
Sei entrato. Scegli il tuo nome: resterà collegato a questo account.
|
Nessun profilo trovato per la tua email. Contatta un amministratore per collegare il tuo
|
||||||
|
account.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-8 w-full max-w-sm space-y-2">
|
<button
|
||||||
{liberi.map((g) => (
|
type="button"
|
||||||
<Scheda
|
onClick={() => void esciERiprova()}
|
||||||
key={g.id}
|
className="premi mt-8 flex w-full max-w-sm items-center justify-center gap-2 rounded-2xl bg-secondary py-3.5 text-sm font-bold uppercase shadow-card"
|
||||||
titolo={nomeCompleto(g)}
|
>
|
||||||
sottotitolo={`#${g.numero} · ${g.ruolo}`}
|
<LogOut className="h-4 w-4" /> Esci
|
||||||
iniziali={`${g.nome[0] ?? ""}${g.cognome[0] ?? ""}`.toUpperCase()}
|
</button>
|
||||||
onClick={() => void reclama(g)}
|
</>
|
||||||
/>
|
) : inAttesaCollegamento ? (
|
||||||
))}
|
<p className="mt-2 text-center text-sm text-muted-foreground">
|
||||||
{liberi.length === 0 ? (
|
Ti stiamo collegando al tuo profilo...
|
||||||
<p className="rounded-2xl bg-card p-4 text-center text-sm text-muted-foreground shadow-card">
|
|
||||||
Nessun profilo libero: chiedi a un amministratore di collegarti.
|
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
-- M5 — Collegamento automatico per email al primo accesso (DD-018)
|
||||||
|
--
|
||||||
|
-- Sostituisce la scelta manuale del proprio slot in `/benvenuto` con un confronto tra
|
||||||
|
-- l'email dell'account Google e l'email registrata per ciascun giocatore. Additiva:
|
||||||
|
-- non tocca `profili_giocatore` né altre tabelle v1.0.
|
||||||
|
|
||||||
|
ALTER TABLE public.giocatori_squadra
|
||||||
|
ADD COLUMN email text;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN public.giocatori_squadra.email IS
|
||||||
|
'Email nota in anticipo per il giocatore (non l''email personale in profili_giocatore): '
|
||||||
|
'chiave del collegamento automatico account <-> giocatore al primo accesso (DD-018). '
|
||||||
|
'NULL finché non nota: la riga resta non collegabile da nessuno.';
|
||||||
|
|
||||||
|
-- Case-insensitive, tollerante a più righe con email NULL (non ancora note).
|
||||||
|
CREATE UNIQUE INDEX giocatori_squadra_email_unica
|
||||||
|
ON public.giocatori_squadra (lower(email))
|
||||||
|
WHERE email IS NOT NULL;
|
||||||
|
|
||||||
|
-- Il trigger di DD-016 (m1_giocatori_squadra) blocca qualunque UPDATE che non arrivi da un
|
||||||
|
-- admin autenticato o dal giocatore stesso: durante la migration non c'è sessione, quindi
|
||||||
|
-- va disattivato solo per il seed e riattivato subito dopo.
|
||||||
|
ALTER TABLE public.giocatori_squadra DISABLE TRIGGER enforce_giocatori_squadra_update;
|
||||||
|
|
||||||
|
-- Solo le due email confermate finora; le altre 15 arriveranno con una migration futura.
|
||||||
|
UPDATE public.giocatori_squadra SET email = 'ivan.cacciari.1995@gmail.com' WHERE id = 'g4';
|
||||||
|
UPDATE public.giocatori_squadra SET email = 'davide3011@gmail.com' WHERE id = 'g10';
|
||||||
|
|
||||||
|
ALTER TABLE public.giocatori_squadra ENABLE TRIGGER enforce_giocatori_squadra_update;
|
||||||
|
|
||||||
|
-- Estende il trigger di DD-016: oltre a "slot libero, nessun altro campo cambia", un
|
||||||
|
-- non-admin può collegarsi solo se l'email del token coincide con l'email della riga
|
||||||
|
-- (case-insensitive). Righe senza email restano bloccate per chiunque.
|
||||||
|
CREATE OR REPLACE FUNCTION public.enforce_giocatori_squadra_update()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF public.has_role(auth.uid(), 'admin'::public.app_role) THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF OLD.auth_user_id IS NULL
|
||||||
|
AND NEW.auth_user_id = auth.uid()
|
||||||
|
AND OLD.email IS NOT NULL
|
||||||
|
AND lower(OLD.email) = lower(coalesce(auth.jwt() ->> 'email', ''))
|
||||||
|
AND NEW.id IS NOT DISTINCT FROM OLD.id
|
||||||
|
AND NEW.nome IS NOT DISTINCT FROM OLD.nome
|
||||||
|
AND NEW.cognome IS NOT DISTINCT FROM OLD.cognome
|
||||||
|
AND NEW.numero IS NOT DISTINCT FROM OLD.numero
|
||||||
|
AND NEW.ruolo IS NOT DISTINCT FROM OLD.ruolo
|
||||||
|
AND NEW.attivo IS NOT DISTINCT FROM OLD.attivo
|
||||||
|
AND NEW.email IS NOT DISTINCT FROM OLD.email
|
||||||
|
AND NEW.creato_il IS NOT DISTINCT FROM OLD.creato_il THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RAISE EXCEPTION 'Aggiornamento non autorizzato su giocatori_squadra';
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
numeroGiaUsato,
|
numeroGiaUsato,
|
||||||
rosaFallback,
|
rosaFallback,
|
||||||
slotDi,
|
slotDi,
|
||||||
slotLiberi,
|
slotPerEmail,
|
||||||
validaDatiSquadra,
|
validaDatiSquadra,
|
||||||
type GiocatoreSquadra,
|
type GiocatoreSquadra,
|
||||||
} from "@/lib/giocatori-squadra";
|
} from "@/lib/giocatori-squadra";
|
||||||
@@ -20,6 +20,7 @@ const riga = (parziale: Partial<GiocatoreSquadra> = {}): GiocatoreSquadra => ({
|
|||||||
ruolo: "schiacciatore",
|
ruolo: "schiacciatore",
|
||||||
authUserId: null,
|
authUserId: null,
|
||||||
attivo: true,
|
attivo: true,
|
||||||
|
email: null,
|
||||||
...parziale,
|
...parziale,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -62,17 +63,25 @@ assert.equal(slotDi(righe, "u1")?.id, "g1");
|
|||||||
assert.equal(slotDi(righe, "u9"), null, "nessuno slot per un account non collegato");
|
assert.equal(slotDi(righe, "u9"), null, "nessuno slot per un account non collegato");
|
||||||
assert.equal(slotDi(righe, null), null, "senza sessione non c'è slot");
|
assert.equal(slotDi(righe, null), null, "senza sessione non c'è slot");
|
||||||
|
|
||||||
// --- slotLiberi ----------------------------------------------------------------
|
// --- slotPerEmail ----------------------------------------------------------------
|
||||||
const rosaMista: GiocatoreSquadra[] = [
|
const rosaEmail: GiocatoreSquadra[] = [
|
||||||
riga({ id: "g1", attivo: true, authUserId: null }),
|
riga({ id: "g1", email: "foo@bar.com", authUserId: null }),
|
||||||
riga({ id: "g2", attivo: true, authUserId: "u1" }),
|
riga({ id: "g2", email: "baz@qux.com", authUserId: "u1" }),
|
||||||
riga({ id: "g3", attivo: false, authUserId: null }),
|
riga({ id: "g3", email: null, authUserId: null }),
|
||||||
];
|
];
|
||||||
assert.deepEqual(
|
assert.equal(slotPerEmail(rosaEmail, "foo@bar.com")?.id, "g1", "match esatto");
|
||||||
slotLiberi(rosaMista).map((g) => g.id),
|
assert.equal(
|
||||||
["g1"],
|
slotPerEmail(rosaEmail, "Foo@Bar.com")?.id,
|
||||||
"libero solo chi è attivo e senza account collegato",
|
"g1",
|
||||||
|
"il confronto ignora maiuscole/minuscole",
|
||||||
);
|
);
|
||||||
|
assert.equal(slotPerEmail(rosaEmail, null), null, "senza email non c'è match");
|
||||||
|
assert.equal(
|
||||||
|
slotPerEmail(rosaEmail, "baz@qux.com"),
|
||||||
|
null,
|
||||||
|
"uno slot già collegato non risulta in match anche con email coincidente",
|
||||||
|
);
|
||||||
|
assert.equal(slotPerEmail(rosaEmail, ""), null, "stringa vuota trattata come nessuna email");
|
||||||
|
|
||||||
// --- validaDatiSquadra ----------------------------------------------------------------
|
// --- validaDatiSquadra ----------------------------------------------------------------
|
||||||
const datiOk = { nome: "Mario", cognome: "Rossi", numero: 7, ruolo: "schiacciatore" };
|
const datiOk = { nome: "Mario", cognome: "Rossi", numero: 7, ruolo: "schiacciatore" };
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
numeroGiaUsato,
|
numeroGiaUsato,
|
||||||
rosaFallback,
|
rosaFallback,
|
||||||
slotDi,
|
slotDi,
|
||||||
slotLiberi,
|
|
||||||
validaDatiSquadra,
|
validaDatiSquadra,
|
||||||
type GiocatoreSquadra,
|
type GiocatoreSquadra,
|
||||||
} from "@/lib/giocatori-squadra";
|
} from "@/lib/giocatori-squadra";
|
||||||
@@ -112,6 +111,7 @@ const squadra: GiocatoreSquadra[] = [
|
|||||||
ruolo: "Banda",
|
ruolo: "Banda",
|
||||||
authUserId: null,
|
authUserId: null,
|
||||||
attivo: true,
|
attivo: true,
|
||||||
|
email: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "g2",
|
id: "g2",
|
||||||
@@ -121,6 +121,7 @@ const squadra: GiocatoreSquadra[] = [
|
|||||||
ruolo: "Libero",
|
ruolo: "Libero",
|
||||||
authUserId: "u2",
|
authUserId: "u2",
|
||||||
attivo: true,
|
attivo: true,
|
||||||
|
email: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const csv = csvTesseramento(squadra, { g1: completo });
|
const csv = csvTesseramento(squadra, { g1: completo });
|
||||||
@@ -146,16 +147,6 @@ assert.deepEqual(dividiNome("Ivan"), { nome: "Ivan", cognome: "" });
|
|||||||
assert.equal(slotDi(squadra, null), null, "senza sessione nessuno slot");
|
assert.equal(slotDi(squadra, null), null, "senza sessione nessuno slot");
|
||||||
assert.equal(slotDi(squadra, "u2")?.id, "g2");
|
assert.equal(slotDi(squadra, "u2")?.id, "g2");
|
||||||
assert.equal(slotDi(squadra, "sconosciuto"), null);
|
assert.equal(slotDi(squadra, "sconosciuto"), null);
|
||||||
assert.deepEqual(
|
|
||||||
slotLiberi(squadra).map((g) => g.id),
|
|
||||||
["g1"],
|
|
||||||
"uno slot già collegato non è più libero",
|
|
||||||
);
|
|
||||||
assert.deepEqual(
|
|
||||||
slotLiberi([...squadra, { ...squadra[0]!, id: "g3", attivo: false }]).map((g) => g.id),
|
|
||||||
["g1"],
|
|
||||||
"i giocatori non attivi restano fuori",
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- dati squadra modificabili dall'admin (DD-017) ---------------------------
|
// --- dati squadra modificabili dall'admin (DD-017) ---------------------------
|
||||||
const datiOk = { nome: "Ivan", cognome: "Cacciari", numero: 23, ruolo: "Banda" };
|
const datiOk = { nome: "Ivan", cognome: "Cacciari", numero: 23, ruolo: "Banda" };
|
||||||
|
|||||||
Reference in New Issue
Block a user