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_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
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",
"lucide-react": "^0.575.0",
"motion": "^13.2.0",
"pocketbase": "^0.28.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"sonner": "^2.0.7",
@@ -651,6 +652,8 @@
"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=="],
"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:integration": "bun test/run.ts integration",
"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": {
"@supabase/supabase-js": "^2.111.0",
@@ -27,6 +30,7 @@
"clsx": "^2.1.1",
"lucide-react": "^0.575.0",
"motion": "^13.2.0",
"pocketbase": "^0.28.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"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
* a tutta la squadra (DD-024).
*
* Quelle route usano la service role e saltano la RLS: senza un controllo qui, chiunque
* conosca l'URL può far suonare i telefoni di tutti. L'id di un evento non è un segreto —
* è un timestamp in base 36 e compare negli URL che la squadra si scambia — quindi non
* può fare da credenziale.
* Quelle route usano il superuser PocketBase e saltano le API rules: senza un controllo
* qui, chiunque conosca l'URL può far suonare i telefoni di tutti. L'id di un evento non
* è un segreto — è un timestamp in base 36 e compare negli URL che la squadra si scambia —
* quindi non può fare da credenziale.
*
* 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
* sessione Supabase e poi il ruolo in `user_roles`. Torna `null` quando la richiesta può
* proseguire, altrimenti la `Response` di rifiuto già pronta.
* sessione PocketBase e il campo `role` sul record utente. Torna `null` quando la
* 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 {
const intestazione = request.headers.get("authorization");
if (!intestazione?.startsWith("Bearer ")) return null;
@@ -31,19 +32,25 @@ export async function richiediAdmin(request: Request): Promise<Response | null>
const token = tokenDaRichiesta(request);
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);
if (error || !utente?.user) return new Response("Sessione non valida", { status: 401 });
// Client "vuoto" con il token dell'utente allegato: authRefresh() lo valida e restituisce
// 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).
const { data: ruolo } = await supabaseAdmin
.from("user_roles")
.select("role")
.eq("user_id", utente.user.id)
.eq("role", "admin")
.maybeSingle();
if (!ruolo) return new Response("Riservato agli amministratori", { status: 403 });
return null;
try {
const { record } = await pb.collection("users").authRefresh();
if (record["role"] !== "admin") {
return new Response("Riservato agli amministratori", { status: 403 });
}
return null;
} catch {
return new Response("Sessione non valida", { status: 401 });
}
}
+25 -36
View File
@@ -1,49 +1,39 @@
import { useEffect, useState } from "react";
import type { Session } from "@supabase/supabase-js";
import { supabase } from "@/integrations/supabase/client";
import type { AuthRecord } from "pocketbase";
import { pb } from "@/integrations/pocketbase/client";
/**
* 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`).
* dal campo `role` sul record utente (vedi `ruoli.ts`).
*/
export function useSessione() {
const [sessione, setSessione] = useState<Session | null>(null);
const [utente, setUtente] = useState<AuthRecord>(null);
const [pronta, setPronta] = useState(false);
useEffect(() => {
let attivo = true;
// Il client Supabase esplode alla costruzione se mancano le variabili d'ambiente:
// qui va assorbito, altrimenti la schermata di accesso non si disegna proprio e
// resta irraggiungibile anche la selezione del giocatore.
// Il client PocketBase esplode alla costruzione se manca la variabile d'ambiente: qui
// va assorbito, altrimenti la schermata di accesso non si disegna proprio e resta
// irraggiungibile anche la selezione del giocatore.
try {
supabase.auth
.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);
setUtente(pb.authStore.record);
setPronta(true);
return () => {
attivo = false;
};
// A differenza di Supabase, l'authStore di PocketBase è già popolato in modo
// 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 {
sessione,
sessione: utente,
pronta,
utenteId: sessione?.user.id ?? null,
emailUtente: sessione?.user.email ?? null,
utenteId: utente?.id ?? null,
emailUtente: (utente?.["email"] as string | undefined) ?? null,
};
}
@@ -53,20 +43,19 @@ export function useSessione() {
* un token già scaduto.
*/
export async function intestazioniAutenticate(): Promise<Record<string, string>> {
const { data } = await supabase.auth.getSession();
const token = data.session?.access_token;
const token = pb.authStore.token;
return token ? { Authorization: `Bearer ${token}` } : {};
}
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",
options: { redirectTo: window.location.origin },
createData: { role: "user" },
});
if (error) throw error;
}
export async function esci(): Promise<void> {
const { error } = await supabase.auth.signOut();
if (error) throw error;
pb.authStore.clear();
}
+9 -28
View File
@@ -1,34 +1,15 @@
import { useQuery } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { useSessione } from "./auth";
export const RUOLI_KEY = ["ruolo-admin"] as const;
/**
* Permessi di amministrazione: unica fonte è `user_roles` nel database (DD-011).
* Nessuna lista di nomi, altrimenti basterebbe scegliere il nome giusto per amministrare.
* Permessi di amministrazione: unica fonte è il campo `role` sul record utente
* 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 {
const { utenteId } = useSessione();
// 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 query.data === true;
const { sessione } = useSessione();
return sessione?.["role"] === "admin";
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { createStart, createCsrfMiddleware, createMiddleware } from "@tanstack/react-start";
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 }) => {
try {
@@ -26,6 +26,6 @@ const csrfMiddleware = createCsrfMiddleware({
});
export const startInstance = createStart(() => ({
functionMiddleware: [attachSupabaseAuth],
functionMiddleware: [attachPocketBaseAuth],
requestMiddleware: [errorMiddleware, csrfMiddleware],
}));