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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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],
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user