diff --git a/.env.example b/.env.example index aca8b4110..b1cdd018b 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,16 @@ DOMAIN_VALIDATION_KEY=todo_developer_portal # Pi Platform API Key: PI_API_KEY=todo_developer_portal +# Supabase connection: +NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key +SUPABASE_SERVICE_ROLE_KEY=your_service_role_key + +# App separazione database - NON MODIFICARE (identifica questa app nel DB condiviso) +# Le 4 app nel database: app_pionieri | chat_pionieri | marketplace | chat_bot_develop +NEXT_PUBLIC_APP_SOURCE=app_pionieri +NEXT_PUBLIC_ADMIN_USERNAME=cipollas + # Generate a random string, or roll your face on the keyboard to fill this value: SESSION_SECRET=abcd1324_TODO diff --git a/app/api/admin/access-logs/route.ts b/app/api/admin/access-logs/route.ts new file mode 100644 index 000000000..0e5a04ba8 --- /dev/null +++ b/app/api/admin/access-logs/route.ts @@ -0,0 +1,101 @@ +import { NextResponse } from "next/server" +import { getAdmin, APP_SOURCE, ADMIN_USERNAME } from "@/lib/supabase/admin" + +export async function GET(req: Request) { + try { + const { searchParams } = new URL(req.url) + const adminUsername = searchParams.get("adminUsername") + const date = searchParams.get("date") // Format: YYYY-MM-DD + + if (adminUsername !== ADMIN_USERNAME) { + return NextResponse.json({ error: "Non autorizzato" }, { status: 403 }) + } + + const supabase = getAdmin() + + // Costruisce range giornaliero come stringhe pure YYYY-MM-DD + // per evitare problemi di timezone server vs client. + // Supabase confronta TIMESTAMPTZ con stringhe ISO — usiamo T00:00:00 e T23:59:59 + // con offset +00:00 (UTC) cosi' copriamo l'intera giornata indipendentemente dal fuso. + const targetDateStr = date || new Date().toISOString().split("T")[0] + const startOfDay = `${targetDateStr}T00:00:00+00:00` + const endOfDay = `${targetDateStr}T23:59:59.999+00:00` + + // Strategia di query doppia: + // Prima tenta con "logged_at" (script 006), poi fallback su "created_at" (script 002). + // In questo modo funziona sia prima che dopo l'esecuzione dello script 006. + // Il DB puo' avere la colonna chiamata "pi_uid" (script 002) oppure "user_id" (versione precedente). + // Proviamo tutte le combinazioni possibili di colonna ID e colonna timestamp + // finche' non troviamo quella che funziona nel DB reale. + type LogRow = { + id: string + uid: string // valore normalizzato (pi_uid o user_id che sia) + username: string + timestamp: string // valore normalizzato (logged_at o created_at che sia) + app_source: string + } + + let logs: LogRow[] = [] + let resolved = false + + // Combinazioni da tentare in ordine: [colonna_uid, colonna_timestamp] + const combos = [ + ["pi_uid", "logged_at"], + ["pi_uid", "created_at"], + ["user_id", "logged_at"], + ["user_id", "created_at"], + ] + + for (const [uidCol, tsCol] of combos) { + if (resolved) break + + // Usiamo select("*") per evitare il ParserError di TypeScript con select dinamico. + // Filtriamo poi i campi necessari nel mapping. + const { data: rows, error } = await supabase + .from("access_logs") + .select("*") + .eq("app_source", APP_SOURCE) + .gte(tsCol, startOfDay) + .lte(tsCol, endOfDay) + .order(tsCol, { ascending: false }) + + // Verifica che la riga abbia effettivamente la colonna attesa (non undefined) + if (!error && rows && rows.length >= 0 && (rows.length === 0 || rows[0][uidCol] !== undefined)) { + logs = (rows as Record[]).map((r) => ({ + id: String(r.id ?? ""), + uid: String(r[uidCol] ?? ""), + username: String(r.username ?? ""), + timestamp: String(r[tsCol] ?? ""), + app_source: String(r.app_source ?? APP_SOURCE), + })) + resolved = true + } + } + + if (!resolved) { + return NextResponse.json({ error: "Struttura tabella access_logs non riconosciuta. Eseguire gli script di migrazione." }, { status: 500 }) + } + + // Normalizza per il frontend: espone sia user_id che pi_uid e logged_at + const normalizedLogs = logs.map((log) => ({ + id: log.id, + pi_uid: log.uid, + user_id: log.uid, + username: log.username, + logged_at: log.timestamp, + app_source: log.app_source, + })) + + const uniqueUsers = new Set(logs.map((log) => log.uid)) + + return NextResponse.json({ + logs: normalizedLogs, + totalAccesses: normalizedLogs.length, + uniqueUsers: uniqueUsers.size, + date: targetDateStr, + app: APP_SOURCE, + }) + } catch { + return NextResponse.json({ error: "Errore del server" }, { status: 500 }) + } +} diff --git a/app/api/admin/ban/route.ts b/app/api/admin/ban/route.ts new file mode 100644 index 000000000..7f135578d --- /dev/null +++ b/app/api/admin/ban/route.ts @@ -0,0 +1,93 @@ +import { NextResponse } from "next/server" +import { getAdmin, APP_SOURCE, ADMIN_USERNAME } from "@/lib/supabase/admin" + +export async function POST(req: Request) { + try { + const { userId, adminUsername, reason } = await req.json() + if (adminUsername !== ADMIN_USERNAME) { + return NextResponse.json({ error: "Non autorizzato" }, { status: 403 }) + } + + const supabase = getAdmin() + + // Get profile for this app + const { data: profile } = await supabase + .from("profiles") + .select("display_name") + .eq("id", userId) + .eq("app_source", APP_SOURCE) + .single() + + if (!profile) return NextResponse.json({ error: "Utente non trovato" }, { status: 404 }) + + // Get pi_user for this app + const { data: piUser } = await supabase + .from("pi_users") + .select("pi_uid") + .eq("pi_username", profile.display_name) + .eq("app_source", APP_SOURCE) + .maybeSingle() + + if (!piUser) return NextResponse.json({ error: "Pi user non trovato" }, { status: 404 }) + + // Ban user for this specific app only - check if already banned first + const { data: alreadyBanned } = await supabase + .from("banned_users") + .select("id") + .eq("pi_uid", piUser.pi_uid) + .eq("app_source", APP_SOURCE) + .maybeSingle() + + if (!alreadyBanned) { + await supabase.from("banned_users").insert({ + pi_uid: piUser.pi_uid, + username: profile.display_name, + reason: reason || "Violazione regole chat", + app_source: APP_SOURCE, + }) + } + + // Delete all messages from banned user for this app + await supabase + .from("messages") + .delete() + .eq("user_id", userId) + .eq("app_source", APP_SOURCE) + + return NextResponse.json({ success: true }) + } catch { + return NextResponse.json({ error: "Errore del server" }, { status: 500 }) + } +} + +// DELETE - rimuove il ban di un utente per questa app +export async function DELETE(req: Request) { + try { + const { piUid, adminUsername } = await req.json() + + if (adminUsername !== ADMIN_USERNAME) { + return NextResponse.json({ error: "Non autorizzato" }, { status: 403 }) + } + + if (!piUid) { + return NextResponse.json({ error: "pi_uid mancante" }, { status: 400 }) + } + + const supabase = getAdmin() + + // Only unban for this specific app + const { error } = await supabase + .from("banned_users") + .delete() + .eq("pi_uid", piUid) + .eq("app_source", APP_SOURCE) + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ success: true }) + } catch { + return NextResponse.json({ error: "Errore del server" }, { status: 500 }) + } +} diff --git a/app/api/admin/banned/route.ts b/app/api/admin/banned/route.ts new file mode 100644 index 000000000..982d6e113 --- /dev/null +++ b/app/api/admin/banned/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server" +import { getAdmin, APP_SOURCE, ADMIN_USERNAME } from "@/lib/supabase/admin" + +// GET - lista tutti gli utenti bannati per questa app +export async function GET(req: Request) { + try { + const { searchParams } = new URL(req.url) + const adminUsername = searchParams.get("adminUsername") + + if (adminUsername !== ADMIN_USERNAME) { + return NextResponse.json({ error: "Non autorizzato" }, { status: 403 }) + } + + const supabase = getAdmin() + + // Only get banned users for this specific app + const { data, error } = await supabase + .from("banned_users") + .select("id, pi_uid, username, reason, banned_at") + .eq("app_source", APP_SOURCE) + .order("banned_at", { ascending: false }) + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ + bannedUsers: data || [], + app: APP_SOURCE, + }) + } catch { + return NextResponse.json({ error: "Errore del server" }, { status: 500 }) + } +} diff --git a/app/api/messages/route.ts b/app/api/messages/route.ts new file mode 100644 index 000000000..db0bf0cac --- /dev/null +++ b/app/api/messages/route.ts @@ -0,0 +1,119 @@ +import { NextResponse } from "next/server" +import { getAdmin, APP_SOURCE, ADMIN_USERNAME } from "@/lib/supabase/admin" + +export async function GET() { + try { + const supabase = getAdmin() + // Only get messages for this specific app + const { data: messages, error } = await supabase + .from("messages") + .select("id, content, created_at, user_id, reply_to, image_url, audio_url, app_source") + .eq("app_source", APP_SOURCE) + .order("created_at", { ascending: true }) + .limit(200) + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + if (!messages || messages.length === 0) return NextResponse.json([]) + + const userIds = [...new Set(messages.map((m) => m.user_id))] + const { data: profiles } = await supabase + .from("profiles") + .select("id, display_name") + .in("id", userIds) + .eq("app_source", APP_SOURCE) + const profileMap = new Map(profiles?.map((p) => [p.id, p.display_name]) || []) + + // Get replied-to messages + const replyIds = messages.map((m) => m.reply_to).filter(Boolean) + let replyMap = new Map() + if (replyIds.length > 0) { + const { data: replies } = await supabase + .from("messages") + .select("id, content, user_id") + .in("id", replyIds) + .eq("app_source", APP_SOURCE) + if (replies) { + for (const r of replies) { + replyMap.set(r.id, { + content: r.content, + display_name: profileMap.get(r.user_id) || "Pioniere", + }) + } + } + } + + const enriched = messages.map((m) => ({ + ...m, + display_name: profileMap.get(m.user_id) || "Pioniere", + reply_message: m.reply_to ? replyMap.get(m.reply_to) || null : null, + })) + + return NextResponse.json(enriched) + } catch { + return NextResponse.json({ error: "Errore del server" }, { status: 500 }) + } +} + +export async function POST(req: Request) { + try { + const { content, userId, replyTo, imageUrl, audioUrl } = await req.json() + if ((!content?.trim() && !imageUrl && !audioUrl) || !userId) { + return NextResponse.json({ error: "Dati mancanti" }, { status: 400 }) + } + const supabase = getAdmin() + + // Check if banned for this app + const { data: piUser } = await supabase + .from("pi_users") + .select("pi_uid") + .eq("pi_username", + (await supabase.from("profiles").select("display_name").eq("id", userId).single()).data?.display_name + ) + .eq("app_source", APP_SOURCE) + .maybeSingle() + + if (piUser) { + const { data: banned } = await supabase + .from("banned_users") + .select("id") + .eq("pi_uid", piUser.pi_uid) + .eq("app_source", APP_SOURCE) + .maybeSingle() + if (banned) return NextResponse.json({ error: "Utente bannato" }, { status: 403 }) + } + + const insertData: Record = { + content: content?.trim() || "", + user_id: userId, + app_source: APP_SOURCE, + } + if (replyTo) insertData.reply_to = replyTo + if (imageUrl) insertData.image_url = imageUrl + if (audioUrl) insertData.audio_url = audioUrl + + const { error } = await supabase.from("messages").insert(insertData) + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + + return NextResponse.json({ success: true }) + } catch { + return NextResponse.json({ error: "Errore del server" }, { status: 500 }) + } +} + +export async function DELETE(req: Request) { + try { + const { messageId, adminUsername } = await req.json() + if (adminUsername !== ADMIN_USERNAME) return NextResponse.json({ error: "Non autorizzato" }, { status: 403 }) + + const supabase = getAdmin() + // Only delete messages from this app + await supabase + .from("messages") + .delete() + .eq("id", messageId) + .eq("app_source", APP_SOURCE) + return NextResponse.json({ success: true }) + } catch { + return NextResponse.json({ error: "Errore del server" }, { status: 500 }) + } +} diff --git a/app/api/pi/approve/route.ts b/app/api/pi/approve/route.ts new file mode 100644 index 000000000..ab7fa5525 --- /dev/null +++ b/app/api/pi/approve/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server" +import { getAdmin, APP_SOURCE } from "@/lib/supabase/admin" + +export async function POST(req: Request) { + try { + const { paymentId, piUid, username, amount, memo } = await req.json() + + if (!process.env.PI_API_KEY) { + return NextResponse.json({ error: "API key non configurata" }, { status: 500 }) + } + + const res = await fetch(`https://api.minepi.com/v2/payments/${paymentId}/approve`, { + method: "POST", + headers: { + Authorization: `Key ${process.env.PI_API_KEY}`, + "Content-Type": "application/json", + }, + }) + + const data = await res.text() + if (!res.ok) { + return NextResponse.json({ error: `Errore approvazione: ${data}` }, { status: res.status }) + } + + // Save payment to pi_payments table with 'approved' status and app_source + const supabase = getAdmin() + try { + await supabase.from("pi_payments").insert({ + pi_uid: piUid || "unknown", + username: username || "Anonimo", + pi_payment_id: paymentId, + amount: amount || 0, + memo: memo || "Donazione", + status: "approved", + app_source: APP_SOURCE, + }) + } catch { + // DB error non blocca la risposta + } + + return NextResponse.json({ success: true }) + } catch { + return NextResponse.json({ error: "Errore del server" }, { status: 500 }) + } +} diff --git a/app/api/pi/auth/route.ts b/app/api/pi/auth/route.ts new file mode 100644 index 000000000..f3c506621 --- /dev/null +++ b/app/api/pi/auth/route.ts @@ -0,0 +1,181 @@ +import { NextResponse } from "next/server" +import { getAdmin, APP_SOURCE, ADMIN_USERNAME } from "@/lib/supabase/admin" + +const PI_API_KEY = process.env.PI_API_KEY! + +export async function POST(req: Request) { + try { + const body = await req.json() + const { accessToken, user: piUser } = body + + if (!accessToken || !piUser?.uid) { + return NextResponse.json({ error: "Dati mancanti" }, { status: 400 }) + } + + // Verify with Pi Network + const piRes = await fetch("https://api.minepi.com/v2/me", { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + if (!piRes.ok) { + return NextResponse.json({ error: "Token Pi non valido" }, { status: 401 }) + } + const piData = await piRes.json() + + const supabase = getAdmin() + const username = piData.username || piUser.uid + const isAdmin = username === ADMIN_USERNAME + + // Log ALL access attempts per questa app. + // Il DB puo' avere la colonna "pi_uid" (script 002) o "user_id" (versione precedente). + // Proviamo prima con pi_uid, se fallisce usiamo user_id. + const { error: logErr1 } = await supabase.from("access_logs").insert({ + pi_uid: piUser.uid, + username, + app_source: APP_SOURCE, + }) + if (logErr1) { + await supabase.from("access_logs").insert({ + user_id: piUser.uid, + username, + app_source: APP_SOURCE, + }) + } + + // Admin bypasses all checks + if (!isAdmin) { + // ------------------------------------------------------------------- + // KYC & MIGRATION CHECK — logica strict + // + // Fonte primaria: piData (risposta verificata da /v2/me tramite Pi API Key) + // Fonte secondaria: piUser.credentials (dati lato client — NON affidabili da soli) + // + // Regole di accesso: + // ACCESSO CONSENTITO se: + // (A) kycStatus === "approved" / "APPROVED" (KYC pieno) + // (B) kycStatus === "provisional" / "PROVISIONAL" + // AND hasMigrated === true (KYC provvisorio + migrazione) + // + // ACCESSO NEGATO in tutti gli altri casi, incluso quando + // Pi SDK non ritorna kycStatus (comportamento strict per sicurezza) + // ------------------------------------------------------------------- + + const credentials = piData.credentials || piUser.credentials || {} + + // Raccoglie kycStatus da tutte le posizioni note (Pi SDK v1/v2/v2.0) + const rawKycStatus: string | undefined = + piData.kyc_verification_status || + credentials.kyc_verification_status || + credentials.kyc_status || + piUser.kyc_verification_status || + undefined + + const normalizedKyc = rawKycStatus?.toLowerCase() + + const kycApproved = + normalizedKyc === "approved" || + piData.kyc_verified === true || + credentials.kyc_verified === true + + const kycProvisional = normalizedKyc === "provisional" + + // Raccoglie migrazione da tutte le posizioni note + const hasMigrated: boolean = + piData.has_migrated === true || + credentials.has_migrated === true || + piUser.has_migrated === true || + credentials.migration_status === "completed" || + piData.migration_status === "completed" + + // Caso A: KYC approvato completamente — accesso consentito + if (kycApproved) { + // pass — continua il flusso + } + // Caso B: KYC provvisorio + migrazione completata — accesso consentito + else if (kycProvisional && hasMigrated) { + // pass — continua il flusso + } + // Caso C: KYC provvisorio ma migrazione NON completata — blocco + else if (kycProvisional && !hasMigrated) { + return NextResponse.json({ + error: "Hai il KYC provvisorio ma non hai ancora completato la prima migrazione. Completa la migrazione su Pi Browser per accedere.", + code: "MIGRATION_REQUIRED", + kycStatus: rawKycStatus, + }, { status: 403 }) + } + // Caso D: KYC esplicitamente negativo (pending, rejected, ecc.) + else if (rawKycStatus !== undefined) { + return NextResponse.json({ + error: "KYC non verificato. Devi avere il KYC approvato (o provvisorio con migrazione completata) per accedere.", + code: "KYC_NOT_VERIFIED", + kycStatus: rawKycStatus, + }, { status: 403 }) + } + // Caso E: Pi SDK non ha restituito alcun dato KYC — blocco strict + else { + return NextResponse.json({ + error: "Impossibile verificare il tuo KYC. Assicurati di usare Pi Browser aggiornato e riprova.", + code: "KYC_UNAVAILABLE", + }, { status: 403 }) + } + } + + // Check if banned for this app + const { data: banned } = await supabase + .from("banned_users") + .select("id") + .eq("pi_uid", piUser.uid) + .eq("app_source", APP_SOURCE) + .maybeSingle() + + if (banned) { + return NextResponse.json({ error: "Utente bannato dalla chat" }, { status: 403 }) + } + + // Upsert pi_users with app_source + // onConflict: "pi_uid,app_source" — dopo lo script 007 il UNIQUE e' su (pi_uid, app_source) + // cosi' lo stesso utente Pi puo' avere un record separato per ogni app, senza sovrascrivere + await supabase.from("pi_users").upsert({ + pi_uid: piUser.uid, + pi_username: username, + access_token: accessToken, + is_admin: isAdmin, + app_source: APP_SOURCE, + }, { onConflict: "pi_uid,app_source" }) + + // Upsert auth user + profile + const email = `${piUser.uid}@pi.user` + const { data: authData } = await supabase.auth.admin.listUsers() + let userId: string + + const existing = authData?.users?.find((u) => u.email === email) + if (existing) { + userId = existing.id + } else { + const { data: newUser, error } = await supabase.auth.admin.createUser({ + email, + password: piUser.uid + "_pi_secret_2024", + email_confirm: true, + }) + if (error || !newUser.user) { + return NextResponse.json({ error: "Errore creazione utente" }, { status: 500 }) + } + userId = newUser.user.id + } + + // Upsert profile with app_source + await supabase.from("profiles").upsert({ + id: userId, + display_name: username, + app_source: APP_SOURCE, + }, { onConflict: "id" }) + + return NextResponse.json({ + userId, + username, + piUid: piUser.uid, + isAdmin, + }) + } catch { + return NextResponse.json({ error: "Errore del server" }, { status: 500 }) + } +} diff --git a/app/api/pi/complete/route.ts b/app/api/pi/complete/route.ts new file mode 100644 index 000000000..db28cd973 --- /dev/null +++ b/app/api/pi/complete/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from "next/server" +import { getAdmin, APP_SOURCE } from "@/lib/supabase/admin" + +export async function POST(req: Request) { + try { + const { paymentId, txid } = await req.json() + + if (!process.env.PI_API_KEY) { + return NextResponse.json({ error: "API key non configurata" }, { status: 500 }) + } + + const res = await fetch(`https://api.minepi.com/v2/payments/${paymentId}/complete`, { + method: "POST", + headers: { + Authorization: `Key ${process.env.PI_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ txid }), + }) + + const data = await res.text() + if (!res.ok) { + return NextResponse.json({ error: `Errore completamento: ${data}` }, { status: res.status }) + } + + // Update payment status to completed in pi_payments table + const supabase = getAdmin() + try { + await supabase + .from("pi_payments") + .update({ + status: "completed", + tx_id: txid, + completed_at: new Date().toISOString() + }) + .eq("pi_payment_id", paymentId) + .eq("app_source", APP_SOURCE) + } catch { + // DB error non blocca la risposta + } + + return NextResponse.json({ success: true }) + } catch { + return NextResponse.json({ error: "Errore del server" }, { status: 500 }) + } +} diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts new file mode 100644 index 000000000..d0ddcaca2 --- /dev/null +++ b/app/api/upload/route.ts @@ -0,0 +1,37 @@ +import { put } from "@vercel/blob" +import { type NextRequest, NextResponse } from "next/server" + +export async function POST(request: NextRequest) { + try { + const formData = await request.formData() + const file = formData.get("file") as File + + if (!file) { + return NextResponse.json({ error: "Nessun file fornito" }, { status: 400 }) + } + + // Check file type + const isImage = file.type.startsWith("image/") + const isAudio = file.type.startsWith("audio/") + + if (!isImage && !isAudio) { + return NextResponse.json({ error: "Solo immagini e audio sono permessi" }, { status: 400 }) + } + + // Validate file size + const maxSize = isAudio ? 10 * 1024 * 1024 : 5 * 1024 * 1024 + if (file.size > maxSize) { + return NextResponse.json({ error: `File troppo grande (max ${isAudio ? "10MB" : "5MB"})` }, { status: 400 }) + } + + const folder = isAudio ? "chat-audio" : "chat-images" + const blob = await put(`${folder}/${Date.now()}-${file.name}`, file, { + access: "public", + }) + + return NextResponse.json({ url: blob.url }) + } catch (error) { + console.error("Upload error:", error) + return NextResponse.json({ error: "Errore upload" }, { status: 500 }) + } +} diff --git a/app/auth/actions.ts b/app/auth/actions.ts new file mode 100644 index 000000000..7ab614d57 --- /dev/null +++ b/app/auth/actions.ts @@ -0,0 +1,7 @@ +"use server" + +import { redirect } from "next/navigation" + +export async function signOutAction() { + redirect("/auth/login") +} diff --git a/app/auth/login/page.tsx b/app/auth/login/page.tsx new file mode 100644 index 000000000..d588b1741 --- /dev/null +++ b/app/auth/login/page.tsx @@ -0,0 +1,133 @@ +"use client" + +import { useEffect, useState, useRef } from "react" + +type PiSDK = { + init: (config: { version: string; sandbox: boolean }) => void + authenticate: (scopes: string[], onIncomplete: () => void) => Promise<{ accessToken: string; user: { uid: string } }> +} + +function LoginForm() { + const [loading, setLoading] = useState(false) + const [status, setStatus] = useState("") + const [error, setError] = useState("") + const [sdkReady, setSdkReady] = useState(false) + const piRef = useRef(null) + + useEffect(() => { + const session = localStorage.getItem("pi_session") + if (session) window.location.href = "/chat" + + // Initialize Pi SDK on mount + function initPiSDK() { + const Pi = (window as unknown as Record).Pi as PiSDK | undefined + if (Pi) { + try { + Pi.init({ version: "2.0", sandbox: false }) + piRef.current = Pi + setSdkReady(true) + } catch { + // SDK might already be initialized, that's ok + piRef.current = Pi + setSdkReady(true) + } + } else { + // SDK not loaded yet, retry in 500ms + setTimeout(initPiSDK, 500) + } + } + initPiSDK() + }, []) + + async function handlePiLogin() { + const Pi = piRef.current + + if (!Pi) { + setError("Pi SDK non disponibile. Apri nel Pi Browser.") + return + } + + setLoading(true) + setError("") + setStatus("Connessione a Pi Network...") + + try { + const auth = await Pi.authenticate(["username", "payments", "wallet_address"], () => {}) + setStatus("Verifica credenziali...") + + const res = await fetch("/api/pi/auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accessToken: auth.accessToken, user: auth.user }), + }) + + const data = await res.json() + if (!res.ok) { + setError(data.error || "Errore di autenticazione") + setLoading(false) + return + } + + setStatus("Accesso riuscito! Reindirizzamento...") + localStorage.setItem("pi_session", JSON.stringify({ + userId: data.userId, + username: data.username, + piUid: data.piUid, + isAdmin: data.isAdmin, + })) + + await new Promise((r) => setTimeout(r, 300)) + window.location.href = "/chat" + } catch { + setError("Errore durante il login. Riprova.") + setLoading(false) + } + } + + return ( +
+
+ Pi +
+ +

App Pionieri

+

+ App esclusiva per Pionieri verificati con KYC approvato o provvisorio e prima migrazione completata +

+ + + + {error &&

{error}

} + +
+

Requisiti di accesso:

+
    +
  • + KYC approvato o provvisorio su Pi Network +
  • +
  • + Prima migrazione al Mainnet completata +
  • +
  • + Accesso tramite Pi Browser +
  • +
+
+ + +
+ ) +} + +export default function LoginPage() { + return +} diff --git a/app/chat/accessi/page.tsx b/app/chat/accessi/page.tsx new file mode 100644 index 000000000..087ecad5f --- /dev/null +++ b/app/chat/accessi/page.tsx @@ -0,0 +1,161 @@ +"use client" + +import { useEffect, useState } from "react" +import { useRouter } from "next/navigation" + +interface AccessLog { + id: string + user_id: string + username: string + logged_at: string +} + +interface AccessData { + logs: AccessLog[] + totalAccesses: number + uniqueUsers: number +} + +export default function AccessiPage() { + const router = useRouter() + const [data, setData] = useState(null) + const [selectedDate, setSelectedDate] = useState(() => { + return new Date().toISOString().split("T")[0] + }) + const [isAdmin, setIsAdmin] = useState(false) + const [adminUsername, setAdminUsername] = useState(null) + const [loading, setLoading] = useState(true) + const [authChecked, setAuthChecked] = useState(false) + + useEffect(() => { + // Check if user is admin — redirect immediato se non autorizzato + const session = localStorage.getItem("pi_session") + if (session) { + try { + const parsed = JSON.parse(session) + if (parsed.isAdmin === true) { + setIsAdmin(true) + setAdminUsername(parsed.username) + } else { + // Non e' admin: redirect a /chat + router.replace("/chat") + return + } + } catch { + router.replace("/chat") + return + } + } else { + // Nessuna sessione: redirect a login + router.replace("/auth/login") + return + } + setAuthChecked(true) + }, [router]) + + useEffect(() => { + async function loadLogs() { + if (!adminUsername) return + setLoading(true) + try { + const res = await fetch(`/api/admin/access-logs?date=${selectedDate}&adminUsername=${encodeURIComponent(adminUsername)}`) + if (res.ok) { + const result = await res.json() + setData(result) + } + } catch { + // Handle error + } finally { + setLoading(false) + } + } + loadLogs() + }, [selectedDate, adminUsername]) + + function formatTime(dateStr: string) { + const date = new Date(dateStr) + return date.toLocaleTimeString("it-IT", { hour: "2-digit", minute: "2-digit" }) + } + + function formatDate(dateStr: string) { + const date = new Date(dateStr) + return date.toLocaleDateString("it-IT", { day: "numeric", month: "short", year: "numeric" }) + } + + // Non renderizza nulla finche' l'auth non e' verificata (evita flash di contenuto) + if (!authChecked || !isAdmin) { + return ( +
+

Verifica accesso...

+
+ ) + } + + return ( +
+
+
+

Registro Accessi

+

Solo admin

+
+ + Indietro + +
+ +
+ {/* Date selector */} +
+ + setSelectedDate(e.target.value)} + className="rounded-lg border border-border bg-card px-3 py-2 text-foreground" + /> +
+ + {/* Stats */} + {data && ( +
+
+

{data.totalAccesses}

+

Accessi totali

+
+
+

{data.uniqueUsers}

+

Utenti unici

+
+
+ )} + + {/* Logs list */} +
+
+

Accessi del {formatDate(selectedDate)}

+
+ + {loading ? ( +
Caricamento...
+ ) : data?.logs.length === 0 ? ( +
Nessun accesso registrato
+ ) : ( +
+ {data?.logs.map((log) => ( +
+
+

{log.username}

+

+ Pi UID: {(log.user_id || "").substring(0, 10)}... +

+
+

{formatTime(log.logged_at)}

+
+ ))} +
+ )} +
+
+
+ ) +} diff --git a/app/chat/admin/page.tsx b/app/chat/admin/page.tsx new file mode 100644 index 000000000..d34f7b0bc --- /dev/null +++ b/app/chat/admin/page.tsx @@ -0,0 +1,259 @@ +"use client" + +import { useEffect, useState } from "react" +import { useRouter } from "next/navigation" + +// Importiamo le costanti centrali per evitare disallineamenti con le altre app +const ADMIN_USERNAME = process.env.NEXT_PUBLIC_ADMIN_USERNAME || "cipollas" +const APP_SOURCE = process.env.NEXT_PUBLIC_APP_SOURCE || "app_pionieri" + +interface AccessLog { + id: string + user_id: string + username: string + logged_at: string +} + +interface LogsData { + logs: AccessLog[] + totalAccesses: number + uniqueUsers: number + date: string + app: string +} + +interface BannedUser { + id: string + pi_uid: string + username: string + reason: string + banned_at: string +} + +type Tab = "accessi" | "bannati" + +export default function AdminPage() { + const router = useRouter() + const [logsData, setLogsData] = useState(null) + const [bannedUsers, setBannedUsers] = useState([]) + const [loading, setLoading] = useState(true) + const [loadError, setLoadError] = useState(null) + const [selectedDate, setSelectedDate] = useState(() => new Date().toISOString().split("T")[0]) + const [username, setUsername] = useState("") + const [isAdmin, setIsAdmin] = useState(false) + const [activeTab, setActiveTab] = useState("accessi") + const [unbanning, setUnbanning] = useState(null) + + useEffect(() => { + const session = localStorage.getItem("pi_session") + if (!session) { + router.push("/auth/login") + return + } + const parsed = JSON.parse(session) + if (parsed.username !== ADMIN_USERNAME) { + router.push("/chat") + return + } + setUsername(parsed.username) + setIsAdmin(true) + }, [router]) + + useEffect(() => { + if (!isAdmin || !username) return + loadLogs() + }, [isAdmin, username, selectedDate]) + + useEffect(() => { + if (!isAdmin || !username || activeTab !== "bannati") return + loadBanned() + }, [isAdmin, username, activeTab]) + + async function loadLogs() { + setLoading(true) + setLoadError(null) + try { + const res = await fetch(`/api/admin/access-logs?adminUsername=${username}&date=${selectedDate}`) + const data = await res.json() + if (res.ok) { + setLogsData(data) + } else { + setLoadError(data.error || "Errore caricamento accessi") + } + } catch { + setLoadError("Errore di rete — riprova") + } + setLoading(false) + } + + async function loadBanned() { + setLoading(true) + const res = await fetch(`/api/admin/banned?adminUsername=${username}`) + if (res.ok) { + const data = await res.json() + setBannedUsers(data.bannedUsers || []) + } + setLoading(false) + } + + async function handleUnban(piUid: string) { + setUnbanning(piUid) + const res = await fetch("/api/admin/ban", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ piUid, adminUsername: username }), + }) + if (res.ok) { + setBannedUsers((prev) => prev.filter((u) => u.pi_uid !== piUid)) + } + setUnbanning(null) + } + + if (!isAdmin) { + return ( +
+

Verifica accesso...

+
+ ) + } + + return ( +
+ {/* Header */} +
+
+

Admin - App Pionieri

+

{APP_SOURCE} · {username}

+
+ +
+ + {/* Tabs */} +
+ + +
+ +
+ {/* TAB ACCESSI */} + {activeTab === "accessi" && ( + <> +
+ + setSelectedDate(e.target.value)} + className="w-full max-w-xs rounded-lg border border-border bg-background px-3 py-2 text-base text-foreground" + /> +
+ + {logsData && ( +
+
+

{logsData.totalAccesses}

+

Accessi totali

+
+
+

{logsData.uniqueUsers}

+

Utenti unici

+
+
+ )} + +
+
+

Accessi del {selectedDate}

+
+
+ {loading ? ( +
Caricamento...
+ ) : loadError ? ( +
{loadError}
+ ) : logsData?.logs.length === 0 ? ( +
Nessun accesso in questa data
+ ) : ( +
    + {logsData?.logs.map((log) => ( +
  • +
    +

    {log.username}

    +

    ID: {log.user_id.slice(0, 8)}...

    +
    +

    + {new Date(log.logged_at).toLocaleTimeString("it-IT", { + hour: "2-digit", + minute: "2-digit", + })} +

    +
  • + ))} +
+ )} +
+
+ + )} + + {/* TAB BANNATI */} + {activeTab === "bannati" && ( +
+
+

Utenti bannati ({bannedUsers.length})

+

Solo ban per {APP_SOURCE}

+
+
+ {loading ? ( +
Caricamento...
+ ) : bannedUsers.length === 0 ? ( +
Nessun utente bannato
+ ) : ( +
    + {bannedUsers.map((user) => ( +
  • +
    +

    {user.username}

    +

    PI: {user.pi_uid.slice(0, 12)}...

    +

    {user.reason}

    +
    + +
  • + ))} +
+ )} +
+
+ )} +
+
+ ) +} diff --git a/app/chat/page.tsx b/app/chat/page.tsx new file mode 100644 index 000000000..b0d0f8bd2 --- /dev/null +++ b/app/chat/page.tsx @@ -0,0 +1,41 @@ +"use client" + +import { useEffect, useState } from "react" +import { ChatRoom } from "@/components/chat-room" + +interface Session { + userId: string + username: string + piUid: string + isAdmin: boolean +} + +export default function ChatPage() { + const [session, setSession] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + const raw = localStorage.getItem("pi_session") + if (!raw) { + window.location.href = "/auth/login" + return + } + try { + setSession(JSON.parse(raw)) + } catch { + localStorage.removeItem("pi_session") + window.location.href = "/auth/login" + } + setLoading(false) + }, []) + + if (loading || !session) { + return ( +
+
+
+ ) + } + + return +} diff --git a/app/chat/payment/page.tsx b/app/chat/payment/page.tsx new file mode 100644 index 000000000..50c175c37 --- /dev/null +++ b/app/chat/payment/page.tsx @@ -0,0 +1,195 @@ +"use client" + +import { useState, useEffect, useRef } from "react" + +type PiSDK = { + init: (config: { version: string; sandbox: boolean }) => void + authenticate: (scopes: string[], onIncomplete: () => void) => Promise<{ user?: { uid?: string; username?: string } }> + createPayment: (data: Record, callbacks: Record) => void +} + +const QUICK_AMOUNTS = [0.1, 0.5, 1, 5, 10] + +export default function PaymentPage() { + const [status, setStatus] = useState<"idle" | "processing" | "success" | "error">("idle") + const [errorMsg, setErrorMsg] = useState("") + const [amount, setAmount] = useState("") + const [sdkReady, setSdkReady] = useState(false) + const piRef = useRef(null) + + useEffect(() => { + // Initialize Pi SDK on mount + function initPiSDK() { + const Pi = (window as unknown as Record).Pi as PiSDK | undefined + if (Pi) { + try { + Pi.init({ version: "2.0", sandbox: false }) + piRef.current = Pi + setSdkReady(true) + } catch { + // SDK might already be initialized, that's ok + piRef.current = Pi + setSdkReady(true) + } + } else { + // SDK not loaded yet, retry in 500ms + setTimeout(initPiSDK, 500) + } + } + initPiSDK() + }, []) + + function selectQuickAmount(value: number) { + setAmount(value.toString()) + } + + async function handlePayment() { + const parsedAmount = parseFloat(amount) + if (!amount || isNaN(parsedAmount) || parsedAmount <= 0) { + setErrorMsg("Inserisci un importo valido maggiore di 0") + setStatus("error") + return + } + + const Pi = piRef.current + + if (!Pi) { + setErrorMsg("Pi SDK non disponibile. Apri nel Pi Browser.") + setStatus("error") + return + } + + setStatus("processing") + setErrorMsg("") + + try { + const authResult = await Pi.authenticate(["payments", "username"], () => {}) + const piUid = authResult?.user?.uid || "unknown" + const username = authResult?.user?.username || "Anonimo" + const memo = `Donazione ${parsedAmount} Pi - App Pionieri` + + Pi.createPayment( + { amount: parsedAmount, memo, metadata: { purpose: "donation" } }, + { + onReadyForServerApproval: async (paymentId: string) => { + const res = await fetch("/api/pi/approve", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ paymentId, piUid, username, amount: parsedAmount, memo }), + }) + if (!res.ok) { + const data = await res.json().catch(() => ({})) + console.error("Approve error:", data) + setErrorMsg(data.error || "Errore approvazione pagamento") + setStatus("error") + } + }, + onReadyForServerCompletion: async (paymentId: string, txid: string) => { + const res = await fetch("/api/pi/complete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ paymentId, txid }), + }) + if (!res.ok) { + const data = await res.json().catch(() => ({})) + console.error("Complete error:", data) + setErrorMsg(data.error || "Errore completamento pagamento") + setStatus("error") + return + } + setStatus("success") + }, + onCancel: () => setStatus("idle"), + onError: (err: Error) => { + setErrorMsg(err?.message || "Errore durante il pagamento") + setStatus("error") + }, + } + ) + } catch (err: unknown) { + setErrorMsg(err instanceof Error ? err.message : "Errore sconosciuto") + setStatus("error") + } + } + + return ( +
+
+ Indietro +

Donazione Pi

+
+ +
+
+ Pi +
+

Aiuta o supporta l'app con una donazione

+

+ Scegli un contributo per il supporto e aggiornamento dell'app, non è obbligatorio il supporto o aiuto. +

+ + {/* Quick amount buttons */} +
+ {QUICK_AMOUNTS.map((val) => ( + + ))} +
+ + {/* Custom amount input */} +
+ +
+ setAmount(e.target.value)} + className="w-full bg-transparent text-lg font-bold text-foreground outline-none" + style={{ fontSize: "18px" }} + /> + Pi +
+
+ + + + {errorMsg &&

{errorMsg}

} + {status === "success" && ( +
+

Donazione completata con successo!

+ +
+ )} +
+
+ ) +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 000000000..2db1d7497 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,31 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 40 33% 95%; + --foreground: 0 0% 5%; + --card: 40 30% 98%; + --card-foreground: 0 0% 5%; + --primary: 36 100% 50%; + --primary-foreground: 0 0% 100%; + --secondary: 40 20% 92%; + --secondary-foreground: 0 0% 5%; + --muted: 40 15% 90%; + --muted-foreground: 0 0% 40%; + --accent: 36 100% 50%; + --accent-foreground: 0 0% 100%; + --destructive: 0 84% 60%; + --destructive-foreground: 0 0% 98%; + --border: 40 15% 85%; + --input: 40 15% 85%; + --ring: 36 100% 50%; + --radius: 0.75rem; + } +} + +@layer base { + * { @apply border-border; } + body { @apply bg-background text-foreground; } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 000000000..fa7133ec1 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,31 @@ +import type { Metadata, Viewport } from "next" +import { Inter } from "next/font/google" +import { SpeedInsights } from "@vercel/speed-insights/next" +import "./globals.css" + +const inter = Inter({ subsets: ["latin"], variable: "--font-inter" }) + +export const metadata: Metadata = { + title: "App Pionieri - Pi Network", + description: "App esclusiva per Pionieri verificati Pi Network", +} + +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + maximumScale: 1, +} + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + +