Sposta l'autenticazione da Supabase a PocketBase

Login Google via PocketBase invece che Supabase Auth: nuovi client
in src/integrations/pocketbase/ (browser, server con superuser, attacher
del bearer token per le server function). Il ruolo admin/user vive ora
come campo diretto sul record utente PocketBase invece che nella tabella
separata user_roles, quindi ruoli.ts e la verifica in auth-route.server.ts
non hanno più bisogno di una query aggiuntiva.

requireSupabaseAuth/auth-middleware.ts non viene riportato: non era
importato da nessun file, era codice morto.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 14:58:09 +02:00
co-authored by Claude Sonnet 5
parent 15885f32c7
commit 0f5a27ed5f
10 changed files with 160 additions and 87 deletions
+9
View File
@@ -55,6 +55,15 @@ Il progetto richiede le seguenti variabili:
- `VITE_SUPABASE_URL` - `VITE_SUPABASE_URL`
- `VITE_SUPABASE_PUBLISHABLE_KEY` - `VITE_SUPABASE_PUBLISHABLE_KEY`
Solo per lo sviluppo locale con PocketBase al posto di Supabase (vedi
[docs/PORTABILITA.md](docs/PORTABILITA.md)):
- `VITE_POCKETBASE_URL` / `POCKETBASE_URL``http://127.0.0.1:8090` con
`docker-compose.pocketbase.yml`
- `POCKETBASE_SUPERUSER_EMAIL` / `POCKETBASE_SUPERUSER_PASSWORD` — credenziali del
superuser creato al primo avvio, usate solo dalle route server che oggi usano
`supabaseAdmin`
## Documentazione ## Documentazione
Indice in [docs/README.md](docs/README.md). Le regole per gli assistenti AI stanno in Indice in [docs/README.md](docs/README.md). Le regole per gli assistenti AI stanno in
+3
View File
@@ -15,6 +15,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.575.0", "lucide-react": "^0.575.0",
"motion": "^13.2.0", "motion": "^13.2.0",
"pocketbase": "^0.28.1",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
@@ -651,6 +652,8 @@
"picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
"pocketbase": ["pocketbase@0.28.1", "", {}, "sha512-/3ihkq+rvfcs0MQgrK4sEElOg6gfenHW9S/O+3BSO+V1yT+4B10+9aT22uTQUPqze9ItwNtwWyY5ZmdgVCTdVA=="],
"postcss": ["postcss@8.5.24", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw=="], "postcss": ["postcss@8.5.24", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
+5 -1
View File
@@ -14,7 +14,10 @@
"test": "bun test/run.ts", "test": "bun test/run.ts",
"test:integration": "bun test/run.ts integration", "test:integration": "bun test/run.ts integration",
"test:e2e": "bun test/run.ts e2e", "test:e2e": "bun test/run.ts e2e",
"test:all": "bun test/run.ts all" "test:all": "bun test/run.ts all",
"pocketbase:start": "docker compose -f docker-compose.pocketbase.yml up -d",
"pocketbase:stop": "docker compose -f docker-compose.pocketbase.yml down",
"pocketbase:reset": "bash scripts/pocketbase-reset.sh"
}, },
"dependencies": { "dependencies": {
"@supabase/supabase-js": "^2.111.0", "@supabase/supabase-js": "^2.111.0",
@@ -27,6 +30,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.575.0", "lucide-react": "^0.575.0",
"motion": "^13.2.0", "motion": "^13.2.0",
"pocketbase": "^0.28.1",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
@@ -0,0 +1,13 @@
import { createMiddleware } from "@tanstack/react-start";
import { pb } from "./client";
// Deve essere registrato come `functionMiddleware` globale in `src/start.ts`; altrimenti
// il browser non allega il bearer token alle RPC delle server function.
export const attachPocketBaseAuth = createMiddleware({ type: "function" }).client(
async ({ next }) => {
const token = pb.authStore.token;
return next({
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
},
);
@@ -0,0 +1,39 @@
// Client server-side con privilegi superuser - bypassa le API rules delle collection
// (equivalente del service role Supabase, con una differenza: PocketBase non ha una
// chiave statica, l'autenticazione è email+password contro la collection _superusers e
// va rinnovata quando il token scade).
//
// SECURITY: usare solo per operazioni server-side fidate, mai esporre al client.
// Carica dentro gli handler server: const { pbAdmin } = await import("@/integrations/pocketbase/client.server");
// L'import a livello di modulo è sicuro solo in altri moduli *.server.ts - le route e le
// server function finiscono nel bundle client.
import PocketBase from "pocketbase";
let _pb: PocketBase | undefined;
export async function pbAdmin(): Promise<PocketBase> {
const POCKETBASE_URL = process.env["POCKETBASE_URL"];
const POCKETBASE_SUPERUSER_EMAIL = process.env["POCKETBASE_SUPERUSER_EMAIL"];
const POCKETBASE_SUPERUSER_PASSWORD = process.env["POCKETBASE_SUPERUSER_PASSWORD"];
if (!POCKETBASE_URL || !POCKETBASE_SUPERUSER_EMAIL || !POCKETBASE_SUPERUSER_PASSWORD) {
const missing = [
...(!POCKETBASE_URL ? ["POCKETBASE_URL"] : []),
...(!POCKETBASE_SUPERUSER_EMAIL ? ["POCKETBASE_SUPERUSER_EMAIL"] : []),
...(!POCKETBASE_SUPERUSER_PASSWORD ? ["POCKETBASE_SUPERUSER_PASSWORD"] : []),
];
const message = `Variabili d'ambiente PocketBase mancanti: ${missing.join(", ")}.`;
console.error(`[PocketBase] ${message}`);
throw new Error(message);
}
if (!_pb) _pb = new PocketBase(POCKETBASE_URL);
if (!_pb.authStore.isValid) {
await _pb
.collection("_superusers")
.authWithPassword(POCKETBASE_SUPERUSER_EMAIL, POCKETBASE_SUPERUSER_PASSWORD);
}
return _pb;
}
+28
View File
@@ -0,0 +1,28 @@
import PocketBase from "pocketbase";
function createPocketBaseClient(): PocketBase {
// Use import.meta.env for client-side (Vite build-time replacement)
// Fall back to process.env for SSR (server-side rendering)
const POCKETBASE_URL = import.meta.env["VITE_POCKETBASE_URL"] || process.env["POCKETBASE_URL"];
if (!POCKETBASE_URL) {
const message = "Manca la variabile d'ambiente POCKETBASE_URL (o VITE_POCKETBASE_URL).";
console.error(`[PocketBase] ${message}`);
throw new Error(message);
}
// PocketBase persiste la sessione in localStorage lato browser e in memoria lato SSR
// di default: nessuna configurazione aggiuntiva serve (a differenza del client Supabase).
return new PocketBase(POCKETBASE_URL);
}
let _pb: PocketBase | undefined;
// Import the PocketBase client like this:
// import { pb } from "@/integrations/pocketbase/client";
export const pb = new Proxy({} as PocketBase, {
get(_, prop, receiver) {
if (!_pb) _pb = createPocketBaseClient();
return Reflect.get(_pb, prop, receiver);
},
});
+27 -20
View File
@@ -2,18 +2,19 @@
* Controllo di accesso per le route in `src/routes/api/public/` che inviano notifiche * Controllo di accesso per le route in `src/routes/api/public/` che inviano notifiche
* a tutta la squadra (DD-024). * a tutta la squadra (DD-024).
* *
* Quelle route usano la service role e saltano la RLS: senza un controllo qui, chiunque * Quelle route usano il superuser PocketBase e saltano le API rules: senza un controllo
* conosca l'URL può far suonare i telefoni di tutti. L'id di un evento non è un segreto — * qui, chiunque conosca l'URL può far suonare i telefoni di tutti. L'id di un evento non
* è un timestamp in base 36 e compare negli URL che la squadra si scambia — quindi non * è un segreto — è un timestamp in base 36 e compare negli URL che la squadra si scambia —
* può fare da credenziale. * quindi non può fare da credenziale.
* *
* Tutte e tre le route che mandano notifiche partono da un pulsante riservato agli * Tutte e tre le route che mandano notifiche partono da un pulsante riservato agli
* amministratori, quindi il controllo è uno solo: `richiediAdmin` verifica il token della * amministratori, quindi il controllo è uno solo: `richiediAdmin` verifica il token della
* sessione Supabase e poi il ruolo in `user_roles`. Torna `null` quando la richiesta può * sessione PocketBase e il campo `role` sul record utente. Torna `null` quando la
* proseguire, altrimenti la `Response` di rifiuto già pronta. * richiesta può proseguire, altrimenti la `Response` di rifiuto già pronta.
*/ */
import PocketBase from "pocketbase";
/** Il token della sessione Supabase, se la richiesta ne porta uno ben formato. */ /** Il token della sessione PocketBase, se la richiesta ne porta uno ben formato. */
function tokenDaRichiesta(request: Request): string | null { function tokenDaRichiesta(request: Request): string | null {
const intestazione = request.headers.get("authorization"); const intestazione = request.headers.get("authorization");
if (!intestazione?.startsWith("Bearer ")) return null; if (!intestazione?.startsWith("Bearer ")) return null;
@@ -31,19 +32,25 @@ export async function richiediAdmin(request: Request): Promise<Response | null>
const token = tokenDaRichiesta(request); const token = tokenDaRichiesta(request);
if (!token) return new Response("Autenticazione richiesta", { status: 401 }); if (!token) return new Response("Autenticazione richiesta", { status: 401 });
const { supabaseAdmin } = await import("@/integrations/supabase/client.server"); const POCKETBASE_URL = process.env["POCKETBASE_URL"];
if (!POCKETBASE_URL) {
console.error("[PocketBase] Manca la variabile d'ambiente POCKETBASE_URL.");
return new Response("Configurazione server non valida", { status: 500 });
}
const { data: utente, error } = await supabaseAdmin.auth.getUser(token); // Client "vuoto" con il token dell'utente allegato: authRefresh() lo valida e restituisce
if (error || !utente?.user) return new Response("Sessione non valida", { status: 401 }); // il record aggiornato in un'unica chiamata, ruolo incluso (con Supabase servivano due
// round trip: getUser() più una query separata su user_roles).
const pb = new PocketBase(POCKETBASE_URL);
pb.authStore.save(token, null);
// Stessa fonte di `src/lib/ruoli.ts`: i permessi stanno solo in `user_roles` (DD-011). try {
const { data: ruolo } = await supabaseAdmin const { record } = await pb.collection("users").authRefresh();
.from("user_roles") if (record["role"] !== "admin") {
.select("role") return new Response("Riservato agli amministratori", { status: 403 });
.eq("user_id", utente.user.id) }
.eq("role", "admin") return null;
.maybeSingle(); } catch {
return new Response("Sessione non valida", { status: 401 });
if (!ruolo) return new Response("Riservato agli amministratori", { status: 403 }); }
return null;
} }
+25 -36
View File
@@ -1,49 +1,39 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { Session } from "@supabase/supabase-js"; import type { AuthRecord } from "pocketbase";
import { supabase } from "@/integrations/supabase/client"; import { pb } from "@/integrations/pocketbase/client";
/** /**
* Autenticazione reale con Google (DD-011). Il login ha sostituito la selezione del * 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 * giocatore: senza sessione non si entra, e i permessi di amministrazione arrivano solo
* da `user_roles` (vedi `ruoli.ts`). * dal campo `role` sul record utente (vedi `ruoli.ts`).
*/ */
export function useSessione() { export function useSessione() {
const [sessione, setSessione] = useState<Session | null>(null); const [utente, setUtente] = useState<AuthRecord>(null);
const [pronta, setPronta] = useState(false); const [pronta, setPronta] = useState(false);
useEffect(() => { useEffect(() => {
let attivo = true; // Il client PocketBase esplode alla costruzione se manca la variabile d'ambiente: qui
// Il client Supabase esplode alla costruzione se mancano le variabili d'ambiente: // va assorbito, altrimenti la schermata di accesso non si disegna proprio e resta
// qui va assorbito, altrimenti la schermata di accesso non si disegna proprio e // irraggiungibile anche la selezione del giocatore.
// resta irraggiungibile anche la selezione del giocatore.
try { try {
supabase.auth setUtente(pb.authStore.record);
.getSession()
.then(({ data }) => {
if (!attivo) return;
setSessione(data.session);
setPronta(true);
})
.catch(() => attivo && setPronta(true));
const { data } = supabase.auth.onAuthStateChange((_evento, nuova) => setSessione(nuova));
return () => {
attivo = false;
data.subscription.unsubscribe();
};
} catch (errore) {
console.error("[auth] Supabase non disponibile", errore);
setPronta(true); setPronta(true);
return () => { // A differenza di Supabase, l'authStore di PocketBase è già popolato in modo
attivo = false; // sincrono all'avvio (legge localStorage nel costruttore): non serve una chiamata
}; // asincrona equivalente a getSession().
return pb.authStore.onChange((_token, record) => setUtente(record));
} catch (errore) {
console.error("[auth] PocketBase non disponibile", errore);
setPronta(true);
return undefined;
} }
}, []); }, []);
return { return {
sessione, sessione: utente,
pronta, pronta,
utenteId: sessione?.user.id ?? null, utenteId: utente?.id ?? null,
emailUtente: sessione?.user.email ?? null, emailUtente: (utente?.["email"] as string | undefined) ?? null,
}; };
} }
@@ -53,20 +43,19 @@ export function useSessione() {
* un token già scaduto. * un token già scaduto.
*/ */
export async function intestazioniAutenticate(): Promise<Record<string, string>> { export async function intestazioniAutenticate(): Promise<Record<string, string>> {
const { data } = await supabase.auth.getSession(); const token = pb.authStore.token;
const token = data.session?.access_token;
return token ? { Authorization: `Bearer ${token}` } : {}; return token ? { Authorization: `Bearer ${token}` } : {};
} }
export async function accediConGoogle(): Promise<void> { export async function accediConGoogle(): Promise<void> {
const { error } = await supabase.auth.signInWithOAuth({ // createData copre il primo accesso: PocketBase crea il record utente al volo se non
// esiste ancora per questa identità Google, e il campo `role` è obbligatorio.
await pb.collection("users").authWithOAuth2({
provider: "google", provider: "google",
options: { redirectTo: window.location.origin }, createData: { role: "user" },
}); });
if (error) throw error;
} }
export async function esci(): Promise<void> { export async function esci(): Promise<void> {
const { error } = await supabase.auth.signOut(); pb.authStore.clear();
if (error) throw error;
} }
+9 -28
View File
@@ -1,34 +1,15 @@
import { useQuery } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { useSessione } from "./auth"; import { useSessione } from "./auth";
export const RUOLI_KEY = ["ruolo-admin"] as const;
/** /**
* Permessi di amministrazione: unica fonte è `user_roles` nel database (DD-011). * Permessi di amministrazione: unica fonte è il campo `role` sul record utente
* Nessuna lista di nomi, altrimenti basterebbe scegliere il nome giusto per amministrare. * PocketBase (DD-011). Nessuna lista di nomi, altrimenti basterebbe scegliere il nome
* giusto per amministrare.
*
* A differenza di Supabase (dove il ruolo stava nella tabella separata `user_roles` e
* richiedeva una query dedicata), qui il ruolo arriva già con la sessione: nessuna
* richiesta di rete o cache aggiuntiva serve.
*/ */
/** `null` = nessuna sessione, quindi il database non ha una risposta da dare. */
async function fetchRuoloAdmin(utenteId: string | null): Promise<boolean | null> {
if (!utenteId) return null;
const { data, error } = await supabase
.from("user_roles")
.select("role")
.eq("user_id", utenteId)
.eq("role", "admin")
.maybeSingle();
if (error) throw error;
return !!data;
}
export function useIsAdmin(): boolean { export function useIsAdmin(): boolean {
const { utenteId } = useSessione(); const { sessione } = useSessione();
// Il ruolo cambia solo quando un admin lo assegna: una lettura per sessione basta. return sessione?.["role"] === "admin";
const query = useQuery({
queryKey: [...RUOLI_KEY, utenteId],
queryFn: () => fetchRuoloAdmin(utenteId),
staleTime: 30 * 60_000,
});
return query.data === true;
} }
+2 -2
View File
@@ -1,7 +1,7 @@
import { createStart, createCsrfMiddleware, createMiddleware } from "@tanstack/react-start"; import { createStart, createCsrfMiddleware, createMiddleware } from "@tanstack/react-start";
import { renderErrorPage } from "./lib/error-page"; import { renderErrorPage } from "./lib/error-page";
import { attachSupabaseAuth } from "@/integrations/supabase/auth-attacher"; import { attachPocketBaseAuth } from "@/integrations/pocketbase/auth-attacher";
const errorMiddleware = createMiddleware().server(async ({ next }) => { const errorMiddleware = createMiddleware().server(async ({ next }) => {
try { try {
@@ -26,6 +26,6 @@ const csrfMiddleware = createCsrfMiddleware({
}); });
export const startInstance = createStart(() => ({ export const startInstance = createStart(() => ({
functionMiddleware: [attachSupabaseAuth], functionMiddleware: [attachPocketBaseAuth],
requestMiddleware: [errorMiddleware, csrfMiddleware], requestMiddleware: [errorMiddleware, csrfMiddleware],
})); }));