PageSourceSearch

https://dreamy-truffle-77db6e.netlify.app/admin

html dreamy-truffle-77db6e.netlify.app collected 2026-10-03 10:39:58 UTC 22,431 bytes, 484 lines download raw bytes

1<!doctype html>
2<html lang="it">
3<head>
4  <meta charset="utf-8" />
5  <meta name="viewport" content="width=device-width, initial-scale=1" />
6  <title>Admin Attività • GuardaMI</title>
7  <style>
8    body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; background: #f8fafc; color: #0f172a; margin: 0; }
9    header { display:flex; align-items:center; justify-content:space-between; padding: 12px 16px; background:#ffffff; border-bottom:1px solid #e2e8f0; position:sticky; top:0; }
10    h1 { font-size: 18px; margin: 0; }
11    main { padding: 12px; }
12    .panel { background:#ffffff; border:1px solid #e2e8f0; border-radius:12px; padding:12px; }
13    table { width:100%; border-collapse: collapse; font-size: 13px; }
14    th, td { border-bottom:1px solid #f1f5f9; padding: 8px; text-align:left; }
15    th { background:#f8fafc; }
16    .pill { display:inline-block; padding:2px 8px; border:1px solid #e2e8f0; border-radius:999px; background:#f8fafc; font-size:12px; }
17    .error { color:#b91c1c; font-weight:700; }
18    .ok { color:#16a34a; font-weight:700; }
19    .controls { display:flex; gap:8px; align-items:center; }
20    input[type="number"]{width:72px;}
21    .hint { color:#64748b; font-size:12px; }
22    .kpis { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin-top: 10px; }
23    .kpi { background:#ffffff; border:1px solid #e2e8f0; border-radius:12px; padding:10px; }
24    .kpi .label { color:#64748b; font-size:12px; }
25    .kpi .value { font-size:20px; font-weight:800; margin-top:4px; }
26    .kpi .sub { margin-top:6px; color:#64748b; font-size:12px; line-height: 1.25; }
27    @media (max-width: 900px) { .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
28    @media (max-width: 520px) { .kpis { grid-template-columns: 1fr; } }
29  </style>
30</head>
31<body>
32  <header>
33    <h1>Admin Attività</h1>
34    <div class="controls">
35      <label>Giorni <input id="days" type="number" min="1" max="60" value="7"/></label>
36      <label>Cerca <input id="q" type="text" placeholder="es. lanzoni" style="width:160px;"/></label>
37      <label>Auth <input id="authEmail" type="email" placeholder="email utente" style="width:190px;"/></label>
38      <button id="authInspect">Auth inspect</button>
39      <button id="refresh">Aggiorna</button>
40      <button id="scan" title="Cerca incoerenze punteggi e specie sconosciute">Scan integrità</button>
41      <button id="fix" title="Ripara automaticamente: specie sconosciute Anfibi/Rettili + punti">Scan + Auto-fix</button>
42      <a class='pill' href='/'>← Torna alla App</a>
43    </div>
44  </header>
45  <main>
46    <div id="status" class="hint">Caricamento…</div>
47    <div id="kpis" class="kpis"></div>
48    <div class="panel" style="margin-top:10px; overflow:auto;">
49      <table id="tbl">
50        <thead>
51          <tr>
52            <th>Utente</th>
53            <th>Email</th>
54            <th>Ultimo login</th>
55            <th>OS</th>
56            <th>PWA</th>
57            <th>Punti Citt.</th>
58            <th>Punti Ricerc.</th>
59            <th>Tot. invii</th>
60            <th>Invii ultimi giorni</th>
61            <th>Moduli</th>
62            <th>Audit</th>
63          </tr>
64        </thead>
65        <tbody></tbody>
66      </table>
67    </div>
68  </main>
69  
69<script type="module">
70    import { initializeApp } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-app.js";
71    import { getAuth, onAuthStateChanged } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-auth.js";
72
73    const firebaseConfig = {
74      apiKey: "AIzaSyDLwAgTCyoRXIWTyWPWgjUqD_9qfObAE0k",
75      authDomain: "guardamiwebapp.firebaseapp.com",
76      projectId: "guardamiwebapp",
77      storageBucket: "guardamiwebapp.firebasestorage.app",
78      messagingSenderId: "105692008925",
79      appId: "1:105692008925:web:fc61d601f308b28e0c6634",
80      measurementId: "G-N3S20SDTWV"
81    };
82
83    const app = initializeApp(firebaseConfig);
84    const auth = getAuth(app);
85
86    const statusEl = document.getElementById("status");
87    const kpisEl = document.getElementById("kpis");
88    const daysEl = document.getElementById("days");
89    const qEl = document.getElementById("q");
90    const tbl = document.getElementById("tbl").querySelector("tbody");
91    const refreshBtn = document.getElementById("refresh");
92    const scanBtn = document.getElementById("scan");
93    const fixBtn = document.getElementById("fix");
94    const authEmailEl = document.getElementById("authEmail");
95    const authInspectBtn = document.getElementById("authInspect");
96
97    refreshBtn.addEventListener("click", () => load());
98    qEl.addEventListener("input", () => renderLast());
99    scanBtn.addEventListener("click", () => scanIntegrity());
100    fixBtn.addEventListener("click", () => scanAndFix());
101    if (authInspectBtn) authInspectBtn.addEventListener("click", () => authInspect());
102
103    function escapeHtml(s) {
104      return String(s || "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
105    }
106
107    async function fetchJson(url, options) {
108      const r = await fetch(url, options);
109      const ct = String(r.headers.get("content-type") || "").toLowerCase();
110      if (!ct.includes("application/json")) {
111        const text = await r.text().catch(() => "");
112        const snippet = text ? text.slice(0, 240) : "";
113        throw new Error(`Risposta non JSON (${r.status}). ${snippet}`.trim());
114      }
115      const data = await r.json();
116      return { r, data };
117    }
118
119    async function waitForUser() {
120      if (auth.currentUser) return auth.currentUser;
121      return new Promise((resolve) => {
122        const unsub = onAuthStateChanged(auth, (u) => {
123          unsub();
124          resolve(u);
125        });
126      });
127    }
128
129    async function authInspect() {
130      const email = String(authEmailEl && authEmailEl.value || "").trim();
131      if (!email) return;
132      try {
133        const u = await waitForUser();
134        if (!u) throw new Error("Devi accedere");
135        const t = await u.getIdToken();
136        const { data } = await fetchJson(`/.netlify/functions/admin-auth-inspect?email=${encodeURIComponent(email)}`, {
137          headers: { Authorization: `Bearer ${t}` }
138        });
139        if (!data || data.ok !== true) throw new Error((data && data.error) || "Errore");
140        if (data.found === false) {
141          alert(`Utente non trovato in Firebase Auth: ${email}`);
142          return;
143        }
144        const lines = [
145          `Email: ${data.email || email}`,
146          `UID: ${data.uid || "—"}`,
147          `Disabilitato: ${data.disabled ? "SI" : "no"}`,
148          `Email verificata: ${data.emailVerified ? "SI" : "no"}`,
149          `Providers: ${(Array.isArray(data.providerIds) && data.providerIds.length) ? data.providerIds.join(", ") : "—"}`,
150          `Creato: ${data.createdAt || "—"}`,
151          `Ultimo accesso: ${data.lastSignInAt || "—"}`
152        ];
153        alert(lines.join("\n"));
154      } catch (e) {
155        alert(e && e.message ? e.message : String(e));
156      }
157    }
158
159    async function load() {
160      statusEl.textContent = "Caricamento…";
161      kpisEl.innerHTML = "";
162      tbl.innerHTML = "";
163
164      const u = await waitForUser();
165      if (!u) {
166        statusEl.innerHTML = '<span class="error">Devi accedere nella WebApp prima di usare la pagina Admin.</span>';
167        return;
168      }
169
170      try {
171        const t = await u.getIdToken();
172        try {
173          window.__webappMetrics = await loadWebappMetrics(t);
174        } catch (e) {
175          window.__webappMetrics = null;
176        }
177        const days = Math.max(1, Math.min(60, parseInt(daysEl.value || "7", 10) || 7));
178        const { r, data } = await fetchJson(`/.netlify/functions/admin-activity-summary?days=${days}&limit=120&debug=1`, {
179          headers: { Authorization: `Bearer ${t}` }
180        });
181        if (!data.ok) {
182          statusEl.innerHTML = `<span class="error">Errore: ${escapeHtml(data.error || String(r.status))}</span>`;
183          return;
184        }
185        const cacheKey = "admin_activity_cache_v1";
186        const saveCache = (payload) => {
187          try { localStorage.setItem(cacheKey, JSON.stringify({ savedAt: Date.now(), payload })); } catch (e) {}
188        };
189        const loadCache = () => {
190          try {
191            const raw = localStorage.getItem(cacheKey);
192            if (!raw) return null;
193            const j = JSON.parse(raw);
194            if (!j || !j.payload) return null;
195            return j;
196          } catch (e) {
197            return null;
198          }
199        };
200
201        let effective = data;
202        const hasWarning = Boolean(data && data.warning);
203        const emptyRows = !(data && Array.isArray(data.rows) && data.rows.length);
204        if (hasWarning && emptyRows) {
205          const cached = loadCache();
206          if (cached && cached.payload && Array.isArray(cached.payload.rows) && cached.payload.rows.length) {
207            effective = cached.payload;
208            const when = new Date(cached.savedAt || Date.now()).toLocaleString();
209            statusEl.innerHTML = `<span class="error">${escapeHtml(String(data.warning))}</span> <span class="hint">Mostro ultimi dati salvati (${escapeHtml(when)}).</span>${data && data.debug && data.debug.projectId ? ` <span class="hint">projectId: ${escapeHtml(String(data.debug.projectId))}</span>` : ""}`;
210          } else {
211            statusEl.innerHTML = `<span class="error">${escapeHtml(String(data.warning))}</span>${data && data.debug && data.debug.projectId ? ` <span class="hint">projectId: ${escapeHtml(String(data.debug.projectId))}</span>` : ""}`;
212          }
213        } else {
214          if (data && Array.isArray(data.rows) && data.rows.length) saveCache(data);
215          statusEl.innerHTML = `<span class="ok">OK</span> dal ${new Date(data.since).toLocaleString()} — Utenti: ${data.totalUsers}${data && data.debug && data.debug.projectId ? ` <span class="hint">projectId: ${escapeHtml(String(data.debug.projectId))}</span>` : ""}`;
216        }
217
218        window.__adminRows = Array.isArray(effective.rows) ? effective.rows.slice() : [];
219        window.__adminSummary = effective.summary || null;
220        renderKpis(effective);
221        renderLast();
222      } catch (e) {
223        statusEl.innerHTML = `<span class="error">Errore: ${escapeHtml(e && e.message ? e.message : String(e))}</span>`;
224      }
225    }
226
227    async function loadWebappMetrics(token) {
228      const cacheKey = "admin_webapp_metrics_cache_v1";
229      const loadCache = () => {
230        try {
231          const raw = localStorage.getItem(cacheKey);
232          if (!raw) return null;
233          const j = JSON.parse(raw);
234          if (!j || !j.payload) return null;
235          return j;
236        } catch (e) {
237          return null;
238        }
239      };
240      const saveCache = (payload) => {
241        try { localStorage.setItem(cacheKey, JSON.stringify({ savedAt: Date.now(), payload })); } catch (e) {}
242      };
243
244      const cached = loadCache();
245      if (cached && cached.payload && Number.isFinite(cached.savedAt) && Date.now() - cached.savedAt < 24 * 60 * 60 * 1000) {
246        return cached.payload;
247      }
248
249      const { data } = await fetchJson(`/.netlify/functions/admin-webapp-metrics`, {
250        headers: { Authorization: `Bearer ${token}` }
251      });
252      if (!data || data.ok !== true) throw new Error((data && data.error) || "Errore KPI");
253      saveCache(data);
254      return data;
255    }
256
257    async function scanIntegrity() {
258      scanBtn.disabled = true;
259      const prev = scanBtn.textContent;
260      scanBtn.textContent = "…";
261      try {
262        const u = await waitForUser();
263        if (!u) throw new Error("Devi accedere");
264        const t = await u.getIdToken();
265        const { data } = await fetchJson(`/.netlify/functions/admin-integrity-scan?limitUsers=200`, {
266          headers: { Authorization: `Bearer ${t}` }
267        });
268        if (!data.ok) throw new Error(data.error || "Errore scan");
269        const msg =
270          `Scan completato\n\n` +
271          `Utenti scansionati: ${data.scannedUsers}\n` +
272          `Problemi trovati: ${data.issuesCount}\n` +
273          `Punti non coerenti: ${data.usersWithMismatchedPoints}\n` +
274          `Anfibi/Rettili sconosciuti (tot): ${data.totalUnknownAnfibiRettili}\n\n` +
275          (Array.isArray(data.issues) && data.issues.length
276            ? `Dettagli (prime 12):\n` + data.issues.slice(0, 12).map((it) => {
277                const who = (it.displayName || it.email || it.uid || "").trim();
278                const unk = Number(it.unknown_anfibi_rettili || 0);
279                const mismatch = (it.doc && it.recomputed) ? ((it.doc.points_total !== it.recomputed.points_total) ? " punti!" : "") : "";
280                return `- ${who}: unk anfibi=${unk}${mismatch}`;
281              }).join("\n")
282            : "Nessun problema trovato ✅");
283        alert(msg);
284      } catch (e) {
285        alert(e && e.message ? e.message : String(e));
286      } finally {
287        scanBtn.textContent = prev;
288        scanBtn.disabled = false;
289      }
290    }
291
292    async function scanAndFix() {
293      const ok = confirm(
294        "Vuoi eseguire lo Scan + Auto-fix?\n\n" +
295        "- Ripara specie Anfibi/Rettili solo se può dedurle dai dati salvati (altrimenti usa 'Specie non specificata')\n" +
296        "- Aggiorna i punti ricalcolandoli dalla history\n\n" +
297        "Procedo?"
298      );
299      if (!ok) return;
300
301      fixBtn.disabled = true;
302      const prev = fixBtn.textContent;
303      fixBtn.textContent = "…";
304      try {
305        const u = await waitForUser();
306        if (!u) throw new Error("Devi accedere");
307        const t = await u.getIdToken();
308        const { data } = await fetchJson(`/.netlify/functions/admin-integrity-fix?dryRun=0&limitUsers=200`, {
309          method: "POST",
310          headers: { Authorization: `Bearer ${t}` }
311        });
312        if (!data.ok) throw new Error(data.error || "Errore auto-fix");
313        const msg =
314          `Auto-fix completato\n\n` +
315          `Utenti scansionati: ${data.scannedUsers}\n` +
316          `Utenti con fix applicati: ${data.usersFixed}\n` +
317          `Utenti con punti aggiornati: ${data.usersPointsUpdated}\n` +
318          `Entry fixate: ${data.entriesFixed}\n` +
319          `Entry ancora 'Specie non specificata' (non deducibili): ${data.entriesStillUnknown}\n\n` +
320          "Ora rifai Scan integrità per verifica finale.";
321        alert(msg);
322        await load();
323      } catch (e) {
324        alert(e && e.message ? e.message : String(e));
325      } finally {
326        fixBtn.textContent = prev;
327        fixBtn.disabled = false;
328      }
329    }
330
331    function renderKpis(data) {
332      const rows = Array.isArray(data && data.rows) ? data.rows : [];
333      const since = data && data.since ? new Date(data.since) : null;
334      const days = rows.length ? Number(rows[0].lastDays || 0) : Number(daysEl.value || 7);
335
336      const activeBySubmissions = rows.filter(r => Number(r.submissions_last_days || 0) > 0).length;
337      const activeByLogin = since ? rows.filter(r => {
338        if (!r.lastLogin) return false;
339        const d = new Date(r.lastLogin);
340        return !Number.isNaN(d.getTime()) && d >= since;
341      }).length : 0;
342      const pwaInstalled = rows.filter(r => r.pwa_installed || r.pwa_standalone).length;
343      const os = rows.reduce((acc, r) => {
344        const k = r.device_os || "Altro";
345        acc[k] = (acc[k] || 0) + 1;
346        return acc;
347      }, {});
348      const osText = Object.entries(os).sort((a,b)=>b[1]-a[1]).map(([k,v]) => `${k}: ${v}`).join(" · ");
349
350      const m = window.__webappMetrics;
351      let metricsCards = "";
352      if (m && m.ok === true) {
353        const when = m.updatedAtIso ? new Date(m.updatedAtIso).toLocaleString() : "—";
354        metricsCards =
355          `<div class="kpi"><div class="label">WebApp installate (totale)</div><div class="value">${Number(m.pwa_installed || 0)}</div><div class="sub">Proxy PWA • Aggiornato: ${escapeHtml(when)}</div></div>` +
356          `<div class="kpi"><div class="label">WebApp attive (totale)</div><div class="value">${Number(m.active_last_30d_login || 0)}</div><div class="sub">Login ultimi ${Number(m.windowDays || 30)} giorni</div></div>`;
357      } else {
358        metricsCards =
359          `<div class="kpi"><div class="label">WebApp installate (totale)</div><div class="value">…</div><div class="sub">Aggiorna 1 volta al giorno</div></div>` +
360          `<div class="kpi"><div class="label">WebApp attive (totale)</div><div class="value">…</div><div class="sub">Login ultimi 30 giorni</div></div>`;
361      }
362
363      kpisEl.innerHTML =
364        metricsCards +
365        `<div class="kpi"><div class="label">Utenti in elenco</div><div class="value">${rows.length}</div><div class="sub">Campione (max 120)</div></div>` +
366        `<div class="kpi"><div class="label">Attivi (invii)</div><div class="value">${activeBySubmissions}</div><div class="sub">Invii negli ultimi ${days} giorni</div></div>` +
367        `<div class="kpi"><div class="label">Attivi (login)</div><div class="value">${activeByLogin}</div><div class="sub">Login negli ultimi ${days} giorni</div></div>` +
368        `<div class="kpi"><div class="label">WebApp installata (elenco)</div><div class="value">${pwaInstalled}</div><div class="sub">${osText || "OS: N/D"}</div></div>`;
369    }
370
371    function renderLast() {
372      tbl.innerHTML = "";
373      const q = String(qEl.value || "").trim().toLowerCase();
374      const rows = Array.isArray(window.__adminRows) ? window.__adminRows.slice() : [];
375      rows.sort((a, b) => (b.submissions_last_days || 0) - (a.submissions_last_days || 0));
376      const filtered = q ? rows.filter((r) => {
377        const name = String(r.displayName || "").toLowerCase();
378        const email = String(r.email || "").toLowerCase();
379        return name.includes(q) || email.includes(q);
380      }) : rows;
381
382      filtered.forEach((row) => {
383          const mods = Object.entries(row.modules || {}).map(([k, v]) => `${k}: ${v}`).join(", ");
384          const os = row.device_os || "";
385          const pwa = (row.pwa_installed || row.pwa_standalone) ? "Sì" : "No";
386          const tr = document.createElement("tr");
387          tr.innerHTML =
388            `<td>${escapeHtml(row.displayName || "")}</td>` +
389            `<td>${escapeHtml(row.email || "")}</td>` +
390            `<td>${escapeHtml(row.lastLogin || "")}</td>` +
391            `<td>${escapeHtml(os)}</td>` +
392            `<td>${escapeHtml(pwa)}</td>` +
393            `<td>${Number(row.points_citizen || 0)}</td>` +
394            `<td>${Number(row.points_expert || 0)}</td>` +
395            `<td>${Number(row.submissions_total || 0)}</td>` +
396            `<td>${Number(row.submissions_last_days || 0)}</td>` +
397            `<td>${escapeHtml(mods)}</td>` +
398            `<td><button type="button" data-email="${escapeHtml(row.email || "")}" style="padding:6px 10px;border-radius:10px;border:1px solid #e2e8f0;background:#ffffff;cursor:pointer;">Verifica</button></td>`;
399          tbl.appendChild(tr);
400        });
401    }
402
403    tbl.addEventListener("click", async (ev) => {
404      const btn = ev.target && ev.target.closest ? ev.target.closest("button[data-email]") : null;
405      if (!btn) return;
406      const email = btn.getAttribute("data-email") || "";
407      if (!email) return;
408      btn.disabled = true;
409      const prev = btn.textContent;
410      btn.textContent = "…";
411      try {
412        const u = await waitForUser();
413        if (!u) throw new Error("Devi accedere");
414        const t = await u.getIdToken();
415        const r = await fetch(`/.netlify/functions/admin-user-audit?email=${encodeURIComponent(email)}`, {
416          headers: { Authorization: `Bearer ${t}` }
417        });
418        const data = await r.json();
419        if (!data.ok) throw new Error(data.error || "Errore audit");
420        const ok = data.matches && data.matches.total;
421        const unknownAnfibi = Number((data.recomputed && data.recomputed.unknown_anfibi_rettili) || 0) || 0;
422        const msg =
423          `Audit ${data.displayName || data.email}\n\n` +
424          `Doc punti (tot): ${data.doc.points_total} | Ricalcolati: ${data.recomputed.points_total}\n` +
425          `Citizen: ${data.doc.points_citizen} vs ${data.recomputed.points_citizen}\n` +
426          `Expert: ${data.doc.points_expert} vs ${data.recomputed.points_expert}\n` +
427          `History entries: ${data.recomputed.history_docs}\n` +
428          `Ultima osservazione: ${data.recomputed.lastObservationTimestamp || "N/D"}\n` +
429          `Anfibi/Rettili sconosciuti: ${unknownAnfibi}\n\n` +
430          (ok ? "✅ Punti coerenti" : "⚠️ Punti NON coerenti");
431
432        if (unknownAnfibi > 0) {
433          const doFixSpecies = confirm(
434            msg +
435              "\n\nVuoi riparare le specie Anfibi/Rettili sconosciute?\n" +
436              "Default: Lucertola muraiola (Podarcis muralis)"
437          );
438          if (doFixSpecies) {
439            const fixResp = await fetch(
440              `/.netlify/functions/admin-fix-anfibi-unknowns?email=${encodeURIComponent(email)}&species=${encodeURIComponent("Lucertola muraiola (Podarcis muralis)")}`,
441              { headers: { Authorization: `Bearer ${t}` }, method: "POST" }
442            );
443            const fixData = await fixResp.json();
444            if (!fixData.ok) throw new Error(fixData.error || "Errore riparazione specie");
445            alert(`Specie riparate: ${fixData.fixed}\nPunti totali: ${fixData.updatedPoints.points_total}`);
446            await load();
447            return;
448          }
449        }
450
451        if (ok) {
452          alert(msg);
453          return;
454        }
455
456        const doFix = confirm(msg + "\n\nVuoi riparare i punti (ricalcolo da history)?");
457        if (!doFix) return;
458
459        const fixResp = await fetch(`/.netlify/functions/admin-user-reconcile?email=${encodeURIComponent(email)}`, {
460          headers: { Authorization: `Bearer ${t}` }
461        });
462        const fixData = await fixResp.json();
463        if (!fixData.ok) throw new Error(fixData.error || "Errore riparazione");
464        alert(
465          `Riparazione completata\n\n` +
466          `${fixData.displayName || fixData.email}\n` +
467          `Nuovi punti (tot): ${fixData.updated.points_total}\n` +
468          `Citizen: ${fixData.updated.points_citizen}\n` +
469          `Expert: ${fixData.updated.points_expert}\n` +
470          `Invii: ${fixData.updated.submissions_total}`
471        );
472        await load();
473      } catch (e) {
474        alert(e && e.message ? e.message : String(e));
475      } finally {
476        btn.textContent = prev;
477        btn.disabled = false;
478      }
479    });
480
481    load();
482  </script>
482
483</body>
484</html>

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.