diff --git a/README.md b/README.md index 0eb56e9..57eaa41 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/bun.lock b/bun.lock index 32c1871..a56c8fe 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index 1ebf31f..47c9b5f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/integrations/pocketbase/auth-attacher.ts b/src/integrations/pocketbase/auth-attacher.ts new file mode 100644 index 0000000..0d5d761 --- /dev/null +++ b/src/integrations/pocketbase/auth-attacher.ts @@ -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}` } : {}, + }); + }, +); diff --git a/src/integrations/pocketbase/client.server.ts b/src/integrations/pocketbase/client.server.ts new file mode 100644 index 0000000..73da7b6 --- /dev/null +++ b/src/integrations/pocketbase/client.server.ts @@ -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 { + 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; +} diff --git a/src/integrations/pocketbase/client.ts b/src/integrations/pocketbase/client.ts new file mode 100644 index 0000000..ad2ad6e --- /dev/null +++ b/src/integrations/pocketbase/client.ts @@ -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); + }, +}); diff --git a/src/lib/auth-route.server.ts b/src/lib/auth-route.server.ts index 94cbac2..7535576 100644 --- a/src/lib/auth-route.server.ts +++ b/src/lib/auth-route.server.ts @@ -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 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 }); + } } diff --git a/src/lib/auth.ts b/src/lib/auth.ts index e67b694..b07be51 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -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(null); + const [utente, setUtente] = useState(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> { - 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 { - 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 { - const { error } = await supabase.auth.signOut(); - if (error) throw error; + pb.authStore.clear(); } diff --git a/src/lib/ruoli.ts b/src/lib/ruoli.ts index bc1c25c..abc978d 100644 --- a/src/lib/ruoli.ts +++ b/src/lib/ruoli.ts @@ -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 { - 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"; } diff --git a/src/start.ts b/src/start.ts index a87e006..c1c8552 100644 --- a/src/start.ts +++ b/src/start.ts @@ -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], }));