diff --git a/shop/app/admin/tools/page.tsx b/shop/app/admin/tools/page.tsx new file mode 100644 index 0000000..63a3c46 --- /dev/null +++ b/shop/app/admin/tools/page.tsx @@ -0,0 +1,283 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + getDatabaseIntegrity, + repairDatabaseSchema, + optimizeDatabaseIndices +} from "@/lib/actions/admin"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Loader2, RefreshCw, Wrench, Database, AlertTriangle, CheckCircle2, XCircle, Terminal, ArrowLeft } from "lucide-react"; +import Link from "next/link"; + +interface TableStatus { + exists: boolean; + count: number; +} + +interface IntegrityData { + tables: { + profiles: TableStatus; + end_customers: TableStatus; + pos_modules: TableStatus; + licenses: TableStatus; + }; + errors: { + foreign_keys: number; + }; +} + +export default function AdminToolsPage() { + const [integrity, setIntegrity] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [logs, setLogs] = useState([]); + const [activeAction, setActiveAction] = useState(null); + + const addLog = (message: string) => { + const timestamp = new Date().toLocaleTimeString(); + setLogs((prev) => [...prev, `[${timestamp}] ${message}`]); + }; + + const loadStatus = async (quiet = false) => { + if (!quiet) setIsLoading(true); + addLog("Starte Integritätsscan der Datenbank..."); + const res = await getDatabaseIntegrity(); + if (res.success && res.data) { + setIntegrity(res.data as IntegrityData); + addLog("Integritätsscan erfolgreich abgeschlossen."); + } else { + addLog(`FEHLER beim Scannen: ${res.error}`); + } + if (!quiet) setIsLoading(false); + }; + + const handleRepair = async () => { + setIsLoading(true); + setActiveAction("repair"); + addLog("Starte automatische Schemareparatur..."); + const res = await repairDatabaseSchema(); + if (res.success && res.data) { + const repaired = (res.data as any).repaired || []; + if (repaired.length > 0) { + addLog(`Erfolgreich reparierte Tabellen: ${repaired.join(", ")}`); + } else { + addLog("Keine fehlenden Tabellen gefunden. Schema intakt."); + } + await loadStatus(true); + } else { + addLog(`FEHLER bei Schemareparatur: ${res.error}`); + } + setIsLoading(false); + setActiveAction(null); + }; + + const handleOptimize = async () => { + setIsLoading(true); + setActiveAction("optimize"); + addLog("Starte Reindexierung des Schemas public..."); + const res = await optimizeDatabaseIndices(); + if (res.success) { + addLog("Reindexierung erfolgreich abgeschlossen. Suchindizes neu aufgebaut."); + } else { + addLog(`FEHLER bei Reindexierung: ${res.error}`); + } + setIsLoading(false); + setActiveAction(null); + }; + + useEffect(() => { + loadStatus(); + }, []); + + return ( +
+ {/* Background decoration */} +
+ + {/* Loading Overlay */} + {isLoading && ( +
+ +

+ {activeAction === "repair" + ? "Schema wird repariert..." + : activeAction === "optimize" + ? "Datenbank wird indexiert..." + : "Verbindung zur Datenbank wird aufgebaut..."} +

+
+ )} + +
+ {/* Header */} +
+ + + +
+

+ + Admin Datenbank-Cockpit +

+

+ Verwalten und reparieren Sie Systemtabellen, Sicherheitsrichtlinien (RLS) und Datenbank-Indizes. +

+
+
+ + {/* Action Controls */} +
+ + + + + Integritätsscan + + + Prüft die physische Existenz der Tabellen und zählt deren Zeilenanzahl. + + + + + + + + + + + + Schema reparieren + + + Erstellt fehlende Kerntabellen automatisch neu und konfiguriert die RLS-Rechte. + + + + + + + + + + + + Index optimieren + + + Baut korrupte Suchindizes im Schema public mittels REINDEX neu auf. + + + + + + +
+ + {/* Tabellenstatus */} + + + Tabellen-Status & Diagnose + + +
+ {integrity?.tables ? ( + Object.entries(integrity.tables).map(([name, status]) => ( +
+ + {name.replace("_", " ")} + +
+ Zeilen: {status.count} + {status.exists ? ( + + OK + + ) : ( + + Fehlt + + )} +
+
+ )) + ) : ( +
+ Lade Diagnosedaten... +
+ )} +
+ + {/* Foreign Key Errors */} + {integrity && integrity.errors.foreign_keys > 0 && ( +
+ +
+

Fremdschlüssel-Fehler gefunden!

+

+ Es wurden {integrity.errors.foreign_keys} verwaiste Lizenzen ohne zugeordneten Endkunden in der Tabelle `licenses` detektiert. +

+
+
+ )} +
+
+ + {/* Console / Output logs */} + + + + + Diagnose-Konsole + + + + +
+ {logs.length === 0 ? ( + Konsole bereit. Führen Sie einen Scan aus. + ) : ( + logs.map((log, index) => ( +
+ {log} +
+ )) + )} +
+
+
+
+
+ ); +} diff --git a/shop/lib/actions/admin.ts b/shop/lib/actions/admin.ts new file mode 100644 index 0000000..2426bec --- /dev/null +++ b/shop/lib/actions/admin.ts @@ -0,0 +1,49 @@ +'use server' + +import { createClient } from '@/lib/supabase/server' +import { verifyAdmin } from '@/lib/actions/auth' + +export async function getDatabaseIntegrity() { + try { + await verifyAdmin() + const supabase = await createClient() + const { data, error } = await supabase.rpc('check_database_integrity') + if (error) { + return { success: false, error: error.message } + } + return { success: true, data } + } catch (err: any) { + console.error("Error in getDatabaseIntegrity:", err) + return { success: false, error: err.message || "Unerwarteter Fehler beim Integritätsscan." } + } +} + +export async function repairDatabaseSchema() { + try { + await verifyAdmin() + const supabase = await createClient() + const { data, error } = await supabase.rpc('repair_database_schema') + if (error) { + return { success: false, error: error.message } + } + return { success: true, data } + } catch (err: any) { + console.error("Error in repairDatabaseSchema:", err) + return { success: false, error: err.message || "Unerwarteter Fehler bei der Schemareparatur." } + } +} + +export async function optimizeDatabaseIndices() { + try { + await verifyAdmin() + const supabase = await createClient() + const { data, error } = await supabase.rpc('optimize_database_indices') + if (error) { + return { success: false, error: error.message } + } + return { success: true, data } + } catch (err: any) { + console.error("Error in optimizeDatabaseIndices:", err) + return { success: false, error: err.message || "Unerwarteter Fehler bei der Indexoptimierung." } + } +} diff --git a/shop/lib/actions/auth.ts b/shop/lib/actions/auth.ts index 99fdaac..23e7856 100644 --- a/shop/lib/actions/auth.ts +++ b/shop/lib/actions/auth.ts @@ -98,3 +98,20 @@ export async function updatePassword(password: string) { } return { success: true } } + +export async function verifyAdmin() { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) throw new Error("Nicht authentifiziert.") + + const { data: userData, error } = await supabase + .from('users') + .select('role') + .eq('id', user.id) + .single() + + if (error || !userData || userData.role !== 'admin') { + throw new Error("Zugriff verweigert. Nur Administratoren erlaubt.") + } + return user +} diff --git a/shop/lib/supabase/proxy.ts b/shop/lib/supabase/proxy.ts index 869a10d..f5ae81e 100644 --- a/shop/lib/supabase/proxy.ts +++ b/shop/lib/supabase/proxy.ts @@ -63,6 +63,27 @@ export async function updateSession(request: NextRequest) { return NextResponse.redirect(url); } + // Admin protection + if (request.nextUrl.pathname.startsWith("/admin")) { + if (!user || !user.sub) { + const url = request.nextUrl.clone(); + url.pathname = "/auth/login"; + return NextResponse.redirect(url); + } + + const { data: dbUser } = await supabase + .from("users") + .select("role") + .eq("id", user.sub) + .single(); + + if (!dbUser || dbUser.role !== "admin") { + const url = request.nextUrl.clone(); + url.pathname = "/"; + return NextResponse.redirect(url); + } + } + // IMPORTANT: You *must* return the supabaseResponse object as it is. // If you're creating a new response object with NextResponse.next() make sure to: // 1. Pass the request in it, like so: diff --git a/shop/supabase/migrations/20260624000000_integrity_functions.sql b/shop/supabase/migrations/20260624000000_integrity_functions.sql new file mode 100644 index 0000000..3527fa9 --- /dev/null +++ b/shop/supabase/migrations/20260624000000_integrity_functions.sql @@ -0,0 +1,195 @@ +-- CREATE FUNCTION public.check_database_integrity +CREATE OR REPLACE FUNCTION public.check_database_integrity() +RETURNS jsonb +SECURITY DEFINER +AS $$ +DECLARE + profiles_exist boolean; + end_customers_exist boolean; + pos_modules_exist boolean; + licenses_exist boolean; + + profiles_count bigint := 0; + end_customers_count bigint := 0; + pos_modules_count bigint := 0; + licenses_count bigint := 0; + + fk_errors_count bigint := 0; + result jsonb; +BEGIN + -- Check table existence + SELECT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = 'profiles') INTO profiles_exist; + SELECT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = 'end_customers') INTO end_customers_exist; + SELECT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = 'pos_modules') INTO pos_modules_exist; + SELECT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = 'licenses') INTO licenses_exist; + + -- Count rows if table exists + IF profiles_exist THEN + EXECUTE 'SELECT count(*) FROM public.profiles' INTO profiles_count; + END IF; + + IF end_customers_exist THEN + EXECUTE 'SELECT count(*) FROM public.end_customers' INTO end_customers_count; + END IF; + + IF pos_modules_exist THEN + EXECUTE 'SELECT count(*) FROM public.pos_modules' INTO pos_modules_count; + END IF; + + IF licenses_exist THEN + EXECUTE 'SELECT count(*) FROM public.licenses' INTO licenses_count; + END IF; + + -- Count foreign key errors (orphaned licenses) + IF licenses_exist AND end_customers_exist THEN + EXECUTE ' + SELECT count(*) + FROM public.licenses l + LEFT JOIN public.end_customers c ON l.end_customer_id = c.id + WHERE c.id IS NULL AND l.end_customer_id IS NOT NULL + ' INTO fk_errors_count; + END IF; + + -- Build JSON result + result := jsonb_build_object( + 'tables', jsonb_build_object( + 'profiles', jsonb_build_object('exists', profiles_exist, 'count', profiles_count), + 'end_customers', jsonb_build_object('exists', end_customers_exist, 'count', end_customers_count), + 'pos_modules', jsonb_build_object('exists', pos_modules_exist, 'count', pos_modules_count), + 'licenses', jsonb_build_object('exists', licenses_exist, 'count', licenses_count) + ), + 'errors', jsonb_build_object( + 'foreign_keys', fk_errors_count + ) + ); + + RETURN result; +END; +$$ LANGUAGE plpgsql; + +-- CREATE FUNCTION public.repair_database_schema +CREATE OR REPLACE FUNCTION public.repair_database_schema() +RETURNS jsonb +SECURITY DEFINER +AS $$ +DECLARE + repaired_tables text[] := '{}'; + result jsonb; +BEGIN + -- 1. profiles table (should exist, but check just in case) + IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = 'profiles') THEN + CREATE TABLE public.profiles ( + id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + first_name TEXT, + last_name TEXT, + company_name TEXT, + vat_id TEXT, + address TEXT, + city TEXT, + zip_code TEXT, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL + ); + repaired_tables := array_append(repaired_tables, 'profiles'); + END IF; + + -- 2. end_customers table + IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = 'end_customers') THEN + CREATE TABLE public.end_customers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + partner_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE, + company_name TEXT NOT NULL, + vat_id TEXT, + first_name TEXT, + last_name TEXT, + street TEXT, + zip TEXT, + city TEXT, + is_anonymized BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + repaired_tables := array_append(repaired_tables, 'end_customers'); + END IF; + + -- 3. pos_modules table + IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = 'pos_modules') THEN + CREATE TABLE public.pos_modules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + price NUMERIC(10, 2) NOT NULL DEFAULT 0.00, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + repaired_tables := array_append(repaired_tables, 'pos_modules'); + END IF; + + -- 4. licenses table + IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = 'licenses') THEN + CREATE TABLE public.licenses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + end_customer_id UUID REFERENCES public.end_customers(id) ON DELETE CASCADE, + pos_module_id UUID REFERENCES public.pos_modules(id) ON DELETE CASCADE, + license_key TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + repaired_tables := array_append(repaired_tables, 'licenses'); + END IF; + + -- Enable RLS on all tables + ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY; + ALTER TABLE public.end_customers ENABLE ROW LEVEL SECURITY; + ALTER TABLE public.pos_modules ENABLE ROW LEVEL SECURITY; + ALTER TABLE public.licenses ENABLE ROW LEVEL SECURITY; + + -- Recreate RLS Policies + + -- profiles Policies + DROP POLICY IF EXISTS "Users can view their own profile" ON public.profiles; + CREATE POLICY "Users can view their own profile" ON public.profiles FOR SELECT USING (auth.uid() = id); + DROP POLICY IF EXISTS "Users can update their own profile" ON public.profiles; + CREATE POLICY "Users can update their own profile" ON public.profiles FOR UPDATE USING (auth.uid() = id); + DROP POLICY IF EXISTS "Users can insert their own profile" ON public.profiles; + CREATE POLICY "Users can insert their own profile" ON public.profiles FOR INSERT WITH CHECK (auth.uid() = id); + DROP POLICY IF EXISTS "Admins can view all profiles" ON public.profiles; + CREATE POLICY "Admins can view all profiles" ON public.profiles FOR SELECT USING (auth.role() = 'authenticated'); + + -- end_customers Policies + DROP POLICY IF EXISTS "Partner sehen nur eigene Endkunden" ON public.end_customers; + CREATE POLICY "Partner sehen nur eigene Endkunden" ON public.end_customers FOR ALL USING (auth.uid() = partner_id) WITH CHECK (auth.uid() = partner_id); + + -- pos_modules Policies + DROP POLICY IF EXISTS "Allow authenticated read on pos_modules" ON public.pos_modules; + CREATE POLICY "Allow authenticated read on pos_modules" ON public.pos_modules FOR SELECT USING (auth.role() = 'authenticated'); + + -- licenses Policies + DROP POLICY IF EXISTS "Partner sehen Lizenzen eigener Kunden" ON public.licenses; + CREATE POLICY "Partner sehen Lizenzen eigener Kunden" ON public.licenses FOR SELECT USING ( + EXISTS ( + SELECT 1 FROM public.end_customers c + WHERE c.id = licenses.end_customer_id AND c.partner_id = auth.uid() + ) + ); + DROP POLICY IF EXISTS "Partner erstellen Lizenzen eigener Kunden" ON public.licenses; + CREATE POLICY "Partner erstellen Lizenzen eigener Kunden" ON public.licenses FOR INSERT WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.end_customers c + WHERE c.id = licenses.end_customer_id AND c.partner_id = auth.uid() + ) + ); + + result := jsonb_build_object( + 'repaired', to_jsonb(repaired_tables), + 'success', true + ); + RETURN result; +END; +$$ LANGUAGE plpgsql; + +-- CREATE FUNCTION public.optimize_database_indices +CREATE OR REPLACE FUNCTION public.optimize_database_indices() +RETURNS jsonb +SECURITY DEFINER +AS $$ +BEGIN + REINDEX SCHEMA public; + RETURN jsonb_build_object('success', true, 'message', 'Schema public successfully reindexed.'); +END; +$$ LANGUAGE plpgsql;