feat: integrate modules 01–03, 06–07 — email, admin auth, analytics, kunden-portal, statistik

- Modul 01 (Email): mailer.ts mit Nodemailer + Supabase-Queue, SMTP-Test-Route
- Modul 02 (Admin-Auth): JWT-Sessions, Rate-Limiting, Token-Blacklist, Audit-Logs, Login-Route
- Modul 03 (Analytics): PageTracker, page_views/phone_clicks Tracking, Admin-Analytics-Seite
- Modul 06 (Kunden-Portal): Supabase Auth Login/Registrierung, Kundendashboard, Middleware
- Modul 07 (KPI-Dashboard): Admin-Statistik mit Anfragen-Übersicht und Monats-Diagramm
- contact/route.ts: speichert Anfragen jetzt in Supabase
- Supabase-Types für alle neuen Tabellen ergänzt
- app/admin/page.tsx: Redirect zu /admin/analytics
- AnalyticsTabs: Design-Inkonsistenz behoben (Light-Mode-Karten auf Dark umgestellt)
- layout.tsx: Umlauts-Fehler in Metadata-Description behoben
- lib/audit-log.ts: TypeScript null→undefined Mapping für reason-Feld
- modules/: Wiederverwendbare Modul-Templates mit Migrations und Integrations-Prompts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MBO-Tech-IT 2026-04-27 19:41:05 +02:00
parent 08377f3a8a
commit 9e56f1b5a3
74 changed files with 9603 additions and 44 deletions

View File

@ -0,0 +1,425 @@
import { Suspense } from "react";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { verifySessionToken } from "@/lib/admin-auth";
import { createServiceClient } from "@/lib/supabase";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import type { Metadata } from "next";
import { AnalyticsTabs } from "@/components/admin/AnalyticsTabs";
import { AdminNav } from "@/components/admin/AdminNav";
export const metadata: Metadata = { title: "Web-Analytics MBO Tech IT" };
export const dynamic = "force-dynamic";
interface PageView {
id: string;
path: string;
timestamp: string;
device_type: string | null;
browser: string | null;
os: string | null;
session_id: string;
duration_ms: number | null;
}
interface FilterOption {
label: string;
tage: number;
beschreibung: string;
}
const FILTER_OPTIONS: FilterOption[] = [
{ label: "Heute", tage: 1, beschreibung: "letzter Tag" },
{ label: "Woche", tage: 7, beschreibung: "letzte 7 Tage" },
{ label: "Monat", tage: 30, beschreibung: "letzte 30 Tage" },
{ label: "3 Monate", tage: 90, beschreibung: "letzte 90 Tage" },
{ label: "Jahr", tage: 365, beschreibung: "letztes Jahr" },
];
function balkenBreite(wert: number, max: number): string {
if (max === 0) return "0%";
return `${Math.max(4, Math.round((wert / max) * 100))}%`;
}
function fmtDuration(ms: number | null): string {
if (!ms) return "";
if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
return `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`;
}
function fmtDateDE(isoDate: string): string {
const [year, month, day] = isoDate.split("-");
return `${day}.${month}.${year}`;
}
async function AnalyticsContent({
vonISO,
bisISO,
tage = null,
}: {
vonISO: string;
bisISO: string;
tage?: number | null;
}) {
const cookieStore = await cookies();
const token = cookieStore.get("admin_session")?.value;
if (!token) redirect("/admin/login");
const session = await verifySessionToken(token);
if (!session) redirect("/admin/login");
const db = createServiceClient();
const heute = new Date().toISOString().slice(0, 10);
const vonTimestamp = vonISO + "T00:00:00Z";
const bisTimestamp = bisISO + "T23:59:59.999Z";
const { data: allViews, error } = await db
.from("page_views")
.select("id, path, timestamp, device_type, browser, os, session_id, duration_ms")
.eq("is_bot", false)
.gte("timestamp", vonTimestamp)
.lte("timestamp", bisTimestamp)
.order("timestamp", { ascending: false });
const views: PageView[] = allViews ?? [];
const heuteViews = views.filter((v) => v.timestamp.startsWith(heute));
const uniqueSessionsHeute = new Set(heuteViews.map((v) => v.session_id)).size;
const mitDauer = views.filter((v) => v.duration_ms != null && v.duration_ms > 0);
const avgDuration =
mitDauer.length > 0
? Math.round(mitDauer.reduce((s, v) => s + v.duration_ms!, 0) / mitDauer.length)
: 0;
const mobileCount = views.filter((v) => v.device_type === "mobile").length;
const mobileRate = views.length > 0 ? Math.round((mobileCount / views.length) * 100) : 0;
const tagesMap: Record<string, number> = {};
for (const v of views) {
const tag = v.timestamp.slice(0, 10);
tagesMap[tag] = (tagesMap[tag] ?? 0) + 1;
}
const tagesStats = Object.entries(tagesMap)
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, count]) => ({ date, count }));
const maxTagesWert = Math.max(0, ...tagesStats.map((s) => s.count));
const pathMap: Record<string, { count: number; durSum: number; durCount: number }> = {};
for (const v of views) {
if (!pathMap[v.path]) pathMap[v.path] = { count: 0, durSum: 0, durCount: 0 };
pathMap[v.path].count++;
if (v.duration_ms) {
pathMap[v.path].durSum += v.duration_ms;
pathMap[v.path].durCount++;
}
}
const topSeiten = Object.entries(pathMap)
.map(([path, d]) => ({
path,
count: d.count,
avgDuration: d.durCount > 0 ? Math.round(d.durSum / d.durCount) : null,
}))
.sort((a, b) => b.count - a.count)
.slice(0, 20);
const maxPathCount = Math.max(0, ...topSeiten.map((s) => s.count));
const browserMap: Record<string, number> = {};
for (const v of views) browserMap[v.browser ?? "Other"] = (browserMap[v.browser ?? "Other"] ?? 0) + 1;
const sortedBrowsers = Object.entries(browserMap).sort((a, b) => b[1] - a[1]).slice(0, 8);
const deviceMap: Record<string, number> = {};
for (const v of views) deviceMap[v.device_type ?? "desktop"] = (deviceMap[v.device_type ?? "desktop"] ?? 0) + 1;
const osMap: Record<string, number> = {};
for (const v of views) osMap[v.os ?? "Other"] = (osMap[v.os ?? "Other"] ?? 0) + 1;
const sortedOs = Object.entries(osMap).sort((a, b) => b[1] - a[1]).slice(0, 8);
let filterDesc: string;
if (tage !== null) {
filterDesc = FILTER_OPTIONS.find((f) => f.tage === tage)?.beschreibung || `letzte ${tage} Tage`;
} else {
filterDesc = `${fmtDateDE(vonISO)} ${fmtDateDE(bisISO)}`;
}
if (error) {
return (
<div className="space-y-6">
<div className="bg-red-500/10 border border-red-500/30 p-4 rounded-lg">
<p className="text-red-400 font-semibold">Fehler beim Laden der Daten:</p>
<p className="text-red-400/70 text-sm mt-2">{error.message}</p>
</div>
</div>
);
}
const overviewContent = (
<div className="space-y-6">
<div className="bg-[#18212f] border border-gray-800 rounded-lg p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-xl font-bold text-white">Web-Analytics</h1>
<p className="text-slate-500 text-sm mt-1">{filterDesc} · Bots gefiltert · IPs anonymisiert</p>
</div>
<Link
href="/admin"
className="inline-flex items-center gap-2 text-slate-500 hover:text-white transition-colors text-sm"
>
<ArrowLeft className="w-4 h-4" />
Zurück
</Link>
</div>
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
{FILTER_OPTIONS.map((option) => (
<Link
key={option.tage}
href={`/admin/analytics?tage=${option.tage}`}
className={`px-3 py-1 rounded text-sm font-medium transition-all ${
tage === option.tage && tage !== null
? "bg-orange-500 text-white"
: "bg-gray-800 text-slate-400 hover:bg-gray-700 hover:text-white"
}`}
>
{option.label}
</Link>
))}
</div>
<form method="GET" action="/admin/analytics" className="flex flex-col sm:flex-row gap-2 items-end bg-[#111925] p-3 rounded-lg">
<div className="flex-1 min-w-0">
<label htmlFor="von" className="block text-xs font-medium text-slate-400 mb-1">Von</label>
<input
id="von"
type="date"
name="von"
defaultValue={vonISO}
className="w-full px-3 py-2 rounded text-sm text-white bg-[#18212f] border border-gray-700 focus:outline-none focus:border-orange-500/60"
/>
</div>
<div className="flex-1 min-w-0">
<label htmlFor="bis" className="block text-xs font-medium text-slate-400 mb-1">Bis</label>
<input
id="bis"
type="date"
name="bis"
defaultValue={bisISO}
className="w-full px-3 py-2 rounded text-sm text-white bg-[#18212f] border border-gray-700 focus:outline-none focus:border-orange-500/60"
/>
</div>
<button
type="submit"
className="px-4 py-2 bg-orange-500 hover:bg-orange-600 text-white text-sm font-bold rounded transition-all whitespace-nowrap"
>
Anwenden
</button>
</form>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ label: "Seitenaufrufe heute", wert: heuteViews.length },
{ label: "Unique Sessions heute", wert: uniqueSessionsHeute },
{ label: "Ø Verweildauer", wert: fmtDuration(avgDuration) },
{ label: "Mobile-Anteil", wert: `${mobileRate}%` },
].map((k) => (
<div key={k.label} className="bg-[#18212f] border border-gray-800 p-4 rounded-lg">
<div className="text-xs text-slate-500 mb-1">{k.label}</div>
<div className="text-2xl font-bold text-white">{k.wert}</div>
</div>
))}
</div>
<div className="bg-[#18212f] border border-gray-800 rounded-lg p-6">
<h2 className="text-base font-bold text-white mb-4">Seitenaufrufe ({filterDesc})</h2>
<div className="space-y-2">
{tagesStats.length === 0 ? (
<p className="text-slate-500 text-sm">Keine Daten vorhanden</p>
) : (
tagesStats.map(({ date, count }) => (
<div key={date} className="flex items-center gap-3">
<div className="w-24 text-sm font-mono text-slate-500">{date}</div>
<div className="flex-1 h-8 bg-[#111925] rounded flex items-center overflow-hidden">
<div
className="h-full bg-orange-500 transition-all duration-300"
style={{ width: balkenBreite(count, maxTagesWert) }}
/>
</div>
<div className="w-12 text-right text-sm font-semibold text-white">{count}</div>
</div>
))
)}
</div>
</div>
<div className="bg-[#18212f] border border-gray-800 rounded-lg overflow-hidden">
<div className="p-6 border-b border-gray-800">
<h2 className="text-base font-bold text-white">Top-Seiten</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b border-gray-800">
<tr className="text-xs text-slate-500 uppercase tracking-wide">
<th className="px-6 py-3 text-left font-medium">Seite</th>
<th className="px-6 py-3 text-left font-medium">Aufrufe</th>
<th className="px-6 py-3 text-left font-medium">Ø Verweildauer</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800/50">
{topSeiten.length === 0 ? (
<tr>
<td colSpan={3} className="px-6 py-8 text-center text-slate-500">Keine Daten vorhanden</td>
</tr>
) : (
topSeiten.map(({ path, count, avgDuration }) => (
<tr key={path} className="hover:bg-[#111925] transition-colors">
<td className="px-6 py-3 font-mono text-slate-300 max-w-sm truncate">{path}</td>
<td className="px-6 py-3">
<div className="flex items-center gap-3">
<div className="w-32 h-6 bg-[#111925] rounded flex items-center overflow-hidden">
<div className="h-full bg-orange-500" style={{ width: balkenBreite(count, maxPathCount) }} />
</div>
<span className="font-semibold text-white w-8">{count}</span>
</div>
</td>
<td className="px-6 py-3 text-slate-400">{fmtDuration(avgDuration)}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-[#18212f] border border-gray-800 rounded-lg p-6">
<h2 className="text-base font-bold text-white mb-4">Browser</h2>
<div className="space-y-3">
{sortedBrowsers.length === 0 ? (
<p className="text-slate-500 text-sm">Keine Daten vorhanden</p>
) : (
sortedBrowsers.map(([browser, count]) => (
<div key={browser} className="flex items-center gap-3">
<div className="w-24 text-sm font-medium text-slate-400">{browser}</div>
<div className="flex-1 h-6 bg-[#111925] rounded flex items-center overflow-hidden">
<div
className="h-full bg-orange-500"
style={{ width: balkenBreite(count, Math.max(...sortedBrowsers.map((s) => s[1]))) }}
/>
</div>
<div className="w-8 text-right text-sm font-semibold text-white">{count}</div>
</div>
))
)}
</div>
</div>
<div className="bg-[#18212f] border border-gray-800 rounded-lg p-6">
<h2 className="text-base font-bold text-white mb-4">Betriebssystem</h2>
<div className="space-y-3">
{sortedOs.length === 0 ? (
<p className="text-slate-500 text-sm">Keine Daten vorhanden</p>
) : (
sortedOs.map(([os, count]) => (
<div key={os} className="flex items-center gap-3">
<div className="w-24 text-sm font-medium text-slate-400">{os}</div>
<div className="flex-1 h-6 bg-[#111925] rounded flex items-center overflow-hidden">
<div
className="h-full bg-orange-500"
style={{ width: balkenBreite(count, Math.max(...sortedOs.map((s) => s[1]))) }}
/>
</div>
<div className="w-8 text-right text-sm font-semibold text-white">{count}</div>
</div>
))
)}
</div>
</div>
</div>
<div className="bg-[#18212f] border border-gray-800 rounded-lg p-6">
<h2 className="text-base font-bold text-white mb-4">Geräte-Verteilung</h2>
<div className="grid grid-cols-3 gap-4">
{Object.entries(deviceMap).map(([device, count]) => (
<div key={device} className="text-center">
<div className="text-2xl font-bold text-white">{count}</div>
<div className="text-sm text-slate-400 capitalize">
{device === "desktop" ? "Desktop" : device === "tablet" ? "Tablet" : "Mobile"}
</div>
<div className="text-xs text-slate-500 mt-1">
{views.length > 0 ? `${Math.round((count / views.length) * 100)}%` : "0%"}
</div>
</div>
))}
</div>
</div>
<div className="bg-blue-500/10 border border-blue-500/20 p-4 rounded-lg">
<p className="text-sm text-blue-400">
<strong>Datenschutz:</strong> IP-Adressen werden anonymisiert (letztes Oktett = 0). Keine Cookies für Analytics.
Session-IDs sind Tab-gebunden und nicht persistent.
</p>
</div>
</div>
);
return <AnalyticsTabs overviewContent={overviewContent} />;
}
export default async function AnalyticsPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[]>>;
}) {
const params = await searchParams;
const von = String(params.von || "");
const bis = String(params.bis || "");
const tageParam = String(params.tage || "");
const heute = new Date().toISOString().slice(0, 10);
function datumMinusTage(isoDate: string, tage: number): string {
const [year, month, day] = isoDate.split("-").map(Number);
const date = new Date(year, month - 1, day);
date.setDate(date.getDate() - tage);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
let vonISO: string;
let bisISO: string;
let tage: number | null;
if (von && bis) {
vonISO = von;
bisISO = bis;
tage = null;
} else if (tageParam) {
tage = Math.max(1, Math.min(365, parseInt(tageParam, 10) || 30));
bisISO = heute;
vonISO = datumMinusTage(heute, tage);
} else {
tage = 30;
bisISO = heute;
vonISO = datumMinusTage(heute, 30);
}
return (
<div className="min-h-screen bg-[#111925]">
<AdminNav />
<div className="max-w-7xl mx-auto px-4 py-8">
<Suspense
fallback={
<div className="text-center py-12">
<p className="text-slate-500">Lädt Analytics-Daten</p>
</div>
}
>
<AnalyticsContent vonISO={vonISO} bisISO={bisISO} tage={tage} />
</Suspense>
</div>
</div>
);
}

View File

@ -0,0 +1,163 @@
import { Suspense } from "react";
import Link from "next/link";
import { verifySessionToken } from "@/lib/admin-auth";
import { getAuditLogs } from "@/lib/audit-log";
import { cookies } from "next/headers";
import { AdminNav } from "@/components/admin/AdminNav";
async function AuditLogsContent() {
const cookieStore = await cookies();
const token = cookieStore.get("admin_session")?.value;
if (!token) {
return (
<div className="text-center py-12">
<p className="text-red-400 font-semibold">Nicht authentifiziert</p>
<Link href="/admin/login" className="text-orange-400 underline mt-4 inline-block">
Zurück zum Login
</Link>
</div>
);
}
const session = await verifySessionToken(token);
if (!session) {
return (
<div className="text-center py-12">
<p className="text-red-400 font-semibold">Session abgelaufen</p>
<Link href="/admin/login" className="text-orange-400 underline mt-4 inline-block">
Erneut anmelden
</Link>
</div>
);
}
const logs = await getAuditLogs({ limit: 100, offset: 0 });
const successCount = logs.filter((l) => l.success).length;
const failedCount = logs.filter((l) => !l.success).length;
const uniqueIps = new Set(logs.map((l) => l.ip_addr)).size;
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const recentLogs = logs.filter((l) => l.timestamp > oneHourAgo);
const recentFailed = recentLogs.filter((l) => !l.success).length;
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-white tracking-tight">Audit-Logs</h1>
<p className="text-sm text-slate-500 mt-0.5">Login-Aktivitäten und Sicherheitsereignisse</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ label: "Erfolgreich (Gesamt)", wert: successCount, color: "text-green-400" },
{ label: "Fehlgeschlagen (Gesamt)", wert: failedCount, color: "text-red-400" },
{ label: "Fehlgeschlagen (1h)", wert: recentFailed, color: recentFailed >= 5 ? "text-red-400" : "text-green-400" },
{ label: "Eindeutige IPs", wert: uniqueIps, color: "text-white" },
].map((k) => (
<div key={k.label} className="bg-[#18212f] border border-gray-800 p-4 rounded-lg">
<div className="text-xs text-slate-500 mb-1">{k.label}</div>
<div className={`text-2xl font-bold ${k.color}`}>{k.wert}</div>
</div>
))}
</div>
{recentFailed >= 5 && (
<div className="bg-red-500/10 border border-red-500/30 p-4 rounded-lg">
<p className="text-sm font-semibold text-red-400"> Verdächtige Aktivität erkannt</p>
<p className="text-sm text-red-400/70 mt-1">
{recentFailed} fehlgeschlagene Login-Versuche in der letzten Stunde
</p>
</div>
)}
<div className="bg-[#18212f] border border-gray-800 rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b border-gray-800">
<tr className="text-xs text-slate-500 uppercase tracking-wide">
<th className="px-4 py-3 text-left font-medium">Zeitstempel</th>
<th className="px-4 py-3 text-left font-medium">E-Mail</th>
<th className="px-4 py-3 text-left font-medium">IP-Adresse</th>
<th className="px-4 py-3 text-left font-medium">Status</th>
<th className="px-4 py-3 text-left font-medium">Grund</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800/50">
{logs.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-slate-500">
Keine Audit-Logs vorhanden
</td>
</tr>
) : (
logs.map((log) => (
<tr key={log.id} className="hover:bg-[#111925] transition-colors">
<td className="px-4 py-3 whitespace-nowrap text-slate-400">
{new Date(log.timestamp).toLocaleString("de-DE")}
</td>
<td className="px-4 py-3 font-mono text-slate-300 truncate">{log.email}</td>
<td className="px-4 py-3 font-mono text-slate-400 text-xs">{log.ip_addr}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
log.success
? "bg-green-500/10 text-green-400 border border-green-500/20"
: "bg-red-500/10 text-red-400 border border-red-500/20"
}`}>
{log.success ? "✓ Erfolgreich" : "✗ Fehlgeschlagen"}
</span>
</td>
<td className="px-4 py-3 text-slate-500 text-xs">
{log.reason ? formatReason(log.reason) : ""}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
<div className="bg-blue-500/10 border border-blue-500/20 p-4 rounded-lg">
<p className="text-sm text-blue-400">
<strong>Hinweis:</strong> Detaillierte Geräte-Informationen sind in der Supabase-Tabelle{" "}
<code className="bg-[#111925] px-2 py-0.5 rounded border border-blue-500/20 font-mono text-xs">
admin_audit_logs
</code>{" "}
gespeichert.
</p>
</div>
</div>
);
}
function formatReason(reason: string): string {
const reasons: Record<string, string> = {
invalid_password: "Falsches Passwort",
user_not_found_or_inactive: "User nicht gefunden / inaktiv",
missing_credentials: "Fehlende Anmeldedaten",
invalid_token: "Ungültiger Token",
token_expired: "Token abgelaufen",
};
return reasons[reason] || reason;
}
export default function AuditLogsPage() {
return (
<div className="min-h-screen bg-[#111925]">
<AdminNav />
<div className="max-w-6xl mx-auto px-4 py-8">
<Suspense
fallback={
<div className="text-center py-12">
<p className="text-slate-500">Lädt Audit-Logs</p>
</div>
}
>
<AuditLogsContent />
</Suspense>
</div>
</div>
);
}

5
app/admin/layout.tsx Normal file
View File

@ -0,0 +1,5 @@
import { SessionTimeoutProvider } from "@/components/admin/SessionTimeoutProvider";
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return <SessionTimeoutProvider>{children}</SessionTimeoutProvider>;
}

109
app/admin/login/page.tsx Normal file
View File

@ -0,0 +1,109 @@
"use client";
import { Suspense, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
function isInternalUrl(url: string): boolean {
if (!url.startsWith("/")) return false;
return url.startsWith("/admin/") || url.startsWith("/kunden/");
}
function AdminLoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const rawFrom = searchParams.get("from");
const from = rawFrom && isInternalUrl(rawFrom) ? rawFrom : "/admin/analytics";
const sessionExpired = searchParams.get("session_expired") === "true";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState(
sessionExpired ? "Ihre Session ist abgelaufen. Bitte melden Sie sich erneut an." : ""
);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
const res = await fetch("/api/admin/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (res.ok) {
router.push(from);
} else {
setError("Ungültige Zugangsdaten");
}
setLoading(false);
}
return (
<div className="min-h-screen bg-[#111925] flex items-center justify-center px-4">
<div className="bg-[#18212f] border border-gray-800 rounded-2xl p-8 w-full max-w-sm">
<div className="mb-8">
<div className="w-10 h-10 rounded-xl bg-orange-500/10 border border-orange-500/30 flex items-center justify-center mb-4">
<svg className="w-5 h-5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<h1 className="text-xl font-bold text-white tracking-tight">Admin · MBO Tech IT</h1>
<p className="text-sm text-slate-500 mt-1">Bitte anmelden</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-slate-400 mb-1.5">E-Mail</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
placeholder="admin@mbo-tech-it.de"
required
className="w-full px-4 py-3 rounded-xl bg-[#111925] border border-gray-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500/60 focus:ring-1 focus:ring-orange-500/20 transition-colors"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-slate-400 mb-1.5">Passwort</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
className="w-full px-4 py-3 rounded-xl bg-[#111925] border border-gray-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500/60 focus:ring-1 focus:ring-orange-500/20 transition-colors"
/>
</div>
{error && (
<p className={`text-sm px-3 py-2 rounded-lg ${sessionExpired ? "text-blue-400 bg-blue-500/10 border border-blue-500/20" : "text-red-400 bg-red-500/10 border border-red-500/20"}`}>
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className="w-full py-3 px-4 bg-orange-500 hover:bg-orange-600 disabled:opacity-60 disabled:cursor-not-allowed text-white font-semibold rounded-xl transition-colors"
>
{loading ? "Wird geprüft…" : "Anmelden"}
</button>
</form>
</div>
</div>
);
}
export default function AdminLoginPage() {
return (
<Suspense>
<AdminLoginForm />
</Suspense>
);
}

5
app/admin/page.tsx Normal file
View File

@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function AdminPage() {
redirect("/admin/analytics");
}

View File

@ -0,0 +1,154 @@
import { createServiceClient } from "@/lib/supabase";
import { verifySessionToken } from "@/lib/admin-auth";
import { AdminNav } from "@/components/admin/AdminNav";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Statistik MBO Tech IT" };
export const dynamic = "force-dynamic";
function fmt(n: number) {
return n.toLocaleString("de-DE");
}
export default async function StatistikPage() {
const cookieStore = await cookies();
const token = cookieStore.get("admin_session")?.value;
if (!token) redirect("/admin/login");
const session = await verifySessionToken(token);
if (!session) redirect("/admin/login?session_expired=true");
const db = createServiceClient();
const { data: alleAnfragen } = await db
.from("anfragen")
.select("id, status, created_at, name, betreff");
const anfragen = alleAnfragen ?? [];
const gesamt = anfragen.length;
const offen = anfragen.filter((a) => a.status === "offen").length;
const inBearbeitung = anfragen.filter((a) => a.status === "in_bearbeitung").length;
const abgeschlossen = anfragen.filter((a) => a.status === "abgeschlossen").length;
const { data: recentRaw } = await db
.from("anfragen")
.select("id, created_at, name, betreff, status, email")
.order("created_at", { ascending: false })
.limit(20);
const recentAnfragen = recentRaw ?? [];
const heute = new Date();
const monatsStats: { monat: string; label: string; count: number }[] = [];
for (let i = 5; i >= 0; i--) {
const d = new Date(heute);
d.setMonth(d.getMonth() - i);
const monat = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
const label = d.toLocaleDateString("de-DE", { month: "short", year: "2-digit" });
const count = anfragen.filter((a) => a.created_at.startsWith(monat)).length;
monatsStats.push({ monat, label, count });
}
const maxCount = Math.max(...monatsStats.map((m) => m.count), 1);
return (
<div className="min-h-screen bg-[#111925]">
<AdminNav />
<div className="max-w-7xl mx-auto px-4 py-8 space-y-8">
<div>
<h1 className="text-2xl font-bold text-white tracking-tight">Statistik</h1>
<p className="text-sm text-slate-500 mt-0.5">Anfragen-Übersicht</p>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: "Anfragen gesamt", wert: fmt(gesamt), farbe: "border-l-slate-400" },
{ label: "Offen", wert: fmt(offen), farbe: "border-l-amber-500" },
{ label: "In Bearbeitung", wert: fmt(inBearbeitung), farbe: "border-l-blue-500" },
{ label: "Abgeschlossen", wert: fmt(abgeschlossen), farbe: "border-l-green-500" },
].map((k) => (
<div key={k.label} className={`bg-[#18212f] border border-gray-800 border-l-4 ${k.farbe} p-4`}>
<p className="text-xs text-slate-500 uppercase tracking-wide font-medium mb-1">{k.label}</p>
<p className="text-2xl font-bold text-white font-mono">{k.wert}</p>
</div>
))}
</div>
<div className="bg-[#18212f] border border-gray-800 p-5">
<h2 className="font-semibold text-white mb-5">Anfragen letzte 6 Monate</h2>
<div className="flex items-end gap-3 h-32">
{monatsStats.map((m) => (
<div key={m.monat} className="flex-1 flex flex-col items-center gap-1">
<span className="text-[10px] font-mono text-slate-500">
{m.count > 0 ? m.count : ""}
</span>
<div className="w-full flex items-end" style={{ height: 80 }}>
<div
className="w-full bg-orange-500 rounded-sm transition-all"
style={{
height: `${Math.max(m.count > 0 ? 8 : 0, Math.round((m.count / maxCount) * 80))}px`,
}}
/>
</div>
<span className="text-[10px] text-slate-400 font-medium">{m.label}</span>
</div>
))}
</div>
</div>
<div className="bg-[#18212f] border border-gray-800">
<div className="p-5 border-b border-gray-800">
<h2 className="font-semibold text-white">Letzte 20 Anfragen</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b border-gray-800">
<tr className="text-xs text-slate-500 uppercase tracking-wide">
<th className="text-left py-3 px-5 font-medium">Datum</th>
<th className="text-left py-3 px-5 font-medium">Name</th>
<th className="text-left py-3 px-5 font-medium hidden sm:table-cell">Betreff</th>
<th className="text-left py-3 px-5 font-medium">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800/50">
{recentAnfragen.length === 0 ? (
<tr>
<td colSpan={4} className="px-5 py-8 text-center text-slate-500">
Keine Anfragen vorhanden
</td>
</tr>
) : (
recentAnfragen.map((a) => (
<tr key={a.id} className="hover:bg-[#111925] transition-colors">
<td className="px-5 py-3 text-slate-400 whitespace-nowrap">
{new Date(a.created_at).toLocaleDateString("de-DE")}
</td>
<td className="px-5 py-3 font-medium text-white">{a.name}</td>
<td className="px-5 py-3 text-slate-400 hidden sm:table-cell truncate max-w-xs">{a.betreff}</td>
<td className="px-5 py-3">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
a.status === "offen"
? "bg-amber-500/10 text-amber-400 border border-amber-500/20"
: a.status === "in_bearbeitung"
? "bg-blue-500/10 text-blue-400 border border-blue-500/20"
: "bg-green-500/10 text-green-400 border border-green-500/20"
}`}>
{a.status === "offen"
? "Offen"
: a.status === "in_bearbeitung"
? "In Bearbeitung"
: "Abgeschlossen"}
</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,133 @@
import { NextResponse, NextRequest } from "next/server";
import { createServiceClient } from "@/lib/supabase";
import { requireAdmin } from "@/lib/admin-auth";
export const dynamic = "force-dynamic";
type DateRange = "today" | "7days" | "30days";
function getDateRange(range: DateRange): { start: string; end: string } {
const end = new Date();
const start = new Date();
switch (range) {
case "today":
start.setHours(0, 0, 0, 0);
break;
case "7days":
start.setDate(start.getDate() - 7);
break;
case "30days":
start.setDate(start.getDate() - 30);
break;
}
return { start: start.toISOString(), end: end.toISOString() };
}
export async function GET(request: NextRequest) {
const check = await requireAdmin();
if (check instanceof NextResponse) return check;
const searchParams = request.nextUrl.searchParams;
const range = (searchParams.get("range") || "today") as DateRange;
const { start, end } = getDateRange(range);
try {
const db = createServiceClient();
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
const todayISO = todayStart.toISOString();
const { count: todayCount } = await db
.from("phone_clicks")
.select("*", { count: "exact" })
.gte("timestamp", todayISO)
.lte("timestamp", new Date().toISOString());
const callsToday = todayCount || 0;
const yesterdayStart = new Date(todayStart);
yesterdayStart.setDate(yesterdayStart.getDate() - 1);
const yesterdayEnd = new Date(yesterdayStart);
yesterdayEnd.setDate(yesterdayEnd.getDate() + 1);
const { count: yesterdayCount } = await db
.from("phone_clicks")
.select("*", { count: "exact" })
.gte("timestamp", yesterdayStart.toISOString())
.lt("timestamp", yesterdayEnd.toISOString());
const callsYesterday = yesterdayCount || 0;
const callsTodayTrend =
callsYesterday > 0
? Math.round(((callsToday - callsYesterday) / callsYesterday) * 100)
: callsToday > 0 ? 100 : 0;
const { data: uniqueData } = await db
.from("phone_clicks")
.select("phone_number")
.gte("timestamp", start)
.lte("timestamp", end);
const uniqueNumbers = new Set((uniqueData || []).map((d) => d.phone_number)).size;
const { data: elementData } = await db
.from("phone_clicks")
.select("source_element")
.gte("timestamp", start)
.lte("timestamp", end);
const elementCounts: Record<string, number> = {};
(elementData || []).forEach((item) => {
elementCounts[item.source_element] = (elementCounts[item.source_element] || 0) + 1;
});
const totalClicks = elementData?.length || 0;
const elementEntries = Object.entries(elementCounts).sort(([, a], [, b]) => b - a);
const [topSourceElement, topSourceElementCount] = elementEntries[0] || [null, 0];
const topSourceElementPercent =
totalClicks > 0 ? Math.round((topSourceElementCount / totalClicks) * 100) : 0;
const numberCounts: Record<string, number> = {};
(uniqueData || []).forEach((item) => {
numberCounts[item.phone_number] = (numberCounts[item.phone_number] || 0) + 1;
});
const phoneNumbers = Object.entries(numberCounts)
.map(([phone_number, click_count]) => ({ phone_number, click_count }))
.sort((a, b) => b.click_count - a.click_count);
const elements = Object.entries(elementCounts)
.map(([source_element, count]) => ({
source_element,
count,
percent: totalClicks > 0 ? Math.round((count / totalClicks) * 100) : 0,
}))
.sort((a, b) => b.count - a.count);
const { data: timeseriesRaw } = await db
.from("phone_clicks")
.select("timestamp")
.gte("timestamp", start)
.lte("timestamp", end)
.order("timestamp", { ascending: true });
const timeseriesMap: Record<string, number> = {};
(timeseriesRaw || []).forEach((item) => {
const date = item.timestamp.split("T")[0];
timeseriesMap[date] = (timeseriesMap[date] || 0) + 1;
});
const timeseries = Object.entries(timeseriesMap).map(([date, count]) => ({ date, count }));
return NextResponse.json({
kpis: { callsToday, callsTodayTrend, uniqueNumbers, topSourceElement, topSourceElementPercent },
phoneNumbers,
elements,
timeseries,
});
} catch (error) {
console.error("Phone calls analytics error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
import { createServiceClient } from "@/lib/supabase";
import { verifyActionToken } from "@/lib/admin-auth";
import { markActionTokenUsed } from "@/lib/token-blacklist";
export async function GET(req: NextRequest) {
const token = req.nextUrl.searchParams.get("token");
if (!token) {
return NextResponse.json({ error: "Token erforderlich" }, { status: 400 });
}
const actionToken = await verifyActionToken(token);
if (!actionToken) {
return NextResponse.json({ error: "Token ungültig oder abgelaufen" }, { status: 400 });
}
const { anfrageId, status } = actionToken;
const appUrl = process.env.APP_URL ?? "https://mbo-tech-it.de";
const ipAddr = req.headers.get("x-forwarded-for") || req.headers.get("x-real-ip") || "unknown";
const [, tokenSig] = token.split(".");
await markActionTokenUsed(tokenSig, anfrageId, status, ipAddr);
try {
const db = createServiceClient();
const { error } = await db
.from("anfragen")
.update({ status })
.eq("id", anfrageId);
if (error) {
console.error(`[Action] Fehler beim Update von Anfrage ${anfrageId}:`, error);
return NextResponse.json({ error: "Statusaktualisierung fehlgeschlagen" }, { status: 500 });
}
return NextResponse.redirect(`${appUrl}/admin/statistik?action=done`);
} catch (err) {
console.error("[Action] Unerwarteter Fehler:", err);
return NextResponse.json({ error: "Ein Fehler ist aufgetreten" }, { status: 500 });
}
}

View File

@ -1,19 +1,8 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/admin-auth";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
// Optionaler Schutz via ADMIN_SECRET env-Variable.
// Wird durch lib/admin-auth ersetzt wenn Modul 02-admin-auth integriert ist.
function checkAuth(request: Request): NextResponse | null {
const secret = process.env.ADMIN_SECRET;
if (!secret) return null;
const auth = request.headers.get("authorization");
if (auth !== `Bearer ${secret}`) {
return NextResponse.json({ error: "Nicht autorisiert" }, { status: 401 });
}
return null;
}
function isSupabaseConfigured() { function isSupabaseConfigured() {
return !!(process.env.SUPABASE_URL && process.env.SUPABASE_SERVICE_ROLE_KEY); return !!(process.env.SUPABASE_URL && process.env.SUPABASE_SERVICE_ROLE_KEY);
} }
@ -25,10 +14,9 @@ function supabaseNotReadyResponse() {
); );
} }
// GET: Queue-Status abrufen
export async function GET(request: Request) { export async function GET(request: Request) {
const authError = checkAuth(request); const check = await requireAdmin();
if (authError) return authError; if (check instanceof NextResponse) return check;
if (!isSupabaseConfigured()) return supabaseNotReadyResponse(); if (!isSupabaseConfigured()) return supabaseNotReadyResponse();
const { createServiceClient } = await import("@/lib/supabase"); const { createServiceClient } = await import("@/lib/supabase");
@ -43,10 +31,9 @@ export async function GET(request: Request) {
return NextResponse.json(data); return NextResponse.json(data);
} }
// POST: Alle pending Mails sofort neu versuchen
export async function POST(request: Request) { export async function POST(request: Request) {
const authError = checkAuth(request); const check = await requireAdmin();
if (authError) return authError; if (check instanceof NextResponse) return check;
if (!isSupabaseConfigured()) return supabaseNotReadyResponse(); if (!isSupabaseConfigured()) return supabaseNotReadyResponse();
const { createServiceClient } = await import("@/lib/supabase"); const { createServiceClient } = await import("@/lib/supabase");
@ -66,10 +53,9 @@ export async function POST(request: Request) {
return NextResponse.json({ ok: true, updated: count ?? 0 }); return NextResponse.json({ ok: true, updated: count ?? 0 });
} }
// DELETE: Fehlgeschlagene Mails löschen
export async function DELETE(request: Request) { export async function DELETE(request: Request) {
const authError = checkAuth(request); const check = await requireAdmin();
if (authError) return authError; if (check instanceof NextResponse) return check;
if (!isSupabaseConfigured()) return supabaseNotReadyResponse(); if (!isSupabaseConfigured()) return supabaseNotReadyResponse();
const { createServiceClient } = await import("@/lib/supabase"); const { createServiceClient } = await import("@/lib/supabase");

View File

@ -0,0 +1,99 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { createServiceClient } from "@/lib/supabase";
import { createSessionToken, verifySessionToken } from "@/lib/admin-auth";
import { logLoginAttempt, getFailedLoginCount, sendSecurityAlert } from "@/lib/audit-log";
import { checkRateLimit, resetRateLimit } from "@/lib/rate-limit";
import { revokeSessionToken } from "@/lib/token-blacklist";
export async function POST(req: NextRequest) {
const { email, password } = await req.json();
const ipAddr = req.headers.get("x-forwarded-for") || req.headers.get("x-real-ip") || "unknown";
const userAgent = req.headers.get("user-agent") || "unknown";
if (!email || !password) {
await logLoginAttempt("", ipAddr, false, userAgent, "missing_credentials");
return NextResponse.json({ error: "E-Mail und Passwort erforderlich" }, { status: 400 });
}
const rateLimitKey = `login:${email.toLowerCase()}:${ipAddr}`;
const { allowed, delayMs, locked } = checkRateLimit(rateLimitKey);
if (locked) {
await logLoginAttempt(email, ipAddr, false, userAgent, "rate_limit_locked");
const res = NextResponse.json({ error: "Zu viele Anmeldeversuche. Bitte später versuchen." }, { status: 429 });
res.headers.set("Retry-After", String(Math.ceil(delayMs / 1000)));
return res;
}
if (!allowed && delayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
const db = createServiceClient();
const { data: admin } = await db
.from("admin_users")
.select("id, email, name, password_hash, aktiv")
.eq("email", email.toLowerCase().trim())
.single();
if (!admin || !admin.aktiv) {
await logLoginAttempt(email, ipAddr, false, userAgent, "user_not_found_or_inactive");
const failedCount = await getFailedLoginCount(email, "email", 60);
if (failedCount >= 10) {
await sendSecurityAlert(
"⚠️ Viele fehlgeschlagene Login-Versuche",
`Email: ${email}\nVersuche in der letzten Stunde: ${failedCount}`
);
}
return NextResponse.json({ error: "Ungültige Zugangsdaten" }, { status: 401 });
}
const valid = await bcrypt.compare(password, admin.password_hash);
if (!valid) {
await logLoginAttempt(email, ipAddr, false, userAgent, "invalid_password");
const failedCount = await getFailedLoginCount(email, "email", 60);
if (failedCount >= 10) {
await sendSecurityAlert(
"⚠️ Viele fehlgeschlagene Login-Versuche (falsches Passwort)",
`Email: ${email}\nIP: ${ipAddr}\nVersuche in der letzten Stunde: ${failedCount}`
);
}
return NextResponse.json({ error: "Ungültige Zugangsdaten" }, { status: 401 });
}
await logLoginAttempt(email, ipAddr, true, userAgent);
resetRateLimit(rateLimitKey);
const token = await createSessionToken({ id: admin.id, email: admin.email, name: admin.name ?? "" });
const res = NextResponse.json({ success: true });
res.cookies.set("admin_session", token, {
httpOnly: true,
sameSite: "lax",
maxAge: 60 * 60 * 2,
path: "/",
secure: process.env.NODE_ENV === "production",
});
return res;
}
export async function DELETE(req: NextRequest) {
const token = req.cookies.get("admin_session")?.value;
if (token) {
try {
const session = await verifySessionToken(token);
if (session) {
const [, sig] = token.split(".");
await revokeSessionToken(sig, session.id, "logout");
}
} catch {
// Logout auch bei Fehler durchführen
}
}
const res = NextResponse.json({ success: true });
res.cookies.delete("admin_session");
return res;
}

View File

@ -1,23 +1,12 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
import { requireAdmin } from "@/lib/admin-auth";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
// Optionaler Schutz via ADMIN_SECRET env-Variable.
// Wird durch lib/admin-auth ersetzt wenn Modul 02-admin-auth integriert ist.
function checkAuth(request: Request): NextResponse | null {
const secret = process.env.ADMIN_SECRET;
if (!secret) return null;
const auth = request.headers.get("authorization");
if (auth !== `Bearer ${secret}`) {
return NextResponse.json({ error: "Nicht autorisiert" }, { status: 401 });
}
return null;
}
export async function GET(request: Request) { export async function GET(request: Request) {
const authError = checkAuth(request); const check = await requireAdmin();
if (authError) return authError; if (check instanceof NextResponse) return check;
const config = { const config = {
host: process.env.SMTP_HOST ?? "(nicht gesetzt)", host: process.env.SMTP_HOST ?? "(nicht gesetzt)",

View File

@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { createServiceClient } from "@/lib/supabase";
import { requireAdmin } from "@/lib/admin-auth";
export const dynamic = "force-dynamic";
export async function GET() {
const check = await requireAdmin();
if (check instanceof NextResponse) return check;
const db = createServiceClient();
const { data: alleAnfragen } = await db
.from("anfragen")
.select("id, status, created_at, name, betreff, email");
const anfragen = alleAnfragen ?? [];
const kpi = {
gesamt: anfragen.length,
offen: anfragen.filter((a) => a.status === "offen").length,
inBearbeitung: anfragen.filter((a) => a.status === "in_bearbeitung").length,
abgeschlossen: anfragen.filter((a) => a.status === "abgeschlossen").length,
};
const heute = new Date();
const monatsStats: { monat: string; label: string; count: number }[] = [];
for (let i = 5; i >= 0; i--) {
const d = new Date(heute);
d.setMonth(d.getMonth() - i);
const monat = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
const label = d.toLocaleDateString("de-DE", { month: "short", year: "2-digit" });
const count = anfragen.filter((a) => a.created_at.startsWith(monat)).length;
monatsStats.push({ monat, label, count });
}
const { data: recentAnfragen } = await db
.from("anfragen")
.select("id, created_at, name, betreff, status, email")
.order("created_at", { ascending: false })
.limit(20);
return NextResponse.json({ kpi, monatsStats, recentAnfragen: recentAnfragen ?? [] });
}

View File

@ -0,0 +1,44 @@
import { createServiceClient } from "@/lib/supabase";
import { isBot, anonymizeIp, parseDevice, parseBrowser, parseOs } from "@/lib/analytics";
export async function POST(request: Request) {
try {
const body = await request.json();
const { phone_number, source_page, source_element, session_id } = body;
if (!phone_number || !source_page || !source_element) {
return Response.json({ error: "Missing required fields" }, { status: 400 });
}
const ip =
request.headers.get("x-forwarded-for")?.split(",")[0].trim() ||
request.headers.get("x-real-ip") ||
"0.0.0.0";
const ua = request.headers.get("user-agent") || "";
if (isBot(ua)) {
return Response.json({ ok: true });
}
const supabase = createServiceClient();
const { error } = await supabase.from("phone_clicks").insert({
phone_number,
source_page,
source_element,
session_id: session_id || null,
ip_anonymized: anonymizeIp(ip),
device_type: parseDevice(ua),
browser: parseBrowser(ua),
os: parseOs(ua),
});
if (error) {
console.error("Phone click tracking error:", error);
}
return Response.json({ ok: true });
} catch {
return Response.json({ ok: true });
}
}

View File

@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from "next/server";
import { createServiceClient } from "@/lib/supabase";
import { anonymizeIp, isBot, parseDevice, parseBrowser, parseOs } from "@/lib/analytics";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
interface TrackBody {
path: string;
session_id: string;
referrer?: string;
view_id?: string;
duration_ms?: number;
}
export async function POST(req: NextRequest) {
try {
const body: TrackBody = await req.json();
if (!body.path || !body.session_id) {
return NextResponse.json({ ok: false }, { status: 400 });
}
const ua = req.headers.get("user-agent") ?? "";
if (isBot(ua)) {
return NextResponse.json({ ok: true, bot: true });
}
const db = createServiceClient();
if (body.view_id && body.duration_ms !== undefined) {
await db
.from("page_views")
.update({ duration_ms: body.duration_ms })
.eq("id", body.view_id)
.is("duration_ms", null);
return NextResponse.json({ ok: true });
}
const rawIp =
req.headers.get("x-forwarded-for")?.split(",")[0].trim() ??
req.headers.get("x-real-ip") ??
"0.0.0.0";
const { data, error } = await db
.from("page_views")
.insert({
path: body.path,
session_id: body.session_id,
referrer: body.referrer ?? null,
ip_anon: anonymizeIp(rawIp),
device_type: parseDevice(ua),
browser: parseBrowser(ua),
os: parseOs(ua),
is_bot: false,
})
.select("id")
.single();
if (error) {
console.error("[analytics/track] DB error:", error);
return NextResponse.json({ ok: false }, { status: 500 });
}
return NextResponse.json({ ok: true, view_id: data.id });
} catch (err) {
console.error("[analytics/track] Error:", err);
return NextResponse.json({ ok: false }, { status: 500 });
}
}

View File

@ -1,5 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { sendeKontaktEmail } from "@/lib/mailer"; import { sendeKontaktEmail } from "@/lib/mailer";
import { createServiceClient } from "@/lib/supabase";
export async function POST(request: Request) { export async function POST(request: Request) {
let body: Record<string, string>; let body: Record<string, string>;
@ -16,6 +17,13 @@ export async function POST(request: Request) {
const result = await sendeKontaktEmail({ name, email, betreff, nachricht, telefon }); const result = await sendeKontaktEmail({ name, email, betreff, nachricht, telefon });
try {
const db = createServiceClient();
await db.from("anfragen").insert({ name, email, betreff, nachricht: nachricht || null, status: "offen" });
} catch (err) {
console.error("[Contact] Supabase insert error:", err);
}
if (!result.sent && !result.queued) { if (!result.sent && !result.queued) {
console.error( console.error(
`[Contact] UNZUSTELLBAR Anfrage konnte weder gesendet noch in Queue gespeichert werden:\n` + `[Contact] UNZUSTELLBAR Anfrage konnte weder gesendet noch in Queue gespeichert werden:\n` +

View File

@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";
import { createServiceClient } from "@/lib/supabase";
async function getKundeEmail(authHeader: string | null): Promise<string | null> {
if (!authHeader?.startsWith("Bearer ")) return null;
const token = authHeader.slice(7);
const anonClient = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
const {
data: { user },
error,
} = await anonClient.auth.getUser(token);
if (error || !user?.email) return null;
return user.email;
}
export async function GET(req: NextRequest) {
const email = await getKundeEmail(req.headers.get("authorization"));
if (!email) {
return NextResponse.json({ error: "Nicht authentifiziert" }, { status: 401 });
}
const db = createServiceClient();
const { data: anfragen, error } = await db
.from("anfragen")
.select("id, created_at, status, name, betreff, nachricht, email")
.eq("email", email)
.order("created_at", { ascending: false });
if (error) {
return NextResponse.json({ error: "Datenbankfehler" }, { status: 500 });
}
return NextResponse.json({ anfragen: anfragen ?? [] });
}

View File

@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from "next/server";
import { createServiceClient } from "@/lib/supabase";
import { sendeRegistrierungsBestaetigung } from "@/lib/mailer";
export async function POST(req: NextRequest) {
let body: { email?: string; password?: string; firma?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Ungültige Eingabe" }, { status: 422 });
}
const { email, password, firma } = body;
if (!email || !password || password.length < 8) {
return NextResponse.json({ error: "Ungültige Eingabe" }, { status: 422 });
}
const appUrl = process.env.APP_URL ?? "https://mbo-tech-it.de";
const db = createServiceClient();
const { data: linkData, error } = await db.auth.admin.generateLink({
type: "signup",
email,
password,
options: {
data: { firma: firma ?? "" },
redirectTo: `${appUrl}/auth/callback`,
},
});
if (error) {
if (
error.message.includes("already registered") ||
error.message.includes("already been registered")
) {
return NextResponse.json({ error: "already_registered" }, { status: 409 });
}
console.error("[Registrierung] Fehler:", error.message);
return NextResponse.json({ error: error.message }, { status: 500 });
}
const bestaetigungsLink = linkData.properties?.action_link;
if (!bestaetigungsLink) {
return NextResponse.json({ error: "Kein Bestätigungslink erhalten" }, { status: 500 });
}
await sendeRegistrierungsBestaetigung({ email, firma, bestaetigungsLink });
return NextResponse.json({ success: true });
}

View File

@ -0,0 +1,88 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { CheckCircle2, XCircle } from "lucide-react";
import Link from "next/link";
import { createBrowserSupabaseClient } from "@/lib/supabase";
export default function AuthCallbackPage() {
const router = useRouter();
const [status, setStatus] = useState<"loading" | "success" | "error">("loading");
useEffect(() => {
const supabase = createBrowserSupabaseClient();
const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => {
if ((event === "SIGNED_IN" || event === "TOKEN_REFRESHED") && session) {
setStatus("success");
setTimeout(() => router.replace("/kunden/dashboard"), 1500);
}
});
supabase.auth.getSession().then(({ data: { session } }) => {
if (session) {
setStatus("success");
setTimeout(() => router.replace("/kunden/dashboard"), 1500);
} else {
setTimeout(() => {
supabase.auth.getSession().then(({ data: { session: s } }) => {
if (s) {
setStatus("success");
setTimeout(() => router.replace("/kunden/dashboard"), 1500);
} else {
setStatus("error");
}
});
}, 2000);
}
});
return () => subscription.unsubscribe();
}, [router]);
if (status === "loading") {
return (
<div className="min-h-screen bg-[#111925] flex items-center justify-center px-4">
<div className="text-center">
<div className="w-10 h-10 border-2 border-orange-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-slate-400 text-sm">E-Mail-Adresse wird bestätigt</p>
</div>
</div>
);
}
if (status === "success") {
return (
<div className="min-h-screen bg-[#111925] flex items-center justify-center px-4">
<div className="w-full max-w-md text-center bg-[#18212f] border border-gray-800 rounded-2xl p-8">
<div className="w-12 h-12 bg-green-500/10 rounded-full flex items-center justify-center mx-auto mb-4 border border-green-500/20">
<CheckCircle2 className="w-6 h-6 text-green-400" />
</div>
<h2 className="text-xl font-bold text-white mb-2">E-Mail bestätigt!</h2>
<p className="text-slate-400 text-sm">Sie werden automatisch weitergeleitet</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-[#111925] flex items-center justify-center px-4">
<div className="w-full max-w-md text-center bg-[#18212f] border border-gray-800 rounded-2xl p-8">
<div className="w-12 h-12 bg-red-500/10 rounded-full flex items-center justify-center mx-auto mb-4 border border-red-500/20">
<XCircle className="w-6 h-6 text-red-400" />
</div>
<h2 className="text-xl font-bold text-white mb-2">Bestätigung fehlgeschlagen</h2>
<p className="text-slate-400 text-sm mb-6">
Der Bestätigungslink ist abgelaufen oder ungültig. Bitte registrieren Sie sich erneut.
</p>
<Link
href="/kunden/registrieren"
className="inline-flex items-center justify-center bg-orange-500 hover:bg-orange-600 text-white rounded-xl font-semibold px-6 py-2.5 text-sm transition-colors"
>
Zur Registrierung
</Link>
</div>
</div>
);
}

View File

@ -0,0 +1,166 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { createBrowserSupabaseClient } from "@/lib/supabase";
import type { User } from "@supabase/supabase-js";
interface Anfrage {
id: string;
created_at: string;
status: string;
name: string;
betreff: string;
nachricht: string | null;
email: string;
}
function formatDatum(iso: string) {
return new Date(iso).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
function StatusBadge({ status }: { status: string }) {
const styles: Record<string, string> = {
offen: "bg-amber-500/10 text-amber-400 border border-amber-500/20",
in_bearbeitung: "bg-blue-500/10 text-blue-400 border border-blue-500/20",
abgeschlossen: "bg-green-500/10 text-green-400 border border-green-500/20",
};
const labels: Record<string, string> = {
offen: "Offen",
in_bearbeitung: "In Bearbeitung",
abgeschlossen: "Abgeschlossen",
};
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${styles[status] ?? styles.offen}`}>
{labels[status] ?? status}
</span>
);
}
export default function KundenDashboardPage() {
const router = useRouter();
const [user, setUser] = useState<User | null>(null);
const [anfragen, setAnfragen] = useState<Anfrage[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const supabase = createBrowserSupabaseClient();
async function init() {
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
router.push("/kunden/login");
return;
}
setUser(session.user);
const res = await fetch("/api/kunden/anfragen", {
headers: { Authorization: `Bearer ${session.access_token}` },
});
if (res.ok) {
const json = await res.json();
setAnfragen(json.anfragen ?? []);
}
setLoading(false);
}
init();
}, [router]);
async function handleLogout() {
const supabase = createBrowserSupabaseClient();
await supabase.auth.signOut();
router.push("/kunden/login");
}
if (loading) {
return (
<div className="min-h-screen bg-[#111925] flex items-center justify-center">
<p className="text-slate-500">Wird geladen</p>
</div>
);
}
return (
<div className="min-h-screen bg-[#111925]">
<div className="max-w-4xl mx-auto px-4 py-10">
<div className="flex items-start justify-between gap-4 mb-8">
<div>
<h1 className="text-2xl font-bold text-white tracking-tight">Mein Bereich</h1>
<p className="text-slate-500 text-sm mt-1">{user?.email}</p>
</div>
<button
onClick={handleLogout}
className="inline-flex items-center gap-1.5 text-sm border border-gray-700 rounded-lg px-3 py-1.5 text-slate-400 hover:text-white hover:border-gray-600 transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Abmelden
</button>
</div>
<hr className="border-gray-800 mb-8" />
<div>
<div className="flex items-center justify-between mb-5">
<h2 className="font-semibold text-white">Meine IT-Anfragen</h2>
<Link
href="/#contact"
className="text-sm text-orange-400 hover:text-orange-300 font-medium transition-colors"
>
+ Neue Anfrage
</Link>
</div>
{anfragen.length === 0 ? (
<div className="border border-dashed border-gray-700 py-12 text-center rounded-xl">
<svg className="w-8 h-8 text-slate-600 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p className="text-slate-500 mb-1">Noch keine Anfragen vorhanden</p>
<p className="text-sm text-slate-600 mb-4">
Kontaktieren Sie uns über das Kontaktformular auf der Startseite.
</p>
<Link
href="/#contact"
className="inline-flex items-center justify-center bg-orange-500 hover:bg-orange-600 text-white rounded-xl font-semibold px-5 py-2 text-sm transition-colors"
>
Zum Kontaktformular
</Link>
</div>
) : (
<div className="space-y-4">
{anfragen.map((anfrage) => (
<div key={anfrage.id} className="bg-[#18212f] border border-gray-800 rounded-xl overflow-hidden">
<div className="flex items-center justify-between gap-3 px-5 py-4 border-b border-gray-800">
<div className="flex items-center gap-3">
<StatusBadge status={anfrage.status} />
<span className="text-xs text-slate-500">{formatDatum(anfrage.created_at)}</span>
</div>
<span className="text-xs text-slate-600 font-mono hidden sm:block">
#{anfrage.id.slice(0, 8)}
</span>
</div>
<div className="px-5 py-4">
<p className="font-semibold text-white text-sm mb-1">{anfrage.betreff}</p>
{anfrage.nachricht && (
<p className="text-sm text-slate-400 line-clamp-2">{anfrage.nachricht}</p>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}

114
app/kunden/login/page.tsx Normal file
View File

@ -0,0 +1,114 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { createBrowserSupabaseClient } from "@/lib/supabase";
export default function KundenLoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [passwort, setPasswort] = useState("");
const [fehler, setFehler] = useState("");
const [loading, setLoading] = useState(false);
async function handleLogin(e: React.FormEvent) {
e.preventDefault();
setFehler("");
setLoading(true);
const supabase = createBrowserSupabaseClient();
const { error } = await supabase.auth.signInWithPassword({ email, password: passwort });
if (error) {
if (error.message.toLowerCase().includes("email not confirmed")) {
setFehler("Bitte bestätigen Sie zuerst Ihre E-Mail-Adresse.");
} else {
setFehler("E-Mail oder Passwort ungültig.");
}
} else {
router.push("/kunden/dashboard");
}
setLoading(false);
}
return (
<div className="min-h-screen bg-[#111925] flex items-center justify-center px-4">
<div className="w-full max-w-sm">
<div className="bg-[#18212f] border border-gray-800 rounded-2xl p-8">
<div className="mb-8">
<div className="w-10 h-10 rounded-xl bg-orange-500/10 border border-orange-500/30 flex items-center justify-center mb-4">
<svg className="w-5 h-5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<h1 className="text-xl font-bold text-white tracking-tight">Kunden-Login</h1>
<p className="text-sm text-slate-500 mt-1">Melde dich an, um deine IT-Anfragen einzusehen</p>
</div>
<form onSubmit={handleLogin} className="space-y-4">
<div>
<label htmlFor="kl-email" className="block text-sm font-medium text-slate-400 mb-1.5">
E-Mail-Adresse
</label>
<input
id="kl-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="ihre@email.de"
required
autoComplete="email"
className="w-full px-4 py-3 rounded-xl bg-[#111925] border border-gray-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500/60 focus:ring-1 focus:ring-orange-500/20 transition-colors"
/>
</div>
<div>
<label htmlFor="kl-passwort" className="block text-sm font-medium text-slate-400 mb-1.5">
Passwort
</label>
<input
id="kl-passwort"
type="password"
value={passwort}
onChange={(e) => setPasswort(e.target.value)}
placeholder="••••••••"
required
autoComplete="current-password"
className="w-full px-4 py-3 rounded-xl bg-[#111925] border border-gray-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500/60 focus:ring-1 focus:ring-orange-500/20 transition-colors"
/>
</div>
{fehler && (
<p className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 px-3 py-2 rounded-lg">
{fehler}
</p>
)}
<button
type="submit"
disabled={loading}
className="w-full py-3 px-4 bg-orange-500 hover:bg-orange-600 disabled:opacity-60 disabled:cursor-not-allowed text-white font-semibold rounded-xl transition-colors"
>
{loading ? "Wird angemeldet…" : "Anmelden"}
</button>
</form>
<div className="mt-6 pt-5 border-t border-gray-800 text-center space-y-3">
<p className="text-sm text-slate-500">
Noch kein Konto?{" "}
<Link href="/kunden/registrieren" className="text-orange-400 hover:text-orange-300 font-medium">
Jetzt registrieren
</Link>
</p>
<p className="text-xs text-slate-600">
Fragen?{" "}
<a href="tel:+4917193451093" data-source-element="kunden-login" className="text-slate-500 hover:text-orange-400 transition-colors">
+49 171 9345193
</a>
</p>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,180 @@
"use client";
import { useState } from "react";
import Link from "next/link";
export default function KundenRegistrierenPage() {
const [email, setEmail] = useState("");
const [passwort, setPasswort] = useState("");
const [passwortWdh, setPasswortWdh] = useState("");
const [firma, setFirma] = useState("");
const [fehler, setFehler] = useState("");
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
async function handleRegistrieren(e: React.FormEvent) {
e.preventDefault();
setFehler("");
if (passwort.length < 8) {
setFehler("Das Passwort muss mindestens 8 Zeichen lang sein.");
return;
}
if (passwort !== passwortWdh) {
setFehler("Die Passwörter stimmen nicht überein.");
return;
}
setLoading(true);
const res = await fetch("/api/kunden/registrieren", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password: passwort, firma }),
});
const json = await res.json();
if (!res.ok) {
if (json.error === "already_registered") {
setFehler("Diese E-Mail ist bereits registriert. Bitte direkt anmelden.");
} else {
setFehler(`Registrierung fehlgeschlagen: ${json.error}`);
}
} else {
setSuccess(true);
}
setLoading(false);
}
if (success) {
return (
<div className="min-h-screen bg-[#111925] flex items-center justify-center px-4">
<div className="w-full max-w-sm text-center bg-[#18212f] border border-gray-800 rounded-2xl p-8">
<div className="w-12 h-12 bg-green-500/10 rounded-full flex items-center justify-center mx-auto mb-4 border border-green-500/20">
<svg className="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-xl font-bold text-white mb-2">Registrierung erfolgreich!</h2>
<p className="text-slate-400 text-sm mb-6">
Bitte bestätigen Sie Ihre E-Mail-Adresse. Nach der Bestätigung können Sie sich anmelden.
</p>
<Link
href="/kunden/login"
className="inline-flex items-center justify-center bg-orange-500 hover:bg-orange-600 text-white rounded-xl font-semibold px-6 py-2.5 text-sm transition-colors"
>
Zur Anmeldung
</Link>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-[#111925] flex items-center justify-center px-4">
<div className="w-full max-w-sm">
<div className="bg-[#18212f] border border-gray-800 rounded-2xl p-8">
<div className="mb-8">
<div className="w-10 h-10 rounded-xl bg-orange-500/10 border border-orange-500/30 flex items-center justify-center mb-4">
<svg className="w-5 h-5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
</svg>
</div>
<h1 className="text-xl font-bold text-white tracking-tight">Konto erstellen</h1>
<p className="text-sm text-slate-500 mt-1">Registrieren, um IT-Anfragen zu verfolgen</p>
</div>
<form onSubmit={handleRegistrieren} className="space-y-4">
<div>
<label htmlFor="kr-firma" className="block text-sm font-medium text-slate-400 mb-1.5">
Firma / Name
</label>
<input
id="kr-firma"
value={firma}
onChange={(e) => setFirma(e.target.value)}
placeholder="Muster GmbH oder Max Muster"
autoComplete="organization"
className="w-full px-4 py-3 rounded-xl bg-[#111925] border border-gray-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500/60 focus:ring-1 focus:ring-orange-500/20 transition-colors"
/>
</div>
<div>
<label htmlFor="kr-email" className="block text-sm font-medium text-slate-400 mb-1.5">
E-Mail-Adresse *
</label>
<input
id="kr-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="ihre@email.de"
required
autoComplete="email"
className="w-full px-4 py-3 rounded-xl bg-[#111925] border border-gray-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500/60 focus:ring-1 focus:ring-orange-500/20 transition-colors"
/>
</div>
<div>
<label htmlFor="kr-pw" className="block text-sm font-medium text-slate-400 mb-1.5">
Passwort * (min. 8 Zeichen)
</label>
<input
id="kr-pw"
type="password"
value={passwort}
onChange={(e) => setPasswort(e.target.value)}
placeholder="••••••••"
required
autoComplete="new-password"
className="w-full px-4 py-3 rounded-xl bg-[#111925] border border-gray-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500/60 focus:ring-1 focus:ring-orange-500/20 transition-colors"
/>
</div>
<div>
<label htmlFor="kr-pw2" className="block text-sm font-medium text-slate-400 mb-1.5">
Passwort wiederholen *
</label>
<input
id="kr-pw2"
type="password"
value={passwortWdh}
onChange={(e) => setPasswortWdh(e.target.value)}
placeholder="••••••••"
required
autoComplete="new-password"
className="w-full px-4 py-3 rounded-xl bg-[#111925] border border-gray-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500/60 focus:ring-1 focus:ring-orange-500/20 transition-colors"
/>
</div>
{fehler && (
<p className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 px-3 py-2 rounded-lg">
{fehler}
</p>
)}
<button
type="submit"
disabled={loading}
className="w-full py-3 px-4 bg-orange-500 hover:bg-orange-600 disabled:opacity-60 disabled:cursor-not-allowed text-white font-semibold rounded-xl transition-colors"
>
{loading ? "Wird registriert…" : "Konto erstellen"}
</button>
</form>
<div className="mt-6 pt-5 border-t border-gray-800 text-center space-y-3">
<p className="text-sm text-slate-500">
Bereits registriert?{" "}
<Link href="/kunden/login" className="text-orange-400 hover:text-orange-300 font-medium">
Jetzt anmelden
</Link>
</p>
<p className="text-xs text-slate-600">
Fragen?{" "}
<a href="tel:+4917193451093" data-source-element="kunden-registrieren" className="text-slate-500 hover:text-orange-400 transition-colors">
+49 171 9345193
</a>
</p>
</div>
</div>
</div>
</div>
);
}

View File

@ -1,11 +1,12 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import "./globals.css"; import "./globals.css";
import Providers from "@/components/Providers"; import Providers from "@/components/Providers";
import { PageTracker } from "@/components/analytics/PageTracker";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "MBO-Tech-IT | Docker, Kubernetes & Cloud-Infrastruktur", title: "MBO-Tech-IT | Docker, Kubernetes & Cloud-Infrastruktur",
description: description:
"Ihr Experte fur Docker-Installationen, Kubernetes-Orchestrierung, Proxmox-Virtualisierung und Hetzner Cloud-Infrastruktur. IT-Losungen fur Hard- und Software.", "Ihr Experte für Docker-Installationen, Kubernetes-Orchestrierung, Proxmox-Virtualisierung und Hetzner Cloud-Infrastruktur. IT-Lösungen für Hard- und Software.",
keywords: [ keywords: [
"Docker", "Docker",
"Kubernetes", "Kubernetes",
@ -27,6 +28,7 @@ export default function RootLayout({
return ( return (
<html lang="de" className="scroll-smooth" suppressHydrationWarning> <html lang="de" className="scroll-smooth" suppressHydrationWarning>
<body> <body>
<PageTracker />
<Providers>{children}</Providers> <Providers>{children}</Providers>
</body> </body>
</html> </html>

View File

@ -208,6 +208,7 @@ export default function Contact() {
<a <a
key={item.label} key={item.label}
href={item.href} href={item.href}
data-source-element="kontakt-section"
className="flex items-center gap-3 text-slate-700 dark:text-slate-300 hover:text-orange-400 transition-colors duration-200 group" className="flex items-center gap-3 text-slate-700 dark:text-slate-300 hover:text-orange-400 transition-colors duration-200 group"
> >
<span className="w-10 h-10 rounded-lg bg-orange-500/10 border border-orange-500/20 flex items-center justify-center text-orange-400 group-hover:bg-orange-500/20 transition-colors duration-200"> <span className="w-10 h-10 rounded-lg bg-orange-500/10 border border-orange-500/20 flex items-center justify-center text-orange-400 group-hover:bg-orange-500/20 transition-colors duration-200">

View File

@ -0,0 +1,49 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
const navLinks = [
{ href: "/admin/analytics", label: "Analytics" },
{ href: "/admin/statistik", label: "Statistik" },
{ href: "/admin/audit-logs", label: "Audit-Logs" },
];
export function AdminNav() {
const pathname = usePathname();
const router = useRouter();
async function handleLogout() {
await fetch("/api/admin/login", { method: "DELETE" });
router.push("/admin/login");
}
return (
<nav className="bg-[#18212f] border-b border-gray-800 px-4 py-3">
<div className="max-w-7xl mx-auto flex items-center justify-between gap-4">
<div className="flex items-center gap-1">
<span className="text-orange-400 font-bold text-sm mr-4">MBO Tech IT · Admin</span>
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className={`px-3 py-1.5 rounded text-sm font-medium transition-colors ${
pathname.startsWith(link.href)
? "bg-orange-500 text-white"
: "text-slate-400 hover:text-white hover:bg-white/10"
}`}
>
{link.label}
</Link>
))}
</div>
<button
onClick={handleLogout}
className="text-slate-500 hover:text-slate-300 text-sm transition-colors"
>
Abmelden
</button>
</div>
</nav>
);
}

View File

@ -0,0 +1,218 @@
"use client";
import { ReactNode, useState, useEffect } from "react";
interface PhoneKpis {
callsToday: number;
callsTodayTrend: number;
uniqueNumbers: number;
topSourceElement: string | null;
topSourceElementPercent: number;
}
interface PhoneNumber {
phone_number: string;
click_count: number;
}
interface ElementStat {
source_element: string;
count: number;
percent: number;
}
interface TimeseriesEntry {
date: string;
count: number;
}
interface PhoneData {
kpis: PhoneKpis;
phoneNumbers: PhoneNumber[];
elements: ElementStat[];
timeseries: TimeseriesEntry[];
}
function balkenBreite(wert: number, max: number): string {
if (max === 0) return "0%";
return `${Math.max(4, Math.round((wert / max) * 100))}%`;
}
function PhoneCallsView() {
const [data, setData] = useState<PhoneData | null>(null);
const [loading, setLoading] = useState(true);
const [range, setRange] = useState("30days");
useEffect(() => {
setLoading(true);
fetch(`/api/admin/analytics/phone-calls?range=${range}`)
.then((r) => r.json())
.then((d) => { setData(d); setLoading(false); })
.catch(() => setLoading(false));
}, [range]);
if (loading) {
return <div className="py-12 text-center text-slate-500">Lädt Phone-Click-Daten</div>;
}
if (!data) {
return <div className="py-12 text-center text-red-500">Fehler beim Laden der Daten</div>;
}
const maxTimeseries = Math.max(0, ...data.timeseries.map((t) => t.count));
const maxElements = Math.max(0, ...data.elements.map((e) => e.count));
return (
<div className="space-y-6">
{/* Header */}
<div className="bg-[#18212f] border border-gray-800 rounded-lg py-6 px-6">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-xl font-bold text-white tracking-tight">Phone-Click-Tracking</h2>
<p className="text-slate-500 text-sm mt-1">Telefon-Link-Klicks · DSGVO-konform</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
{[["today", "Heute"], ["7days", "7 Tage"], ["30days", "30 Tage"]].map(([val, label]) => (
<button
key={val}
onClick={() => setRange(val)}
className={`px-3 py-1 rounded text-sm font-medium transition-all ${
range === val ? "bg-orange-500 text-white" : "bg-gray-800 text-slate-400 hover:bg-gray-700 hover:text-white"
}`}
>
{label}
</button>
))}
</div>
</div>
{/* KPI-Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-[#18212f] border border-gray-800 p-4 rounded-lg">
<div className="text-sm text-slate-500">Klicks heute</div>
<div className="text-2xl font-bold text-white">{data.kpis.callsToday}</div>
{data.kpis.callsTodayTrend !== 0 && (
<div className={`text-xs mt-1 ${data.kpis.callsTodayTrend > 0 ? "text-green-400" : "text-red-400"}`}>
{data.kpis.callsTodayTrend > 0 ? "+" : ""}{data.kpis.callsTodayTrend}% vs. gestern
</div>
)}
</div>
<div className="bg-[#18212f] border border-gray-800 p-4 rounded-lg">
<div className="text-sm text-slate-500">Eindeutige Nummern</div>
<div className="text-2xl font-bold text-white">{data.kpis.uniqueNumbers}</div>
</div>
<div className="bg-[#18212f] border border-gray-800 p-4 rounded-lg">
<div className="text-sm text-slate-500">Top Quelle</div>
<div className="text-2xl font-bold text-white">{data.kpis.topSourceElement ?? ""}</div>
{data.kpis.topSourceElementPercent > 0 && (
<div className="text-xs text-slate-500 mt-1">{data.kpis.topSourceElementPercent}% aller Klicks</div>
)}
</div>
<div className="bg-[#18212f] border border-gray-800 p-4 rounded-lg">
<div className="text-sm text-slate-500">Klicks gesamt</div>
<div className="text-2xl font-bold text-white">
{data.phoneNumbers.reduce((s, p) => s + p.click_count, 0)}
</div>
</div>
</div>
{/* Zeitreihe */}
{data.timeseries.length > 0 && (
<div className="bg-[#18212f] border border-gray-800 rounded-lg p-6">
<h3 className="text-base font-bold text-white mb-4">Klicks über Zeit</h3>
<div className="space-y-2">
{data.timeseries.map(({ date, count }) => (
<div key={date} className="flex items-center gap-3">
<div className="w-24 text-sm font-mono text-slate-500">{date}</div>
<div className="flex-1 h-7 bg-[#111925] rounded flex items-center overflow-hidden">
<div className="h-full bg-orange-500" style={{ width: balkenBreite(count, maxTimeseries) }} />
</div>
<div className="w-8 text-right text-sm font-semibold text-white">{count}</div>
</div>
))}
</div>
</div>
)}
{/* Quellen */}
{data.elements.length > 0 && (
<div className="bg-[#18212f] border border-gray-800 rounded-lg p-6">
<h3 className="text-base font-bold text-white mb-4">Klicks nach Quelle</h3>
<div className="space-y-3">
{data.elements.map(({ source_element, count }) => (
<div key={source_element} className="flex items-center gap-3">
<div className="w-36 text-sm font-medium text-slate-400 truncate">{source_element}</div>
<div className="flex-1 h-6 bg-[#111925] rounded overflow-hidden">
<div className="h-full bg-orange-500" style={{ width: balkenBreite(count, maxElements) }} />
</div>
<div className="w-8 text-right text-sm font-semibold text-white">{count}</div>
</div>
))}
</div>
</div>
)}
{/* Nummern-Tabelle */}
{data.phoneNumbers.length > 0 && (
<div className="bg-[#18212f] border border-gray-800 rounded-lg overflow-hidden">
<div className="p-4 border-b border-gray-800">
<h3 className="font-bold text-white">Gerufene Nummern</h3>
</div>
<table className="w-full text-sm">
<thead className="border-b border-gray-800">
<tr className="text-xs text-slate-500 uppercase tracking-wide">
<th className="px-4 py-3 text-left font-medium">Telefonnummer</th>
<th className="px-4 py-3 text-right font-medium">Klicks</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800/50">
{data.phoneNumbers.map((p) => (
<tr key={p.phone_number} className="hover:bg-[#111925] transition-colors">
<td className="px-4 py-3 font-mono text-slate-300">{p.phone_number}</td>
<td className="px-4 py-3 text-right font-semibold text-white">{p.click_count}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{data.phoneNumbers.length === 0 && (
<div className="text-center py-12 text-slate-500">
Noch keine Phone-Klicks im gewählten Zeitraum
</div>
)}
</div>
);
}
export function AnalyticsTabs({ overviewContent }: { overviewContent: ReactNode }) {
const [activeTab, setActiveTab] = useState<"overview" | "phone">("overview");
return (
<div>
<div className="flex gap-1 mb-0 border-b border-gray-800 bg-[#18212f] px-4">
{[
{ key: "overview", label: "Seitenaufrufe" },
{ key: "phone", label: "Phone-Klicks" },
].map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key as "overview" | "phone")}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px ${
activeTab === tab.key
? "border-orange-500 text-orange-400"
: "border-transparent text-slate-500 hover:text-white"
}`}
>
{tab.label}
</button>
))}
</div>
<div className="pt-6">
{activeTab === "overview" ? overviewContent : <PhoneCallsView />}
</div>
</div>
);
}

View File

@ -0,0 +1,47 @@
"use client";
import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
const INACTIVITY_TIMEOUT_MS = 2 * 60 * 60 * 1000;
const WARNING_BEFORE_LOGOUT_MS = 15 * 60 * 1000;
export function SessionTimeoutProvider({ children }: { children: React.ReactNode }) {
const router = useRouter();
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const warningTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const warningShownRef = useRef<boolean>(false);
function resetTimeout() {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
if (warningTimeoutRef.current) clearTimeout(warningTimeoutRef.current);
warningShownRef.current = false;
warningTimeoutRef.current = setTimeout(() => {
if (!warningShownRef.current) {
warningShownRef.current = true;
alert("⚠️ Ihre Session läuft in 15 Minuten ab. Bitte klicken Sie auf eine Taste oder bewegen Sie die Maus, um weiterzuarbeiten.");
}
}, INACTIVITY_TIMEOUT_MS - WARNING_BEFORE_LOGOUT_MS);
timeoutRef.current = setTimeout(async () => {
await fetch("/api/admin/login", { method: "DELETE" });
router.push("/admin/login?session_expired=true");
}, INACTIVITY_TIMEOUT_MS);
}
useEffect(() => {
const events = ["mousedown", "keydown", "scroll", "touchstart"];
const handleActivity = () => resetTimeout();
events.forEach((e) => window.addEventListener(e, handleActivity));
resetTimeout();
return () => {
events.forEach((e) => window.removeEventListener(e, handleActivity));
if (timeoutRef.current) clearTimeout(timeoutRef.current);
if (warningTimeoutRef.current) clearTimeout(warningTimeoutRef.current);
};
}, [router]);
return <>{children}</>;
}

View File

@ -0,0 +1,79 @@
"use client";
import { useEffect, useRef } from "react";
import { usePathname } from "next/navigation";
const EXCLUDED_PREFIXES = ["/admin", "/api", "/_next"];
export function PageTracker() {
const pathname = usePathname();
const viewIdRef = useRef<string | null>(null);
const startTimeRef = useRef<number>(Date.now());
function getSessionId(): string {
let sid = sessionStorage.getItem("_mpv_sid");
if (!sid) {
sid = crypto.randomUUID();
sessionStorage.setItem("_mpv_sid", sid);
}
return sid;
}
useEffect(() => {
if (EXCLUDED_PREFIXES.some((p) => pathname.startsWith(p))) return;
startTimeRef.current = Date.now();
viewIdRef.current = null;
fetch("/api/analytics/track", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: pathname,
session_id: getSessionId(),
referrer: typeof document !== "undefined" ? document.referrer || undefined : undefined,
}),
})
.then((r) => r.json())
.then((d) => { viewIdRef.current = d.view_id ?? null; })
.catch(() => {});
function sendDuration() {
if (!viewIdRef.current) return;
const ms = Date.now() - startTimeRef.current;
const blob = new Blob(
[JSON.stringify({ path: pathname, session_id: getSessionId(), view_id: viewIdRef.current, duration_ms: ms })],
{ type: "application/json" }
);
navigator.sendBeacon("/api/analytics/track", blob);
}
function trackPhoneClick(event: Event) {
const target = event.target as HTMLElement;
const link = target.closest('a[href^="tel:"]');
if (!link) return;
const phoneNumber = link.getAttribute("href")?.replace("tel:", "").trim();
if (!phoneNumber) return;
const sourceElement =
link.getAttribute("data-source-element") ||
link.closest("[data-source-element]")?.getAttribute("data-source-element") ||
"unknown";
fetch("/api/analytics/track-phone-click", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone_number: phoneNumber, source_page: pathname, source_element: sourceElement, session_id: getSessionId() }),
}).catch(() => {});
}
window.addEventListener("pagehide", sendDuration);
document.addEventListener("click", trackPhoneClick);
return () => {
document.removeEventListener("click", trackPhoneClick);
window.removeEventListener("pagehide", sendDuration);
sendDuration();
};
}, [pathname]);
return null;
}

145
lib/admin-auth.ts Normal file
View File

@ -0,0 +1,145 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { isSessionTokenRevoked, isActionTokenUsed } from "./token-blacklist";
const encoder = new TextEncoder();
interface AdminSession {
id: string;
email: string;
name: string;
exp: number;
}
interface ActionToken {
anfrageId: string;
status: "in_bearbeitung" | "abgeschlossen";
exp: number;
}
function toBase64Url(buffer: ArrayBuffer | Uint8Array): string {
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}
function fromBase64Url(str: string): ArrayBuffer {
const b64 = str.replace(/-/g, "+").replace(/_/g, "/");
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
return bytes.buffer as ArrayBuffer;
}
async function getKey(secret: string): Promise<CryptoKey> {
return crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign", "verify"]
);
}
export async function createSessionToken(
session: Omit<AdminSession, "exp">
): Promise<string> {
const secret = process.env.ADMIN_SESSION_TOKEN!;
const payload: AdminSession = {
...session,
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 2,
};
const data = toBase64Url(encoder.encode(JSON.stringify(payload)));
const key = await getKey(secret);
const sig = toBase64Url(await crypto.subtle.sign("HMAC", key, encoder.encode(data)));
return `${data}.${sig}`;
}
export async function requireAdmin(): Promise<AdminSession | NextResponse> {
const cookieStore = await cookies();
const token = cookieStore.get("admin_session")?.value;
if (!token) {
return NextResponse.json({ error: "Nicht authentifiziert" }, { status: 401 });
}
const session = await verifySessionToken(token);
if (!session) {
return NextResponse.json({ error: "Session ungültig" }, { status: 401 });
}
return session;
}
export async function verifySessionToken(token: string): Promise<AdminSession | null> {
try {
const secret = process.env.ADMIN_SESSION_TOKEN!;
const [data, sig] = token.split(".");
if (!data || !sig) return null;
if (await isSessionTokenRevoked(sig)) return null;
const key = await getKey(secret);
const valid = await crypto.subtle.verify(
"HMAC",
key,
fromBase64Url(sig),
encoder.encode(data)
);
if (!valid) return null;
const session: AdminSession = JSON.parse(
new TextDecoder().decode(new Uint8Array(fromBase64Url(data)))
);
if (session.exp < Math.floor(Date.now() / 1000)) return null;
return session;
} catch {
return null;
}
}
export async function createActionToken(
anfrageId: string,
status: "in_bearbeitung" | "abgeschlossen",
ablaufTage = 7
): Promise<string> {
const secret = process.env.ADMIN_SESSION_TOKEN!;
const payload: ActionToken = {
anfrageId,
status,
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * ablaufTage,
};
const data = toBase64Url(encoder.encode(JSON.stringify(payload)));
const key = await getKey(secret);
const sig = toBase64Url(await crypto.subtle.sign("HMAC", key, encoder.encode(data)));
return `${data}.${sig}`;
}
export async function verifyActionToken(
token: string
): Promise<{ anfrageId: string; status: string } | null> {
try {
const secret = process.env.ADMIN_SESSION_TOKEN!;
const [data, sig] = token.split(".");
if (!data || !sig) return null;
if (await isActionTokenUsed(sig)) return null;
const key = await getKey(secret);
const valid = await crypto.subtle.verify(
"HMAC",
key,
fromBase64Url(sig),
encoder.encode(data)
);
if (!valid) return null;
const actionToken: ActionToken = JSON.parse(
new TextDecoder().decode(new Uint8Array(fromBase64Url(data)))
);
if (actionToken.exp < Math.floor(Date.now() / 1000)) return null;
return { anfrageId: actionToken.anfrageId, status: actionToken.status };
} catch {
return null;
}
}

47
lib/analytics.ts Normal file
View File

@ -0,0 +1,47 @@
const BOT_PATTERNS =
/bot|crawl|spider|slurp|mediapartners|adsbot|facebookexternalhit|twitterbot|linkedinbot|whatsapp|telegram|pinterest|slack|discordbot|applebot|bingpreview|google-read-aloud|ia_archiver|mj12bot|ahrefs|semrush|dotbot|rogerbot|screaming\.?frog/i;
export function isBot(ua: string): boolean {
return BOT_PATTERNS.test(ua);
}
export function anonymizeIp(ip: string): string {
const mapped = ip.match(/^::ffff:(\d+\.\d+\.\d+)\.\d+$/i);
if (mapped) return `${mapped[1]}.0`;
const v4 = ip.match(/^(\d+\.\d+\.\d+)\.\d+$/);
if (v4) return `${v4[1]}.0`;
const v6parts = ip.split(":");
if (v6parts.length >= 3) return `${v6parts.slice(0, 3).join(":")}::`;
return "0.0.0.0";
}
export type DeviceType = "mobile" | "tablet" | "desktop";
export type BrowserName = "Chrome" | "Firefox" | "Safari" | "Edge" | "Opera" | "Other";
export type OsName = "Windows" | "macOS" | "iOS" | "Android" | "Linux" | "Other";
export function parseDevice(ua: string): DeviceType {
if (/tablet|ipad|playbook|silk/i.test(ua)) return "tablet";
if (/mobile|android.*mobile|iphone|ipod|blackberry|windows phone/i.test(ua)) return "mobile";
return "desktop";
}
export function parseBrowser(ua: string): BrowserName {
if (/edg\//i.test(ua)) return "Edge";
if (/opr\//i.test(ua)) return "Opera";
if (/firefox\//i.test(ua)) return "Firefox";
if (/chrome\//i.test(ua)) return "Chrome";
if (/safari\//i.test(ua)) return "Safari";
return "Other";
}
export function parseOs(ua: string): OsName {
if (/windows/i.test(ua)) return "Windows";
if (/iphone|ipad|ipod/i.test(ua)) return "iOS";
if (/android/i.test(ua)) return "Android";
if (/mac os x|macintosh/i.test(ua)) return "macOS";
if (/linux/i.test(ua)) return "Linux";
return "Other";
}

91
lib/audit-log.ts Normal file
View File

@ -0,0 +1,91 @@
import { createServiceClient } from "@/lib/supabase";
export interface AuditLogEntry {
id: string;
email: string;
ip_addr: string;
user_agent: string;
success: boolean;
timestamp: string;
reason?: string;
}
export async function logLoginAttempt(
email: string,
ipAddr: string,
success: boolean,
userAgent: string,
reason?: string
): Promise<void> {
try {
const db = createServiceClient();
await db.from("admin_audit_logs").insert({
email: email.toLowerCase().trim(),
ip_addr: ipAddr,
user_agent: userAgent,
success,
reason,
timestamp: new Date().toISOString(),
});
} catch (error) {
console.error("Fehler beim Audit-Logging:", error);
}
}
export async function getFailedLoginCount(
emailOrIp: string,
type: "email" | "ip" = "email",
timeWindowMinutes = 60
): Promise<number> {
try {
const db = createServiceClient();
const since = new Date(Date.now() - timeWindowMinutes * 60 * 1000).toISOString();
const column = type === "email" ? "email" : "ip_addr";
const { count } = await db
.from("admin_audit_logs")
.select("id", { count: "exact" })
.eq(column, emailOrIp)
.eq("success", false)
.gt("timestamp", since);
return count || 0;
} catch {
return 0;
}
}
export async function getAuditLogs(options?: {
email?: string;
ipAddr?: string;
successOnly?: boolean;
limit?: number;
offset?: number;
}): Promise<AuditLogEntry[]> {
try {
const db = createServiceClient();
const limit = options?.limit ?? 100;
const offset = options?.offset ?? 0;
let query = db
.from("admin_audit_logs")
.select("*")
.order("timestamp", { ascending: false })
.range(offset, offset + limit - 1);
if (options?.email) query = query.eq("email", options.email.toLowerCase().trim());
if (options?.ipAddr) query = query.eq("ip_addr", options.ipAddr);
if (options?.successOnly !== undefined) query = query.eq("success", options.successOnly);
const { data, error } = await query;
if (error) return [];
return (data ?? []).map((row) => ({ ...row, reason: row.reason ?? undefined }));
} catch {
return [];
}
}
export async function sendSecurityAlert(
subject: string,
message: string
): Promise<void> {
console.warn(`⚠️ SECURITY ALERT: ${subject}\n${message}`);
}

View File

@ -64,6 +64,57 @@ async function sendWithFallback(
} }
} }
export interface RegistrierungData {
email: string;
firma?: string;
bestaetigungsLink: string;
}
export async function sendeRegistrierungsBestaetigung(
data: RegistrierungData
): Promise<void> {
const html = `
<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"></head>
<body style="font-family:system-ui,sans-serif;color:#e2e8f0;max-width:600px;margin:0 auto;padding:0">
<div style="background:#18212f;padding:20px 24px;border-bottom:2px solid #f97316">
<h1 style="color:#f97316;margin:0;font-size:20px;font-weight:700">MBO Tech IT</h1>
<p style="color:rgba(255,255,255,0.6);margin:4px 0 0;font-size:13px">Kunden-Portal</p>
</div>
<div style="padding:24px;background:#1e2a3b">
<h2 style="margin:0 0 16px;font-size:18px;color:#f8fafc">Bitte bestätigen Sie Ihre E-Mail-Adresse</h2>
<p style="color:#94a3b8;margin:0 0 24px">
${data.firma ? `Hallo ${data.firma},` : "Hallo,"}<br><br>
vielen Dank für Ihre Registrierung im MBO Tech IT Kunden-Portal.
Bitte bestätigen Sie Ihre E-Mail-Adresse, um Zugang zu Ihren IT-Anfragen zu erhalten.
</p>
<a href="${data.bestaetigungsLink}"
style="display:inline-block;background:#f97316;color:#fff;font-weight:700;padding:12px 24px;border-radius:8px;text-decoration:none;font-size:15px">
E-Mail bestätigen
</a>
<p style="color:#64748b;font-size:12px;margin:24px 0 0">
Dieser Link ist 24 Stunden gültig. Falls Sie sich nicht registriert haben, können Sie diese E-Mail ignorieren.
</p>
</div>
<div style="padding:12px 24px;background:#111925;border-top:1px solid rgba(255,255,255,0.1)">
<p style="margin:0;font-size:11px;color:#64748b">MBO Tech IT · ${process.env.APP_URL ?? "https://mbo-tech-it.de"}</p>
</div>
</body>
</html>`;
await sendWithFallback(
{
from: `"MBO Tech IT" <${process.env.SMTP_FROM}>`,
to: data.email,
subject: "Bitte bestätigen Sie Ihre E-Mail MBO Tech IT",
text: `Hallo,\n\nBitte bestätigen Sie Ihre E-Mail-Adresse:\n${data.bestaetigungsLink}\n\nDieser Link ist 24 Stunden gültig.\n\nMBO Tech IT`,
html,
},
`Registrierungsbestätigung ${data.email}`
);
}
export async function sendeKontaktEmail( export async function sendeKontaktEmail(
data: KontaktEmailData data: KontaktEmailData
): Promise<{ sent: boolean; queued: boolean }> { ): Promise<{ sent: boolean; queued: boolean }> {

71
lib/rate-limit.ts Normal file
View File

@ -0,0 +1,71 @@
interface RateLimitEntry {
count: number;
lastAttempt: number;
locked: boolean;
lockedUntil?: number;
}
const attempts = new Map<string, RateLimitEntry>();
const CLEANUP_INTERVAL = 10 * 60 * 1000;
const RESET_WINDOW = 15 * 60 * 1000;
const MAX_ATTEMPTS = 5;
const LOCK_THRESHOLD = 10;
const LOCK_DURATION = 15 * 60 * 1000;
setInterval(() => {
const now = Date.now();
for (const [key, entry] of attempts.entries()) {
if (now - entry.lastAttempt > RESET_WINDOW) {
attempts.delete(key);
}
}
}, CLEANUP_INTERVAL);
export function checkRateLimit(identifier: string) {
const now = Date.now();
let entry = attempts.get(identifier);
if (!entry) {
attempts.set(identifier, { count: 1, lastAttempt: now, locked: false });
return { allowed: true, delayMs: 0, locked: false };
}
if (now - entry.lastAttempt > RESET_WINDOW) {
attempts.set(identifier, { count: 1, lastAttempt: now, locked: false });
return { allowed: true, delayMs: 0, locked: false };
}
if (entry.locked && entry.lockedUntil) {
if (now < entry.lockedUntil) {
return { allowed: false, delayMs: entry.lockedUntil - now, locked: true };
} else {
entry.locked = false;
entry.count = 1;
entry.lastAttempt = now;
return { allowed: true, delayMs: 0, locked: false };
}
}
entry.count++;
entry.lastAttempt = now;
const delayMs = Math.min(Math.pow(2, entry.count - 2) * 1000, 30000);
if (entry.count >= LOCK_THRESHOLD) {
entry.locked = true;
entry.lockedUntil = now + LOCK_DURATION;
return { allowed: false, delayMs: LOCK_DURATION, locked: true };
}
const allowed = entry.count <= MAX_ATTEMPTS;
return { allowed, delayMs: allowed ? 0 : delayMs, locked: false };
}
export function resetRateLimit(identifier: string) {
attempts.delete(identifier);
}
export function getAttemptCount(identifier: string): number {
return attempts.get(identifier)?.count ?? 0;
}

View File

@ -1,11 +1,255 @@
// STUB wird ersetzt wenn Supabase eingerichtet ist (Modul: Supabase-Integration) import { createClient, SupabaseClient } from "@supabase/supabase-js";
// Aktuell wirft createServiceClient() einen Fehler email-queue.ts fängt diesen ab. import { createBrowserClient } from "@supabase/ssr";
// eslint-disable-next-line @typescript-eslint/no-explicit-any export type SupabaseServiceClient = SupabaseClient<Database>;
export type SupabaseServiceClient = any;
export interface Database {
public: {
Tables: {
email_queue: {
Row: {
id: string;
mail_from: string;
mail_to: string;
reply_to: string | null;
subject: string;
html: string;
body_text: string;
status: string;
retry_count: number;
next_retry_at: string;
error_last: string | null;
created_at: string;
};
Insert: {
id?: string;
mail_from: string;
mail_to: string;
reply_to?: string | null;
subject: string;
html: string;
body_text: string;
status?: string;
retry_count?: number;
next_retry_at?: string;
error_last?: string | null;
created_at?: string;
};
Update: {
status?: string;
retry_count?: number;
next_retry_at?: string;
error_last?: string | null;
};
Relationships: [];
};
admin_users: {
Row: {
id: string;
email: string;
name: string | null;
password_hash: string;
aktiv: boolean;
created_at: string;
};
Insert: {
id?: string;
email: string;
name?: string | null;
password_hash: string;
aktiv?: boolean;
created_at?: string;
};
Update: {
email?: string;
name?: string | null;
password_hash?: string;
aktiv?: boolean;
};
Relationships: [];
};
admin_audit_logs: {
Row: {
id: string;
email: string;
ip_addr: string;
user_agent: string;
success: boolean;
reason: string | null;
timestamp: string;
};
Insert: {
id?: string;
email: string;
ip_addr: string;
user_agent: string;
success: boolean;
reason?: string | null;
timestamp?: string;
};
Update: Record<string, never>;
Relationships: [];
};
admin_session_blacklist: {
Row: {
id: string;
admin_id: string;
token_signature: string;
revoked_at: string;
reason: string;
notes: string | null;
};
Insert: {
id?: string;
admin_id: string;
token_signature: string;
revoked_at?: string;
reason: string;
notes?: string | null;
};
Update: Record<string, never>;
Relationships: [];
};
action_token_blacklist: {
Row: {
id: string;
anfrage_id: string;
token_signature: string;
action_type: string;
used_at: string;
used_by_ip: string | null;
notes: string | null;
};
Insert: {
id?: string;
anfrage_id: string;
token_signature: string;
action_type: string;
used_at?: string;
used_by_ip?: string | null;
notes?: string | null;
};
Update: Record<string, never>;
Relationships: [];
};
page_views: {
Row: {
id: string;
path: string;
timestamp: string;
ip_anon: string | null;
device_type: string | null;
browser: string | null;
os: string | null;
referrer: string | null;
session_id: string;
duration_ms: number | null;
is_bot: boolean;
};
Insert: {
id?: string;
path: string;
timestamp?: string;
ip_anon?: string | null;
device_type?: string | null;
browser?: string | null;
os?: string | null;
referrer?: string | null;
session_id: string;
duration_ms?: number | null;
is_bot?: boolean;
};
Update: {
duration_ms?: number | null;
};
Relationships: [];
};
phone_clicks: {
Row: {
id: number;
phone_number: string;
source_page: string;
source_element: string;
session_id: string | null;
ip_anonymized: string | null;
device_type: string | null;
browser: string | null;
os: string | null;
timestamp: string;
};
Insert: {
phone_number: string;
source_page: string;
source_element: string;
session_id?: string | null;
ip_anonymized?: string | null;
device_type?: string | null;
browser?: string | null;
os?: string | null;
timestamp?: string;
};
Update: Record<string, never>;
Relationships: [];
};
anfragen: {
Row: {
id: string;
name: string;
email: string;
betreff: string;
nachricht: string | null;
status: string;
admin_notizen: string | null;
kunde_id: string | null;
created_at: string;
};
Insert: {
id?: string;
name: string;
email: string;
betreff: string;
nachricht?: string | null;
status?: string;
admin_notizen?: string | null;
kunde_id?: string | null;
created_at?: string;
};
Update: {
status?: string;
admin_notizen?: string | null;
};
Relationships: [];
};
};
Views: Record<string, never>;
Functions: Record<string, never>;
Enums: Record<string, never>;
CompositeTypes: Record<string, never>;
};
}
let serviceClient: SupabaseServiceClient | null = null;
export function createServiceClient(): SupabaseServiceClient { export function createServiceClient(): SupabaseServiceClient {
throw new Error( if (serviceClient) return serviceClient;
"Supabase noch nicht konfiguriert. lib/supabase.ts implementieren und @supabase/supabase-js installieren."
const url = process.env.SUPABASE_URL;
const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!url || !key) {
throw new Error("SUPABASE_URL oder SUPABASE_SERVICE_ROLE_KEY fehlt in .env.local");
}
serviceClient = createClient<Database>(url, key, {
auth: { persistSession: false },
});
return serviceClient;
}
export function createBrowserSupabaseClient() {
return createBrowserClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
); );
} }

88
lib/token-blacklist.ts Normal file
View File

@ -0,0 +1,88 @@
import { createServiceClient } from "./supabase";
export async function revokeSessionToken(
tokenSignature: string,
adminId: string,
reason: "logout" | "password_changed" | "suspicious_activity" = "logout",
notes?: string
): Promise<boolean> {
try {
const db = createServiceClient();
const { error } = await db.from("admin_session_blacklist").insert({
admin_id: adminId,
token_signature: tokenSignature,
reason,
notes,
});
if (error) {
console.error("Failed to revoke session token:", error);
return false;
}
return true;
} catch (error) {
console.error("Error revoking session token:", error);
return false;
}
}
export async function isSessionTokenRevoked(tokenSignature: string): Promise<boolean> {
try {
const db = createServiceClient();
const { data, error } = await db
.from("admin_session_blacklist")
.select("id")
.eq("token_signature", tokenSignature)
.single();
if (error && error.code !== "PGRST116") {
console.error("Error checking token revocation:", error);
return false;
}
return data?.id != null;
} catch {
return false;
}
}
export async function markActionTokenUsed(
tokenSignature: string,
anfrageId: string,
actionType: string,
ipAddr?: string
): Promise<boolean> {
try {
const db = createServiceClient();
const { error } = await db.from("action_token_blacklist").insert({
token_signature: tokenSignature,
anfrage_id: anfrageId,
action_type: actionType,
used_by_ip: ipAddr,
});
if (error) {
console.error("Failed to mark action token as used:", error);
return false;
}
return true;
} catch {
return false;
}
}
export async function isActionTokenUsed(tokenSignature: string): Promise<boolean> {
try {
const db = createServiceClient();
const { data, error } = await db
.from("action_token_blacklist")
.select("id")
.eq("token_signature", tokenSignature)
.single();
if (error && error.code !== "PGRST116") {
console.error("Error checking action token usage:", error);
return false;
}
return data?.id != null;
} catch {
return false;
}
}

32
middleware.ts Normal file
View File

@ -0,0 +1,32 @@
import { createServerClient } from "@supabase/ssr";
import { type NextRequest, NextResponse } from "next/server";
export async function middleware(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value));
supabaseResponse = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
);
},
},
}
);
await supabase.auth.getUser();
return supabaseResponse;
}
export const config = {
matcher: ["/kunden/:path*", "/auth/:path*"],
};

View File

@ -0,0 +1,188 @@
# Modul: Email-System
> SMTP-Versand mit Nodemailer (STARTTLS Port 587), Queue-Fallback in Supabase bei Versandfehlern, exponentielles Retry (max 10 Versuche). Unterstützt HTML + Plaintext, HMAC-signierte Action-Buttons in Admin-Mails (7 Tage gültig, One-Time-Use).
---
## Enthaltene Dateien
| Ziel im neuen Projekt | Quelle |
|---|---|
| `lib/mailer.ts` | Haupt-Mailer (Nodemailer + alle Email-Funktionen) |
| `lib/email-queue.ts` | Queue-System (Supabase-Fallback + Worker) |
| `app/api/admin/email-queue/route.ts` | Admin-API: Queue-Status anzeigen |
| `app/api/admin/smtp-test/route.ts` | Admin-API: SMTP-Verbindung testen |
**Hinweis:** `[id]`-Ordner in `files/` entsprechen Next.js Dynamic-Route-Ordnern `[id]`.
---
## Voraussetzungen
```bash
npm install nodemailer
npm install -D @types/nodemailer
```
Benötigt außerdem:
- `lib/supabase.ts` (Service Client für Queue-Speicherung)
- `lib/admin-auth.ts` (für `createActionToken` in Admin-Mails, optional)
---
## Umgebungsvariablen (`.env.local`)
```env
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=user@example.com
SMTP_PASS=password
SMTP_FROM=noreply@example.com # Absender
SMTP_TO=admin@example.com # Admin-Inbox
COMPANY_PHONE=+49123456789 # Wird in Mails angezeigt
APP_URL=https://example.com # Basis-URL für Links in Mails
NEXTAUTH_SECRET=your-secret # HMAC-Key für Action-Tokens
```
---
## Datenbank-Migration (Supabase)
Tabelle `email_queue` muss in `lib/supabase.ts` als Type definiert und in Supabase existieren:
```sql
CREATE TABLE email_queue (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
to_addr text NOT NULL,
subject text NOT NULL,
html text NOT NULL,
text text,
attempts int DEFAULT 0,
max_attempts int DEFAULT 10,
status text DEFAULT 'pending' CHECK (status IN ('pending','sent','failed')),
last_error text,
next_retry_at timestamptz DEFAULT now(),
created_at timestamptz DEFAULT now(),
sent_at timestamptz
);
CREATE INDEX idx_email_queue_pending ON email_queue(status, next_retry_at) WHERE status = 'pending';
```
---
## Einbindung Schritt für Schritt
### 1. Dateien kopieren
Alle Dateien aus `files/` in die entsprechenden Pfade des neuen Projekts kopieren.
### 2. Mailer an Projekt anpassen (`lib/mailer.ts`)
Die Datei enthält projektspezifische Email-Funktionen (Anfragen, Kunden-Bestätigungen etc.). Folgende Funktionen beibehalten oder anpassen:
- `sendeKontaktEmail(data)` → bleibt meist unverändert
- Alle anderen Funktionen nach Bedarf umbenennen/entfernen
- Firmenname/Branding in HTML-Templates ersetzen (Suche nach `Mietpark Hahn`)
### 3. Queue-Worker starten (`instrumentation.ts`)
```ts
// instrumentation.ts (Projekt-Root)
import { startEmailQueueWorker } from "@/lib/email-queue";
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
startEmailQueueWorker();
}
}
```
### 4. Email-Queue Typ in `lib/supabase.ts` ergänzen
Im `Database`-Typ die `email_queue` Tabelle mit den Feldern aus der Migration ergänzen.
### 5. Action-Buttons (optional)
Wenn Admin-Mails HMAC-signierte Buttons enthalten sollen (z.B. "Bestätigen / Ablehnen"):
- Modul `02-admin-auth` muss eingebunden sein (liefert `createActionToken`)
- Buttons verweisen auf `GET /api/admin/anfragen-action?token=...`
### 6. SMTP testen
```bash
curl -X POST http://localhost:3000/api/admin/smtp-test \
-H "Content-Type: application/json" \
-d '{"to":"test@example.com"}'
```
---
## Anpassungspunkte
| Was | Wo |
|---|---|
| HTML-Templates (Firmenname, Farben) | `lib/mailer.ts` alle `html`-Variablen |
| Email-Betreffs | `lib/mailer.ts` alle `subject`-Zeilen |
| Retry-Intervalle | `lib/email-queue.ts` `calcNextRetry()` |
| Max. Versuche | `lib/email-queue.ts` `maxAttempts: 10` |
| Queue-Worker-Intervall | `lib/email-queue.ts` `setInterval(60_000)` |
| SMTP Port (465 statt 587) | `lib/mailer.ts` `secure: true, port: 465` |
---
## Integrations-Prompt
Kopiere diesen Prompt in eine neue KI-Konversation, nachdem du die `files/` in dein Projekt kopiert hast. Ersetze alle `[PLATZHALTER]`.
```
Ich integriere das Email-System-Modul (Nodemailer + Supabase Queue) in mein bestehendes Next.js/Supabase-Projekt.
PROJEKT-KONTEXT:
- Projektname: [PROJEKTNAME]
- Admin-Email (Empfänger): [ADMIN_EMAIL]
- SMTP-Absender: [SMTP_FROM_EMAIL]
- App-URL (für Links in Mails): [https://beispiel.de]
BEREITS KOPIERTE DATEIEN (aus modules/01-email-system/files/):
- lib/mailer.ts
- lib/email-queue.ts
- app/api/admin/email-queue/route.ts
- app/api/admin/smtp-test/route.ts
AUFGABEN führe sie der Reihe nach aus:
1. SUPABASE-MIGRATION: Führe folgendes SQL im Supabase SQL-Editor aus:
CREATE TABLE email_queue (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
to_addr text NOT NULL, subject text NOT NULL,
html text NOT NULL, text text,
attempts int DEFAULT 0, max_attempts int DEFAULT 10,
status text DEFAULT 'pending' CHECK (status IN ('pending','sent','failed')),
last_error text, next_retry_at timestamptz DEFAULT now(),
created_at timestamptz DEFAULT now(), sent_at timestamptz
);
CREATE INDEX idx_email_queue_pending ON email_queue(status, next_retry_at) WHERE status = 'pending';
2. SUPABASE-TYPEN: Lies lib/supabase.ts und ergänze in Database.public.Tables:
email_queue: { Row: { id: string; to_addr: string; subject: string; html: string; text: string | null; attempts: number; max_attempts: number; status: string; last_error: string | null; next_retry_at: string; created_at: string; sent_at: string | null } }
3. BRANDING: Ersetze in lib/mailer.ts alle Vorkommen von "Mietpark Hahn" durch "[PROJEKTNAME]".
Passe außerdem die Farben im HTML-Template an (aktuell #f7d334 für Buttons).
4. QUEUE-WORKER: Lies instrumentation.ts (oder erstelle sie im Projekt-Root falls nicht vorhanden).
Ergänze/ersetze den Inhalt um:
import { startEmailQueueWorker } from "@/lib/email-queue";
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") startEmailQueueWorker();
}
5. ENV-VARIABLEN: Ergänze .env.local um:
SMTP_HOST=[SMTP_HOST]
SMTP_PORT=587
SMTP_USER=[SMTP_USER]
SMTP_PASS=[SMTP_PASS]
SMTP_FROM=[SMTP_FROM_EMAIL]
SMTP_TO=[ADMIN_EMAIL]
COMPANY_PHONE=[TELEFON]
APP_URL=[https://beispiel.de]
NEXTAUTH_SECRET=[MIN_32_ZEICHEN_ZUFAELLIGER_STRING]
6. TEST: Starte den Dev-Server und sende eine Test-Email:
curl -X POST http://localhost:3000/api/admin/smtp-test \
-H "Content-Type: application/json" \
-d '{"to":"[ADMIN_EMAIL]"}'
Lies jede Datei vor dem Bearbeiten. Melde wenn alle Schritte abgeschlossen sind.
```

View File

@ -0,0 +1,59 @@
import { NextResponse } from "next/server";
import { createServiceClient } from "@/lib/supabase";
import { requireAdmin } from "@/lib/admin-auth";
// Kein statischer Build braucht Env-Vars zur Laufzeit
export const dynamic = "force-dynamic";
// GET: Status der Queue abrufen
export async function GET() {
const check = await requireAdmin();
if (check instanceof NextResponse) return check;
const supabase = createServiceClient();
const { data, error } = await supabase
.from("email_queue")
.select("id, created_at, next_retry_at, retry_count, max_retries, status, error_last, mail_to, subject")
.order("created_at", { ascending: false })
.limit(50);
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json(data);
}
// POST: Alle pending Mails sofort erneut versuchen (manueller Trigger)
export async function POST() {
const check = await requireAdmin();
if (check instanceof NextResponse) return check;
const supabase = createServiceClient();
const { error, count } = await supabase
.from("email_queue")
.update({ next_retry_at: new Date().toISOString() })
.eq("status", "pending");
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
// Worker-Lauf anstoßen (falls noch nicht gestartet)
try {
const { startEmailQueueWorker } = await import("../../../../lib/email-queue");
startEmailQueueWorker();
} catch {}
return NextResponse.json({ ok: true, updated: count ?? 0 });
}
// DELETE: Fehlgeschlagene Mails aus Queue entfernen
export async function DELETE() {
const check = await requireAdmin();
if (check instanceof NextResponse) return check;
const supabase = createServiceClient();
const { error, count } = await supabase
.from("email_queue")
.delete()
.eq("status", "failed");
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ ok: true, deleted: count ?? 0 });
}

View File

@ -0,0 +1,65 @@
import { NextResponse } from "next/server";
import nodemailer from "nodemailer";
import { requireAdmin } from "@/lib/admin-auth";
export const dynamic = "force-dynamic";
// Schneller Diagnose-Endpunkt nur im Admin nutzbar, nicht öffentlich verlinkt
// Aufruf: GET /api/admin/smtp-test
export async function GET() {
const check = await requireAdmin();
if (check instanceof NextResponse) return check;
const config = {
host: process.env.SMTP_HOST ?? "(nicht gesetzt)",
port: Number(process.env.SMTP_PORT ?? 587),
user: process.env.SMTP_USER ?? "(nicht gesetzt)",
from: process.env.SMTP_FROM ?? "(nicht gesetzt)",
to: process.env.SMTP_TO ?? "(nicht gesetzt)",
};
const transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: false,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
connectionTimeout: 10000,
greetingTimeout: 8000,
socketTimeout: 12000,
tls: { rejectUnauthorized: false },
});
try {
await transporter.verify();
// Test-Mail senden
await transporter.sendMail({
from: `"Mietpark Hahn TEST" <${process.env.SMTP_FROM}>`,
to: process.env.SMTP_TO,
subject: `✓ SMTP-Test erfolgreich ${new Date().toLocaleString("de-DE")}`,
text: "Diese Test-Mail wurde automatisch von /api/admin/smtp-test gesendet.\n\nSMTP-Verbindung und Mail-Versand funktionieren korrekt.",
html: `<p style="font-family:sans-serif">Diese Test-Mail wurde automatisch von <code>/api/admin/smtp-test</code> gesendet.</p><p style="color:green;font-weight:bold">✓ SMTP-Verbindung und Mail-Versand funktionieren korrekt.</p>`,
});
return NextResponse.json({
ok: true,
message: "SMTP-Verbindung erfolgreich Test-Mail gesendet",
config: { ...config, pass: "***" },
});
} catch (err) {
const e = err as Error & { code?: string; command?: string };
return NextResponse.json(
{
ok: false,
error: e.message,
code: e.code,
command: e.command,
config: { ...config, pass: "***" },
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,176 @@
/**
* Email-Queue: Speichert fehlgeschlagene E-Mails in der Datenbank
* und versucht sie regelmäßig erneut zu senden.
*
* WICHTIG: Kein process.env.NEXT_PUBLIC_* verwenden diese Werte
* werden beim Build eingebettet und enthalten die öffentliche URL
* (supabase.demo.mbo-tech-it.de), die vom Docker-Container aus nicht
* erreichbar ist. Stattdessen createServiceClient() nutzen, der intern
* SUPABASE_INTERNAL_URL verwendet (Runtime-Variable, nie eingebettet).
*
* Retry-Strategie: exponentielles Backoff
* Versuch 1 sofort
* Versuch 2 1 Min
* Versuch 3 2 Min
* Versuch 4 4 Min
* ...bis max. 60 Min zwischen Versuchen, dann Status "failed"
*/
import nodemailer from "nodemailer";
import { createServiceClient } from "./supabase";
export interface QueuedMail {
mail_from: string;
mail_to: string;
reply_to?: string;
subject: string;
html: string;
body_text: string;
}
// ─── Datenbank-Operationen via Supabase-Client (interne URL) ──────────────
async function dbInsert(mail: QueuedMail) {
const db = createServiceClient();
const { error } = await db.from("email_queue").insert({
...mail,
status: "pending",
retry_count: 0,
next_retry_at: new Date().toISOString(),
});
if (error) throw new Error(`Queue-Insert fehlgeschlagen: ${error.message}`);
}
async function dbFetchPending(): Promise<
Array<QueuedMail & { id: string; retry_count: number }>
> {
const db = createServiceClient();
const now = new Date().toISOString();
const { data, error } = await db
.from("email_queue")
.select("*")
.eq("status", "pending")
.lte("next_retry_at", now)
.order("next_retry_at", { ascending: true })
.limit(20);
if (error) {
console.error("[EmailQueue] Fetch pending fehlgeschlagen:", error.message);
return [];
}
return (data ?? []) as Array<QueuedMail & { id: string; retry_count: number }>;
}
async function dbMarkSent(id: string) {
const db = createServiceClient();
await db
.from("email_queue")
.update({ status: "sent", error_last: null })
.eq("id", id);
}
async function dbMarkRetry(id: string, retryCount: number, error: string) {
const db = createServiceClient();
const nextCount = retryCount + 1;
const maxRetries = 10;
if (nextCount >= maxRetries) {
await db
.from("email_queue")
.update({ status: "failed", retry_count: nextCount, error_last: error.slice(0, 500) })
.eq("id", id);
console.error(
`[EmailQueue] Mail ${id} endgültig fehlgeschlagen nach ${nextCount} Versuchen: ${error}`
);
return;
}
// Exponentielles Backoff: 1, 2, 4, 8, 16, 32, 60, 60, 60... Minuten
const minutesDelay = Math.min(Math.pow(2, nextCount - 1), 60);
const nextRetry = new Date(Date.now() + minutesDelay * 60 * 1000).toISOString();
await db
.from("email_queue")
.update({ retry_count: nextCount, next_retry_at: nextRetry, error_last: error.slice(0, 500) })
.eq("id", id);
console.warn(
`[EmailQueue] Versuch ${nextCount}/${maxRetries} fehlgeschlagen nächster Retry in ${minutesDelay} Min. Fehler: ${error}`
);
}
// ─── Queue-Eintrag speichern ───────────────────────────────────────────────
export async function queueEmail(mail: QueuedMail): Promise<void> {
try {
await dbInsert(mail);
console.log(`[EmailQueue] "${mail.subject}" → "${mail.mail_to}" in Queue gespeichert`);
// Sofort-Retry: nicht auf das 60s-Intervall warten
processQueue().catch(() => {});
} catch (err) {
console.error("[EmailQueue] Konnte Mail NICHT in Queue speichern:", err);
}
}
// ─── Worker: Pending Mails senden ─────────────────────────────────────────
let transporter: nodemailer.Transporter | null = null;
function getTransporter() {
if (!transporter) {
transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT ?? 587),
secure: false,
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
connectionTimeout: 15000,
greetingTimeout: 10000,
socketTimeout: 20000,
tls: { rejectUnauthorized: false, ciphers: "SSLv3" },
});
}
return transporter;
}
async function processQueue(): Promise<void> {
const pending = await dbFetchPending();
if (pending.length === 0) return;
console.log(`[EmailQueue] Verarbeite ${pending.length} ausstehende Mail(s)...`);
for (const mail of pending) {
try {
await getTransporter().sendMail({
from: mail.mail_from,
to: mail.mail_to,
replyTo: mail.reply_to,
subject: mail.subject,
html: mail.html,
text: mail.body_text,
});
await dbMarkSent(mail.id);
console.log(`[EmailQueue] ✓ "${mail.subject}" → "${mail.mail_to}" gesendet`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
await dbMarkRetry(mail.id, mail.retry_count, msg);
}
}
}
// ─── Worker starten (Singleton) ───────────────────────────────────────────
let workerStarted = false;
export function startEmailQueueWorker(): void {
if (workerStarted) return;
workerStarted = true;
console.log("[EmailQueue] Worker gestartet prüft alle 60 Sekunden");
// Sofort beim Start: Mails aus vorherigen Abstürzen verarbeiten
processQueue().catch((e) => console.error("[EmailQueue] Initialer Lauf fehlgeschlagen:", e));
setInterval(() => {
processQueue().catch((e) => console.error("[EmailQueue] Lauf fehlgeschlagen:", e));
}, 60_000);
}

View File

@ -0,0 +1,932 @@
import nodemailer from "nodemailer";
import { queueEmail } from "./email-queue";
// Port 587 = STARTTLS (bestätigt erreichbar vom Server + Docker-Container)
// Port 465 = SSL/TLS (auf diesem Server geblockt nicht verwenden)
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT ?? 587),
secure: false, // STARTTLS auf Port 587
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
connectionTimeout: 15000, // 15 s (war default 2 min schlägt jetzt schneller fehl)
greetingTimeout: 10000,
socketTimeout: 20000,
tls: {
rejectUnauthorized: false,
ciphers: "SSLv3", // Kompatibilitätsmodus für ältere SMTP-Server
},
});
export interface AnfrageEmailData {
anfrageId: string;
firma: string;
telefon: string;
email: string;
positionen: {
maschineName: string;
mietbeginn: string;
mietende: string;
gesamtTage: number;
lieferung: boolean;
lieferadresse: string;
anmerkung: string;
tagessatz: number | null;
preisStufe?: "tag" | "woche" | "monat" | null;
zubehoer?: {
id: string;
name: string;
preisTag: number | null;
preisWoche?: number | null;
preisMonat?: number | null;
}[];
}[];
}
// ─── Preisberechnung ──────────────────────────────────────────────────────
const VERSICHERUNG_PROZENT = 7.5;
const MWST_PROZENT = 19;
function fmt(n: number) {
return n.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function formatDatum(iso: string) {
return new Date(iso).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
/** Alle Sonntage zwischen von und bis (inkl.) als formatierte Strings */
function getSonntage(von: string, bis: string): string[] {
const result: string[] = [];
const end = new Date(bis);
for (let d = new Date(von); d <= end; d.setDate(d.getDate() + 1)) {
if (d.getDay() === 0) {
result.push(
new Date(d).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit" })
);
}
}
return result;
}
/** Kalendertage (inkl. Sonntage) zwischen von und bis */
export function getKalenderTage(von: string, bis: string): number {
const start = new Date(von);
const end = new Date(bis);
return Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1;
}
function zubehoerTagessatz(
z: { preisTag: number | null; preisWoche?: number | null; preisMonat?: number | null },
stufe: string | null | undefined
): number | null {
if (stufe === "monat" && z.preisMonat != null) return z.preisMonat;
if (stufe === "woche" && z.preisWoche != null) return z.preisWoche;
return z.preisTag;
}
function positionNetto(p: AnfrageEmailData["positionen"][number]): number {
const maschine = p.tagessatz != null ? p.tagessatz * p.gesamtTage : 0;
const zubehoer = (p.zubehoer ?? []).reduce((sum, z) => {
const rate = zubehoerTagessatz(z, p.preisStufe);
return sum + (rate != null ? rate * p.gesamtTage : 0);
}, 0);
return maschine + zubehoer;
}
function buildPreisBlock(positionen: AnfrageEmailData["positionen"]) {
const gesamtNetto = positionen.reduce((s, p) => s + positionNetto(p), 0);
const versicherungBetrag = gesamtNetto * (VERSICHERUNG_PROZENT / 100);
const mwstBetrag = (gesamtNetto + versicherungBetrag) * (MWST_PROZENT / 100);
const gesamtBrutto = gesamtNetto + versicherungBetrag + mwstBetrag;
const posRows = positionen.map((p, i) => {
const hatPreis = p.tagessatz != null;
const netto = hatPreis ? positionNetto(p) : null;
const zubehoerMitPreis = (p.zubehoer ?? []).filter(
(z) => zubehoerTagessatz(z, p.preisStufe) != null
);
// Sonntage berechnen
const kalenderTage = getKalenderTage(p.mietbeginn, p.mietende);
const sonntage = getSonntage(p.mietbeginn, p.mietende);
const hatSonntage = sonntage.length > 0 && kalenderTage !== p.gesamtTage;
const zubehoerRows = zubehoerMitPreis
.map((z) => {
const rate = zubehoerTagessatz(z, p.preisStufe)!;
return `<tr>
<td style="padding:2px 12px 2px 28px;color:#64748b;font-size:12px" colspan="2"> ${z.name}</td>
<td style="padding:2px 12px;font-family:monospace;font-size:12px;color:#475569;text-align:right">${fmt(rate * p.gesamtTage)} </td>
</tr>`;
})
.join("");
const sonntageHtml = hatSonntage
? `<span style="font-size:11px;color:#94a3b8;display:block">
${kalenderTage} Kalendertage &ndash; ${sonntage.length} Sonntag${sonntage.length > 1 ? "e" : ""} nicht berechnet
(${sonntage.join(", ")})
</span>`
: "";
const details = [
p.tagessatz ? `${p.tagessatz} €/Tag · ${p.gesamtTage} berechnete Tag${p.gesamtTage !== 1 ? "e" : ""}` : "",
p.lieferung ? `Lieferung: ${p.lieferadresse}` : "",
p.anmerkung ? `Anmerkung: ${p.anmerkung}` : "",
]
.filter(Boolean)
.map((d) => `<span style="font-size:11px;color:#94a3b8;display:block">${d}</span>`)
.join("");
return `<tr style="background:${i % 2 === 0 ? "#f8fafc" : "#fff"}">
<td style="padding:8px 12px;font-weight:600;color:#1c1917;border-bottom:1px solid #e2e8f0;font-size:13px">
${i + 1}. ${p.maschineName}
</td>
<td style="padding:8px 12px;color:#475569;font-size:13px;border-bottom:1px solid #e2e8f0">
${formatDatum(p.mietbeginn)} ${formatDatum(p.mietende)}
${details}
${sonntageHtml}
</td>
<td style="padding:8px 12px;font-family:monospace;font-weight:600;color:#1c1917;border-bottom:1px solid #e2e8f0;text-align:right;white-space:nowrap;font-size:13px">
${netto != null ? `${fmt(netto)}` : "Auf Anfrage"}
</td>
</tr>${zubehoerRows}`;
}).join("");
const html = `
<table style="width:100%;border-collapse:collapse">
<thead>
<tr style="background:#1c1917">
<th style="padding:8px 12px;text-align:left;color:#f7d334;font-weight:600;font-size:13px">Maschine / Gerät</th>
<th style="padding:8px 12px;text-align:left;color:rgba(255,255,255,0.75);font-weight:500;font-size:12px">Zeitraum &amp; Details</th>
<th style="padding:8px 12px;text-align:right;color:rgba(255,255,255,0.75);font-weight:500;font-size:12px">Mietpreis</th>
</tr>
</thead>
<tbody>${posRows}</tbody>
</table>
<table style="width:100%;border-collapse:collapse;margin-top:2px">
<tr>
<td style="padding:6px 12px;color:#64748b;font-size:13px">Zwischensumme (netto)</td>
<td style="padding:6px 12px;font-family:monospace;text-align:right;font-size:13px">${fmt(gesamtNetto)} </td>
</tr>
<tr>
<td style="padding:6px 12px;color:#64748b;font-size:13px">+ Versicherung (${VERSICHERUNG_PROZENT} %)</td>
<td style="padding:6px 12px;font-family:monospace;text-align:right;font-size:13px">${fmt(versicherungBetrag)} </td>
</tr>
<tr style="border-top:1px solid #e2e8f0">
<td style="padding:6px 12px;color:#475569;font-size:13px">Summe netto inkl. Versicherung</td>
<td style="padding:6px 12px;font-family:monospace;text-align:right;font-size:13px">${fmt(gesamtNetto + versicherungBetrag)} </td>
</tr>
<tr>
<td style="padding:6px 12px;color:#64748b;font-size:13px">+ MwSt. (${MWST_PROZENT} %)</td>
<td style="padding:6px 12px;font-family:monospace;text-align:right;font-size:13px">${fmt(mwstBetrag)} </td>
</tr>
<tr style="border-top:2px solid #f7d334">
<td style="padding:10px 12px;font-weight:700;font-size:15px;color:#1c1917">Gesamtbetrag (brutto)</td>
<td style="padding:10px 12px;font-family:monospace;font-weight:700;font-size:15px;color:#1c1917;text-align:right">${fmt(gesamtBrutto)} </td>
</tr>
</table>`;
const text =
positionen
.map((p, i) => {
const netto = positionNetto(p);
const kalenderTage = getKalenderTage(p.mietbeginn, p.mietende);
const sonntage = getSonntage(p.mietbeginn, p.mietende);
const hatSonntage = sonntage.length > 0 && kalenderTage !== p.gesamtTage;
const lines = [
`${i + 1}. ${p.maschineName}`,
` ${formatDatum(p.mietbeginn)} ${formatDatum(p.mietende)}`,
hatSonntage
? ` ${kalenderTage} Kalendertage ${sonntage.length} Sonntag${sonntage.length > 1 ? "e" : ""} nicht berechnet (${sonntage.join(", ")})`
: null,
p.tagessatz
? ` ${p.tagessatz} €/Tag × ${p.gesamtTage} Tage = ${fmt(p.tagessatz * p.gesamtTage)} € Maschinenmiete`
: " Preis auf Anfrage",
...(p.zubehoer ?? [])
.filter((z) => zubehoerTagessatz(z, p.preisStufe) != null)
.map((z) => {
const rate = zubehoerTagessatz(z, p.preisStufe)!;
return ` + ${z.name}: ${fmt(rate * p.gesamtTage)}`;
}),
p.lieferung ? ` Lieferung: ${p.lieferadresse}` : null,
p.anmerkung ? ` Anmerkung: ${p.anmerkung}` : null,
` Positionssumme: ${fmt(netto)}`,
].filter((l): l is string => l != null && l !== "");
return lines.join("\n");
})
.join("\n\n") +
`\n\n${"─".repeat(40)}\nZwischensumme (netto): ${fmt(gesamtNetto)}\n+ Versicherung ${VERSICHERUNG_PROZENT} %: ${fmt(versicherungBetrag)}\n+ MwSt. ${MWST_PROZENT} %: ${fmt(mwstBetrag)}\n${"─".repeat(40)}\nGesamtbetrag (brutto): ${fmt(gesamtBrutto)}`;
return { html, text, gesamtNetto, versicherungBetrag, mwstBetrag, gesamtBrutto };
}
// ─── Robuster Send mit Queue-Fallback ────────────────────────────────────
interface MailOptions {
from: string;
to: string | undefined;
replyTo?: string;
subject: string;
text: string;
html: string;
}
async function sendWithFallback(options: MailOptions, label: string) {
if (!options.to) {
console.error(`[Mailer] Kein Empfänger für "${label}" Mail übersprungen`);
return;
}
try {
await transporter.sendMail(options);
console.log(`[Mailer] ✓ Mail "${label}" an "${options.to}" gesendet`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[Mailer] ✗ Mail "${label}" fehlgeschlagen (${msg}) in Queue gespeichert`);
await queueEmail({
mail_from: options.from,
mail_to: options.to,
reply_to: options.replyTo,
subject: options.subject,
html: options.html,
body_text: options.text,
});
}
}
// ─── Kontaktformular ──────────────────────────────────────────────────────
export interface KontaktEmailData {
name: string;
anrede?: string;
telefon: string;
email: string;
betreff: string;
nachricht?: string;
}
export async function sendeKontaktEmail(data: KontaktEmailData) {
const anrede = data.anrede
? `${data.anrede.charAt(0).toUpperCase() + data.anrede.slice(1)} `
: "";
const html = `
<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"></head>
<body style="font-family:system-ui,sans-serif;color:#1c1917;max-width:600px;margin:0 auto;padding:0">
<div style="background:#1c1917;padding:20px 24px">
<h1 style="color:#f7d334;margin:0;font-size:20px;font-weight:700">Mietpark Hahn</h1>
<p style="color:rgba(255,255,255,0.7);margin:4px 0 0;font-size:13px">Neue Kontaktanfrage</p>
</div>
<div style="padding:24px;background:#fff">
<h2 style="margin:0 0 16px;font-size:18px">Kontaktanfrage von ${anrede}${data.name}</h2>
<table style="width:100%;border-collapse:collapse;margin-bottom:24px">
<tr><td style="padding:6px 0;color:#64748b;width:120px">Name</td><td style="padding:6px 0;font-weight:600">${anrede}${data.name}</td></tr>
<tr><td style="padding:6px 0;color:#64748b">Telefon</td><td style="padding:6px 0"><a href="tel:${data.telefon}" style="color:#1c1917">${data.telefon}</a></td></tr>
<tr><td style="padding:6px 0;color:#64748b">E-Mail</td><td style="padding:6px 0"><a href="mailto:${data.email}" style="color:#1c1917">${data.email}</a></td></tr>
<tr><td style="padding:6px 0;color:#64748b">Betreff</td><td style="padding:6px 0">${data.betreff}</td></tr>
</table>
${data.nachricht ? `<div style="padding:12px 16px;background:#f8fafc;border-left:3px solid #f7d334"><p style="margin:0;font-size:14px;white-space:pre-wrap">${data.nachricht}</p></div>` : ""}
</div>
</body>
</html>`;
await sendWithFallback({
from: `"Mietpark Hahn" <${process.env.SMTP_FROM}>`,
to: process.env.SMTP_TO,
replyTo: data.email,
subject: `Kontaktanfrage: ${anrede}${data.name} ${data.betreff} Mietpark Hahn`,
text: `Neue Kontaktanfrage\n\nName: ${anrede}${data.name}\nTelefon: ${data.telefon}\nE-Mail: ${data.email}\nBetreff: ${data.betreff}${data.nachricht ? `\n\nNachricht:\n${data.nachricht}` : ""}`,
html,
}, `Kontaktanfrage ${data.name}`);
}
// ─── Kunden-Eingangsbestätigung ───────────────────────────────────────────
export async function sendeKundenEingangsbestaetigung(data: AnfrageEmailData) {
const { html: preisHtml, text: preisText } = buildPreisBlock(data.positionen);
// Build a simple equipment list
const equipmentHtml = `
<div style="margin-bottom:24px">
<h3 style="margin:0 0 12px;font-size:13px;text-transform:uppercase;letter-spacing:0.05em;color:#64748b">
Ihre gemieteten Geräte
</h3>
<ul style="margin:0;padding-left:20px;list-style:none">
${data.positionen
.map((p) => `
<li style="margin:0 0 6px;padding:0;font-size:14px;color:#1c1917">
<span style="display:inline-block;width:6px;height:6px;background:#f7d334;border-radius:50%;margin-right:8px;vertical-align:middle"></span>
<strong>${p.maschineName}</strong>
<span style="color:#64748b"> · ${formatDatum(p.mietbeginn)} bis ${formatDatum(p.mietende)}</span>
</li>`)
.join("")}
</ul>
</div>
`;
const html = `
<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"></head>
<body style="font-family:system-ui,sans-serif;color:#1c1917;max-width:600px;margin:0 auto;padding:0">
<div style="background:#1c1917;padding:20px 24px">
<h1 style="color:#f7d334;margin:0;font-size:20px;font-weight:700">Mietpark Hahn</h1>
<p style="color:rgba(255,255,255,0.7);margin:4px 0 0;font-size:13px">Ihre Mietanfrage ist eingegangen</p>
</div>
<div style="padding:24px;background:#fff">
<h2 style="margin:0 0 8px;font-size:18px">Vielen Dank für Ihre Anfrage!</h2>
<p style="color:#475569;margin:0 0 24px;font-size:14px">
Guten Tag ${data.firma},<br>
wir haben Ihre Mietanfrage erhalten und werden uns schnellstmöglich bei Ihnen melden.
</p>
${equipmentHtml}
<h3 style="margin:0 0 12px;font-size:15px;border-top:2px solid #f7d334;padding-top:16px">
Detaillierte Preisübersicht
</h3>
${preisHtml}
<div style="margin-top:20px;padding:14px 16px;background:#fef3c7;border-left:3px solid #f7d334">
<p style="margin:0;font-size:13px;color:#92400e">
<strong>Hinweis:</strong> Die angezeigten Preise sind Mietpreise inkl. ${VERSICHERUNG_PROZENT} % Versicherung und ${MWST_PROZENT} % MwSt.
Die Bestätigung erfolgt nach Prüfung durch den Verleih.
</p>
</div>
<p style="margin-top:20px;font-size:13px;color:#64748b">
Bei Fragen erreichen Sie uns unter
<a href="tel:${process.env.COMPANY_PHONE ?? ""}" style="color:#1c1917">${process.env.COMPANY_PHONE ?? ""}</a>
oder per E-Mail an
<a href="mailto:${process.env.SMTP_FROM ?? ""}" style="color:#1c1917">${process.env.SMTP_FROM ?? ""}</a>
</p>
</div>
<div style="padding:12px 24px;background:#f8fafc;border-top:1px solid #e2e8f0">
<p style="margin:0 0 8px;font-size:11px;color:#94a3b8">
Mit der Nutzung unserer Dienste akzeptieren Sie unsere
<a href="${process.env.APP_URL ?? "https://www.mietparkhahn.de"}/agb" style="color:#64748b">Allgemeinen Geschäftsbedingungen</a>.
</p>
<p style="margin:0;font-size:11px;color:#94a3b8">
Mietpark Hahn · Anfrage-ID: ${data.anfrageId}
</p>
</div>
</body>
</html>`;
const equipmentList = data.positionen
.map((p) => `${p.maschineName} (${formatDatum(p.mietbeginn)} bis ${formatDatum(p.mietende)})`)
.join("\n");
await sendWithFallback({
from: `"Mietpark Hahn" <${process.env.SMTP_FROM}>`,
to: data.email,
subject: `Ihre Mietanfrage ist eingegangen Mietpark Hahn`,
text: `Guten Tag ${data.firma},\n\nvielen Dank für Ihre Mietanfrage. Wir werden uns schnellstmöglich bei Ihnen melden.\n\nIhre gemieteten Geräte:\n${equipmentList}\n\nDetaillierte Preisübersicht:\n\n${preisText}\n\nHinweis: Die angezeigten Preise sind Mietpreise inkl. ${VERSICHERUNG_PROZENT} % Versicherung und ${MWST_PROZENT} % MwSt. Die Bestätigung erfolgt nach Prüfung durch den Verleih.\n\nMit freundlichen Grüßen\nMietpark Hahn\nAnfrage-ID: ${data.anfrageId}\n\nUnsere AGB finden Sie unter: ${process.env.APP_URL ?? "https://www.mietparkhahn.de"}/agb`,
html,
}, `Kundeneingang ${data.firma}`);
}
// ─── Admin-Benachrichtigung (Vermieter) ───────────────────────────────────
export async function sendeAnfrageEmail(data: AnfrageEmailData) {
const { html: preisHtml, text: preisText } = buildPreisBlock(data.positionen);
// Action-Tokens für direkte Email-Links generieren
const { createActionToken } = await import("@/lib/admin-auth");
const [tokenBestaetigt, tokenAbgelehnt, tokenAbgeschlossen] = await Promise.all([
createActionToken(data.anfrageId, "bestaetigt"),
createActionToken(data.anfrageId, "abgelehnt"),
createActionToken(data.anfrageId, "abgeschlossen"),
]);
const baseUrl = process.env.APP_URL ?? "https://www.mietparkhahn.de";
const urlBestaetigt = `${baseUrl}/api/admin/anfragen-action?token=${tokenBestaetigt}`;
const urlAbgelehnt = `${baseUrl}/api/admin/anfragen-action?token=${tokenAbgelehnt}`;
const urlAbgeschlossen = `${baseUrl}/api/admin/anfragen-action?token=${tokenAbgeschlossen}`;
const html = `
<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"></head>
<body style="font-family:system-ui,sans-serif;color:#1c1917;max-width:600px;margin:0 auto;padding:0">
<div style="background:#1c1917;padding:20px 24px">
<h1 style="color:#f7d334;margin:0;font-size:20px;font-weight:700">Mietpark Hahn</h1>
<p style="color:rgba(255,255,255,0.7);margin:4px 0 0;font-size:13px">Neue Mietanfrage eingegangen</p>
</div>
<div style="padding:24px;background:#fff">
<h2 style="margin:0 0 16px;font-size:18px">Neue Mietanfrage</h2>
<h3 style="margin:0 0 10px;font-size:13px;text-transform:uppercase;letter-spacing:0.05em;color:#64748b">
Kontaktdaten Kunde
</h3>
<table style="width:100%;border-collapse:collapse;margin-bottom:24px;font-size:13px;background:#f8fafc;border:1px solid #e2e8f0">
<tr>
<td style="padding:8px 12px;color:#64748b;width:130px;border-bottom:1px solid #e2e8f0">Firma / Name</td>
<td style="padding:8px 12px;font-weight:600;border-bottom:1px solid #e2e8f0">${data.firma}</td>
</tr>
<tr>
<td style="padding:8px 12px;color:#64748b;border-bottom:1px solid #e2e8f0">Telefon</td>
<td style="padding:8px 12px;border-bottom:1px solid #e2e8f0">
<a href="tel:${data.telefon}" style="color:#1c1917;font-weight:600">${data.telefon}</a>
</td>
</tr>
<tr>
<td style="padding:8px 12px;color:#64748b">E-Mail</td>
<td style="padding:8px 12px">
<a href="mailto:${data.email}" style="color:#1c1917">${data.email}</a>
</td>
</tr>
</table>
<h3 style="margin:0 0 12px;font-size:13px;text-transform:uppercase;letter-spacing:0.05em;color:#64748b;border-top:1px solid #e2e8f0;padding-top:16px">
Angefragte Maschinen &amp; Preisübersicht
</h3>
${preisHtml}
<div style="margin:24px 0 0;display:flex;gap:12px;flex-wrap:wrap">
<a href="${urlBestaetigt}"
style="background:#16a34a;color:#fff;padding:10px 20px;border-radius:4px;font-weight:600;font-size:14px;text-decoration:none;display:inline-block">
Bestätigen
</a>
<a href="${urlAbgelehnt}"
style="background:#dc2626;color:#fff;padding:10px 20px;border-radius:4px;font-weight:600;font-size:14px;text-decoration:none;display:inline-block">
Ablehnen
</a>
<a href="${urlAbgeschlossen}"
style="background:#64748b;color:#fff;padding:10px 20px;border-radius:4px;font-weight:600;font-size:14px;text-decoration:none;display:inline-block">
Abschließen
</a>
</div>
<div style="margin-top:20px;padding:12px 16px;background:#f8fafc;border-left:3px solid #f7d334">
<p style="margin:0;font-size:12px;color:#64748b">
Anfrage-ID: <code style="background:#e2e8f0;padding:1px 4px;border-radius:2px">${data.anfrageId}</code><br>
<a href="${baseUrl}/admin/anfragen/${data.anfrageId}"
style="color:#1c1917;font-weight:600"> Anfrage im Admin öffnen</a>
</p>
</div>
</div>
</body>
</html>`;
const posText = `Kontakt:\nFirma/Name: ${data.firma}\nTelefon: ${data.telefon}\nE-Mail: ${data.email}\n\nMaschinen & Preise:\n\n${preisText}`;
const actionLinks = `\nQuickaktion aus Email:\n✓ Bestätigen: ${urlBestaetigt}\n✕ Ablehnen: ${urlAbgelehnt}\n✓ Abschließen: ${urlAbgeschlossen}`;
await sendWithFallback({
from: `"Mietpark Hahn" <${process.env.SMTP_FROM}>`,
to: process.env.SMTP_TO,
subject: `Neue Mietanfrage: ${data.firma} ${data.positionen.length} Gerät${data.positionen.length !== 1 ? "e" : ""} Mietpark Hahn`,
text: `Neue Mietanfrage\n\n${posText}\n\nAdmin: ${baseUrl}/admin/anfragen/${data.anfrageId}${actionLinks}`,
html,
}, `Admin-Anfrage ${data.firma}`);
}
// ─── Kunden-Statusbenachrichtigung ────────────────────────────────────────
export interface StatusEmailData {
anfrageId: string;
firma: string;
email: string;
neuerStatus: "bestaetigt" | "abgelehnt" | "abgeschlossen";
notizen?: string;
positionen?: {
maschineName: string;
mietbeginn: string;
mietende: string;
gesamtTage: number;
lieferung: boolean;
lieferadresse: string;
anmerkung: string;
tagessatz: number | null;
preisStufe?: "tag" | "woche" | "monat" | null;
zubehoer?: {
id: string;
name: string;
preisTag: number | null;
preisWoche?: number | null;
preisMonat?: number | null;
}[];
}[];
}
const STATUS_TEXTE: Record<
StatusEmailData["neuerStatus"],
{ betreff: string; headline: string; text: string; farbe: string }
> = {
bestaetigt: {
betreff: "Ihre Mietanfrage wurde bestätigt Mietpark Hahn",
headline: "Ihre Anfrage ist bestätigt!",
text: "Wir freuen uns, Ihnen mitteilen zu können, dass Ihre Mietanfrage bestätigt wurde. Wir setzen uns zur Vorbereitung mit Ihnen in Verbindung.",
farbe: "#16a34a",
},
abgelehnt: {
betreff: "Zu Ihrer Mietanfrage Mietpark Hahn",
headline: "Zu Ihrer Mietanfrage",
text: "Leider können wir Ihre Mietanfrage im angefragten Zeitraum nicht erfüllen. Bitte kontaktieren Sie uns für alternative Termine.",
farbe: "#dc2626",
},
abgeschlossen: {
betreff: "Ihre Miete wurde abgeschlossen Mietpark Hahn",
headline: "Vielen Dank!",
text: "Ihre Miete wurde erfolgreich abgeschlossen. Wir hoffen, Sie waren mit unserem Service zufrieden und freuen uns auf Ihre nächste Anfrage.",
farbe: "#475569",
},
};
export async function sendeKundenStatusEmail(data: StatusEmailData) {
const info = STATUS_TEXTE[data.neuerStatus];
// Geräte-Übersicht (falls vorhanden)
const equipmentHtml = data.positionen ? `
<div style="margin-bottom:24px">
<h3 style="margin:0 0 12px;font-size:13px;text-transform:uppercase;letter-spacing:0.05em;color:#64748b">
Ihre gemieteten Geräte
</h3>
<ul style="margin:0;padding-left:20px;list-style:none">
${data.positionen
.map((p) => `
<li style="margin:0 0 6px;padding:0;font-size:14px;color:#1c1917">
<span style="display:inline-block;width:6px;height:6px;background:#f7d334;border-radius:50%;margin-right:8px;vertical-align:middle"></span>
<strong>${p.maschineName}</strong>
<span style="color:#64748b"> · ${formatDatum(p.mietbeginn)} bis ${formatDatum(p.mietende)}</span>
</li>`)
.join("")}
</ul>
</div>
` : "";
// Preisblock (falls vorhanden)
const preisBlock = data.positionen ? buildPreisBlock(data.positionen) : null;
const preisHtml = preisBlock?.html ?? "";
const html = `
<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"></head>
<body style="font-family:system-ui,sans-serif;color:#1c1917;max-width:600px;margin:0 auto;padding:0">
<div style="background:#1c1917;padding:20px 24px">
<h1 style="color:#f7d334;margin:0;font-size:20px;font-weight:700">Mietpark Hahn</h1>
<p style="color:rgba(255,255,255,0.7);margin:4px 0 0;font-size:13px">Update zu Ihrer Mietanfrage</p>
</div>
<div style="padding:24px;background:#fff">
<div style="border-left:4px solid ${info.farbe};padding-left:16px;margin-bottom:20px">
<h2 style="margin:0 0 8px;font-size:18px">${info.headline}</h2>
<p style="margin:0;color:#475569;font-size:14px">Guten Tag ${data.firma},<br>${info.text}</p>
</div>
${equipmentHtml}
${data.positionen && preisHtml ? `
<h3 style="margin:0 0 12px;font-size:15px;border-top:2px solid #f7d334;padding-top:16px">
Detaillierte Preisübersicht
</h3>
${preisHtml}
` : ""}
${
data.notizen
? `<div style="padding:12px 16px;background:#f8fafc;border:1px solid #e2e8f0;margin-bottom:20px;margin-top:20px">
<p style="margin:0 0 4px;font-size:12px;color:#64748b;font-weight:600">NACHRICHT VOM VERLEIH</p>
<p style="margin:0;font-size:14px;color:#1c1917;white-space:pre-wrap">${data.notizen}</p>
</div>`
: ""
}
<p style="margin-top:20px;font-size:13px;color:#64748b">
Bei Fragen erreichen Sie uns unter
<a href="mailto:${process.env.SMTP_FROM}" style="color:#1c1917">${process.env.SMTP_FROM ?? ""}</a>
</p>
</div>
<div style="padding:12px 24px;background:#f8fafc;border-top:1px solid #e2e8f0">
<p style="margin:0;font-size:11px;color:#94a3b8">
Mit der Nutzung unserer Dienste akzeptieren Sie unsere
<a href="${process.env.APP_URL ?? "https://www.mietparkhahn.de"}/agb" style="color:#64748b">Allgemeinen Geschäftsbedingungen</a>.
</p>
</div>
</body>
</html>`;
const equipmentList = data.positionen
? data.positionen
.map((p) => `${p.maschineName} (${formatDatum(p.mietbeginn)} bis ${formatDatum(p.mietende)})`)
.join("\n")
: "";
await sendWithFallback({
from: `"Mietpark Hahn" <${process.env.SMTP_FROM}>`,
to: data.email,
subject: info.betreff,
text: `Guten Tag ${data.firma},\n\n${info.text}${equipmentList ? `\n\nIhre gemieteten Geräte:\n${equipmentList}` : ""}${data.notizen ? `\n\nNachricht vom Verleih:\n${data.notizen}` : ""}\n\nMit freundlichen Grüßen\nMietpark Hahn\n\nUnsere AGB finden Sie unter: ${process.env.APP_URL ?? "https://www.mietparkhahn.de"}/agb`,
html,
}, `Status-${data.neuerStatus} ${data.firma}`);
}
// ─── Registrierungsbestätigung ─────────────────────────────────────────────
export async function sendeRegistrierungsBestaetigung(data: {
email: string;
firma?: string;
bestaetigungsLink: string;
}) {
const name = data.firma || data.email;
const html = `
<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"></head>
<body style="font-family:system-ui,sans-serif;color:#1c1917;max-width:600px;margin:0 auto;padding:0">
<div style="background:#1c1917;padding:20px 24px">
<h1 style="color:#f7d334;margin:0;font-size:20px;font-weight:700">Mietpark Hahn</h1>
<p style="color:rgba(255,255,255,0.7);margin:4px 0 0;font-size:13px">E-Mail-Adresse bestätigen</p>
</div>
<div style="padding:24px;background:#fff">
<p style="margin:0 0 16px">Guten Tag ${name},</p>
<p style="margin:0 0 24px;color:#475569">
vielen Dank für Ihre Registrierung bei Mietpark Hahn. Bitte bestätigen Sie Ihre
E-Mail-Adresse, um Zugang zu Ihrem Kundenbereich zu erhalten.
</p>
<a href="${data.bestaetigungsLink}"
style="display:inline-block;background:#f7d334;color:#1c1917;font-weight:700;padding:12px 28px;text-decoration:none;border-radius:4px;font-size:15px">
E-Mail-Adresse bestätigen
</a>
<p style="margin:24px 0 0;font-size:12px;color:#94a3b8">
Dieser Link ist 24 Stunden gültig. Falls Sie sich nicht registriert haben, können Sie
diese E-Mail ignorieren.
</p>
</div>
<div style="padding:12px 24px;background:#f8fafc;border-top:1px solid #e2e8f0">
<p style="margin:0;font-size:11px;color:#94a3b8">
Mit der Nutzung unserer Dienste akzeptieren Sie unsere
<a href="${process.env.APP_URL ?? "https://www.mietparkhahn.de"}/agb" style="color:#64748b">Allgemeinen Geschäftsbedingungen</a>.
</p>
</div>
</body>
</html>`;
await sendWithFallback({
from: `"Mietpark Hahn" <${process.env.SMTP_FROM}>`,
to: data.email,
subject: "Bitte bestätigen Sie Ihre E-Mail-Adresse Mietpark Hahn",
text: `Guten Tag ${name},\n\nbitte bestätigen Sie Ihre E-Mail-Adresse:\n\n${data.bestaetigungsLink}\n\nDieser Link ist 24 Stunden gültig.\n\nMit freundlichen Grüßen\nMietpark Hahn\n\nUnsere AGB finden Sie unter: ${process.env.APP_URL ?? "https://www.mietparkhahn.de"}/agb`,
html,
}, `Registrierung ${name}`);
}
// ─── Maschinen-Bedarfscheck ────────────────────────────────────────────────
export async function sendeBedarfscheckAnKunde(data: {
name: string;
email: string;
}) {
const html = `
<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"></head>
<body style="font-family:system-ui,sans-serif;color:#1c1917;max-width:600px;margin:0 auto;padding:0">
<div style="background:#1c1917;padding:20px 24px">
<h1 style="color:#f7d334;margin:0;font-size:20px;font-weight:700">Mietpark Hahn</h1>
<p style="color:rgba(255,255,255,0.7);margin:4px 0 0;font-size:13px">Ihr kostenloser Maschinen-Bedarfscheck</p>
</div>
<div style="padding:24px;background:#fff">
<p style="margin:0 0 20px">Guten Tag ${data.name},</p>
<p style="margin:0 0 24px;color:#475569;font-size:14px">
vielen Dank für Ihre Anfrage! Hier sind die Antworten zu den 7 wichtigsten Fragen vor der Maschinen-Miete:
</p>
<ol style="margin:0 0 24px;padding-left:20px;color:#1c1917">
<li style="margin-bottom:16px;font-size:14px;line-height:1.5">
<strong>Welche Maschinenklasse passt zu meinem Projekt?</strong><br>
<span style="color:#475569">Minibagger (1,53t) für Garten- und enge Baustellenarbeiten, Kettenbagger (516t) für Baugruben und Schachtarbeiten, Radlader für Materialtransport und Verdichtung.</span>
</li>
<li style="margin-bottom:16px;font-size:14px;line-height:1.5">
<strong>Welches Zubehör brauche ich?</strong><br>
<span style="color:#475569">Tieflöffel für Erde, Grabenräumlöffel für Leitungsgräben, Abbruchhammer für Beton/Asphalt, Greifer für Schüttgut. Die genaue Auswahl beraten wir gerne persönlich.</span>
</li>
<li style="margin-bottom:16px;font-size:14px;line-height:1.5">
<strong>Wie plane ich Mietdauer & Lieferung richtig?</strong><br>
<span style="color:#475569">Immer 1 Puffertag einkalkulieren. Lieferung mindestens 1 Werktag vorab anfragen. In Frühjahr/Herbst frühzeitig reservieren, da die Nachfrage hoch ist.</span>
</li>
<li style="margin-bottom:16px;font-size:14px;line-height:1.5">
<strong>Was muss ich bei der Übergabe prüfen?</strong><br>
<span style="color:#475569">Betriebsstunden und Ölstände notieren, Schäden fotografieren, Bedienungsanleitung mitnehmen. Dies schützt Sie vor Haftungsansprüchen.</span>
</li>
<li style="margin-bottom:16px;font-size:14px;line-height:1.5">
<strong>Welche Versicherung ist sinnvoll?</strong><br>
<span style="color:#475569">Privatpersonen sollten ihre Haftpflichtversicherung prüfen. Firmen benötigen Baugeräteversicherung. Eine Maschinenbruchversicherung über uns ist optional, aber empfohlen.</span>
</li>
<li style="margin-bottom:16px;font-size:14px;line-height:1.5">
<strong>Wie spare ich durch Wochenmiete?</strong><br>
<span style="color:#475569">Wochentarife sind ca. 3040% günstiger als 5× Tagessatz. Schon ab 4 Tagen Einsatz lohnt sich die Wochenmiete gegenüber Tagesmietung.</span>
</li>
<li style="margin-bottom:0;font-size:14px;line-height:1.5">
<strong>Was tun, wenn die Maschine ausfällt?</strong><br>
<span style="color:#475569">Sofort den Verleih anrufen. Wenn kein Verschulden vorliegt, entstehen keine Kosten während des Ausfalls. Ersatz wird schnellstmöglich bereitgestellt.</span>
</li>
</ol>
<div style="padding:16px;background:#fef3c7;border-left:3px solid #f7d334;margin:24px 0">
<p style="margin:0;font-size:13px;color:#92400e">
<strong>Nächste Schritte:</strong> Rufen Sie uns an oder stellen Sie eine Anfrage mit den gewünschten Maschinen. Unser Team prüft sofort die Verfügbarkeit und meldet sich bei Ihnen!
</p>
</div>
<p style="margin:20px 0 0;font-size:13px;color:#64748b">
Bei Fragen erreichen Sie uns unter
<a href="tel:${process.env.COMPANY_PHONE ?? ""}" style="color:#1c1917">${process.env.COMPANY_PHONE ?? ""}</a>
oder per E-Mail an
<a href="mailto:${process.env.SMTP_FROM ?? ""}" style="color:#1c1917">${process.env.SMTP_FROM ?? ""}</a>
</p>
</div>
<div style="padding:12px 24px;background:#f8fafc;border-top:1px solid #e2e8f0">
<p style="margin:0;font-size:11px;color:#94a3b8">
Mit der Nutzung unserer Dienste akzeptieren Sie unsere
<a href="${process.env.APP_URL ?? "https://www.mietparkhahn.de"}/agb" style="color:#64748b">Allgemeinen Geschäftsbedingungen</a>.
</p>
</div>
</body>
</html>`;
const text = `Guten Tag ${data.name},
vielen Dank für Ihre Anfrage! Hier sind die Antworten zu den 7 wichtigsten Fragen vor der Maschinen-Miete:
1. Welche Maschinenklasse passt zu meinem Projekt?
Minibagger (1,53t) für Garten- und enge Baustellenarbeiten, Kettenbagger (516t) für Baugruben und Schachtarbeiten, Radlader für Materialtransport und Verdichtung.
2. Welches Zubehör brauche ich?
Tieflöffel für Erde, Grabenräumlöffel für Leitungsgräben, Abbruchhammer für Beton/Asphalt, Greifer für Schüttgut. Die genaue Auswahl beraten wir gerne persönlich.
3. Wie plane ich Mietdauer & Lieferung richtig?
Immer 1 Puffertag einkalkulieren. Lieferung mindestens 1 Werktag vorab anfragen. In Frühjahr/Herbst frühzeitig reservieren, da die Nachfrage hoch ist.
4. Was muss ich bei der Übergabe prüfen?
Betriebsstunden und Ölstände notieren, Schäden fotografieren, Bedienungsanleitung mitnehmen. Dies schützt Sie vor Haftungsansprüchen.
5. Welche Versicherung ist sinnvoll?
Privatpersonen sollten ihre Haftpflichtversicherung prüfen. Firmen benötigen Baugeräteversicherung. Eine Maschinenbruchversicherung über uns ist optional, aber empfohlen.
6. Wie spare ich durch Wochenmiete?
Wochentarife sind ca. 3040% günstiger als 5× Tagessatz. Schon ab 4 Tagen Einsatz lohnt sich die Wochenmiete gegenüber Tagesmietung.
7. Was tun, wenn die Maschine ausfällt?
Sofort den Verleih anrufen. Wenn kein Verschulden vorliegt, entstehen keine Kosten während des Ausfalls. Ersatz wird schnellstmöglich bereitgestellt.
Nächste Schritte: Rufen Sie uns an oder stellen Sie eine Anfrage mit den gewünschten Maschinen.
Mit freundlichen Grüßen
Mietpark Hahn
Unsere AGB finden Sie unter: ${process.env.APP_URL ?? "https://www.mietparkhahn.de"}/agb`;
await sendWithFallback({
from: `"Mietpark Hahn" <${process.env.SMTP_FROM}>`,
to: data.email,
subject: "Ihr kostenloser Maschinen-Bedarfscheck Mietpark Hahn",
text,
html,
}, `Bedarfscheck ${data.name}`);
}
export async function sendeBedarfscheckAnVermieter(data: {
name: string;
email: string;
telefon: string;
adresse: string | null;
}) {
const html = `
<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"></head>
<body style="font-family:system-ui,sans-serif;color:#1c1917;max-width:600px;margin:0 auto;padding:0">
<div style="background:#1c1917;padding:20px 24px">
<h1 style="color:#f7d334;margin:0;font-size:20px;font-weight:700">Mietpark Hahn</h1>
<p style="color:rgba(255,255,255,0.7);margin:4px 0 0;font-size:13px">Neuer Lead aus Bedarfscheck</p>
</div>
<div style="padding:24px;background:#fff">
<h2 style="margin:0 0 16px;font-size:18px">Neuer Lead aus Maschinen-Bedarfscheck</h2>
<h3 style="margin:0 0 10px;font-size:13px;text-transform:uppercase;letter-spacing:0.05em;color:#64748b">
Kontaktdaten
</h3>
<table style="width:100%;border-collapse:collapse;margin-bottom:24px;font-size:13px;background:#f8fafc;border:1px solid #e2e8f0">
<tr>
<td style="padding:8px 12px;color:#64748b;width:130px;border-bottom:1px solid #e2e8f0">Name / Firma</td>
<td style="padding:8px 12px;font-weight:600;border-bottom:1px solid #e2e8f0">${data.name}</td>
</tr>
<tr>
<td style="padding:8px 12px;color:#64748b;border-bottom:1px solid #e2e8f0">E-Mail</td>
<td style="padding:8px 12px;border-bottom:1px solid #e2e8f0">
<a href="mailto:${data.email}" style="color:#1c1917">${data.email}</a>
</td>
</tr>
<tr>
<td style="padding:8px 12px;color:#64748b;border-bottom:1px solid #e2e8f0">Telefon</td>
<td style="padding:8px 12px;border-bottom:1px solid #e2e8f0">
<a href="tel:${data.telefon}" style="color:#1c1917;font-weight:600">${data.telefon}</a>
</td>
</tr>
${
data.adresse
? `<tr>
<td style="padding:8px 12px;color:#64748b">Adresse</td>
<td style="padding:8px 12px">${data.adresse}</td>
</tr>`
: ""
}
</table>
<div style="padding:12px 16px;background:#f8fafc;border-left:3px solid #f7d334">
<p style="margin:0;font-size:12px;color:#64748b">
Quelle: Maschinen-Bedarfscheck (Lead Magnet)
</p>
</div>
</div>
</body>
</html>`;
const text = `Neuer Lead aus Maschinen-Bedarfscheck
Kontaktdaten:
Name/Firma: ${data.name}
E-Mail: ${data.email}
Telefon: ${data.telefon}
${data.adresse ? `Adresse: ${data.adresse}` : ""}
Quelle: Maschinen-Bedarfscheck`;
await sendWithFallback({
from: `"Mietpark Hahn" <${process.env.SMTP_FROM}>`,
to: process.env.SMTP_TO,
subject: `Neuer Lead: ${data.name} Maschinen-Bedarfscheck`,
text,
html,
}, `Lead ${data.name}`);
}
// ─── Email-Änderungsbestätigung (Admin-Profil) ────────────────────────────────
export async function sendeEmailAenderungsBestaetigung(data: {
adminEmail: string;
adminName: string;
bestaetigungsLink: string;
}) {
const html = `
<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"></head>
<body style="font-family:system-ui,sans-serif;color:#1c1917;max-width:600px;margin:0 auto;padding:0">
<div style="background:#1c1917;padding:20px 24px">
<h1 style="color:#f7d334;margin:0;font-size:20px;font-weight:700">Mietpark Hahn</h1>
<p style="color:rgba(255,255,255,0.7);margin:4px 0 0;font-size:13px">Admin-Bereich: E-Mail-Änderung</p>
</div>
<div style="padding:24px;background:#fff">
<p style="margin:0 0 16px">Guten Tag ${data.adminName},</p>
<p style="margin:0 0 24px;color:#475569">
Sie haben eine neue E-Mail-Adresse für Ihr Admin-Konto hinterlegt.
Bitte bestätigen Sie diese Änderung, um die neue E-Mail-Adresse zu aktivieren.
</p>
<a href="${data.bestaetigungsLink}"
style="display:inline-block;background:#f7d334;color:#1c1917;font-weight:700;padding:12px 28px;text-decoration:none;border-radius:4px;font-size:15px">
E-Mail-Adresse bestätigen
</a>
<p style="margin:24px 0 0;font-size:12px;color:#94a3b8">
<strong>Wichtig:</strong> Ihre bisherige E-Mail-Adresse bleibt aktiv, bis Sie diesen Link klicken.
Dieser Link ist 24 Stunden gültig.
</p>
<p style="margin:16px 0 0;font-size:12px;color:#94a3b8">
Falls Sie diese E-Mail nicht angefordert haben, können Sie sie ignorieren.
Ihre E-Mail-Adresse wird nicht geändert.
</p>
</div>
<div style="padding:12px 24px;background:#f8fafc;border-top:1px solid #e2e8f0">
<p style="margin:0;font-size:11px;color:#94a3b8">
Mit der Nutzung unserer Dienste akzeptieren Sie unsere
<a href="${process.env.APP_URL ?? "https://www.mietparkhahn.de"}/agb" style="color:#64748b">Allgemeinen Geschäftsbedingungen</a>.
</p>
</div>
</body>
</html>`;
const text = `Guten Tag ${data.adminName},
Sie haben eine neue E-Mail-Adresse für Ihr Admin-Konto hinterlegt.
Bitte bestätigen Sie diese Änderung unter folgendem Link:
${data.bestaetigungsLink}
Dieser Link ist 24 Stunden gültig.
WICHTIG: Ihre bisherige E-Mail-Adresse bleibt aktiv, bis Sie den Link oben bestätigen.
Mit freundlichen Grüßen
Mietpark Hahn
Unsere AGB finden Sie unter: ${process.env.APP_URL ?? "https://www.mietparkhahn.de"}/agb`;
await sendWithFallback({
from: `"Mietpark Hahn" <${process.env.SMTP_FROM}>`,
to: data.adminEmail,
subject: "Neue E-Mail-Adresse bestätigen Mietpark Hahn",
text,
html,
}, `Email-Änderung ${data.adminName}`);
}

View File

@ -0,0 +1,235 @@
# Modul: Admin-Auth & Security
> Vollständige Admin-Authentifizierung: HMAC-SHA256 Session-Tokens (2h), Brute-Force-Schutz (Rate-Limit + exponentieller Backoff), Token-Blacklist (Logout-Revocation), One-Time-Use Email-Action-Links, Session-Timeout-Provider (15-Min-Warnung), Audit-Logging aller Login-Versuche.
---
## Enthaltene Dateien
| Ziel im neuen Projekt | Inhalt |
|---|---|
| `lib/admin-auth.ts` | Token-Erzeugung/Verifikation (HMAC-SHA256), `requireAdmin()` Middleware |
| `lib/rate-limit.ts` | In-Memory Rate-Limiting mit Backoff (5 Versuche/15 Min, Lock nach 10) |
| `lib/token-blacklist.ts` | Session-Token Revocation + Action-Token One-Time-Use (Supabase) |
| `lib/audit-log.ts` | Login-Audit-Logging + Brute-Force-Erkennung |
| `app/api/admin/login/route.ts` | POST: Login, DELETE: Logout |
| `app/api/admin/anfragen-action/route.ts` | GET: Email-Action-Link verarbeiten (HMAC-Token) |
| `app/admin/login/page.tsx` | Login-Seite (Open-Redirect-Schutz) |
| `app/admin/audit-logs/page.tsx` | Admin-Dashboard: Login-Überwachung |
| `components/admin/SessionTimeoutProvider.tsx` | Client-seitiger Inaktivitäts-Tracker (Warnung + Auto-Logout) |
| `migrations/MIGRATIONS_TOKEN_BLACKLIST.sql` | Tabellen: `admin_session_blacklist`, `action_token_blacklist` |
| `migrations/MIGRATIONS_AUDIT_LOGS.sql` | Tabelle: `admin_audit_logs` |
---
## Voraussetzungen
```bash
npm install bcryptjs
npm install -D @types/bcryptjs
```
Benötigt außerdem:
- `lib/supabase.ts` mit Service Client
- Supabase-Tabelle `benutzer` (Admin-User-Tabelle, siehe unten)
---
## Umgebungsvariablen (`.env.local`)
```env
NEXTAUTH_SECRET=min-32-zeichen-zufaelliger-string # HMAC-Schlüssel für Tokens
```
---
## Datenbank-Migrationen (Supabase)
### 1. Migrations aus `migrations/` ausführen
```
MIGRATIONS_AUDIT_LOGS.sql → Tabelle admin_audit_logs
MIGRATIONS_TOKEN_BLACKLIST.sql → Tabellen admin_session_blacklist, action_token_blacklist
```
### 2. Admin-User-Tabelle (muss im Projekt existieren)
```sql
CREATE TABLE benutzer (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text UNIQUE NOT NULL,
password_hash text NOT NULL, -- bcrypt-Hash
name text,
active boolean DEFAULT true,
created_at timestamptz DEFAULT now()
);
```
### 3. Supabase-Typen ergänzen (`lib/supabase.ts`)
In `Database.public.Tables` ergänzen:
```ts
benutzer: { Row: { id: string; email: string; password_hash: string; active: boolean } }
admin_audit_logs: { Row: { id: string; email: string; ip_addr: string; user_agent: string; success: boolean; reason: string | null; timestamp: string } }
admin_session_blacklist: { Row: { id: string; admin_id: string; token_signature: string; revoked_at: string; reason: string } }
action_token_blacklist: { Row: { id: string; anfrage_id: string; token_signature: string; action_type: string; used_at: string } }
```
---
## Einbindung Schritt für Schritt
### 1. Dateien kopieren
Alle `files/` in entsprechende Projektpfade.
### 2. Admin-Layout absichern (`app/admin/layout.tsx`)
```tsx
import { SessionTimeoutProvider } from "@/components/admin/SessionTimeoutProvider";
export default function AdminLayout({ children }) {
return (
<SessionTimeoutProvider>
{children}
</SessionTimeoutProvider>
);
}
```
### 3. Admin-API-Routes schützen
```ts
import { requireAdmin } from "@/lib/admin-auth";
export async function GET(req: Request) {
const admin = await requireAdmin(req);
if (!admin.ok) return admin.response;
// ... Route-Logik
}
```
### 4. Login-Route in Nav verlinken
```tsx
// Redirect nach Login zu /admin (oder ?from=/admin/anfragen)
<Link href="/admin/login">Admin Login</Link>
```
### 5. Ersten Admin-User anlegen
```sql
-- bcrypt-Hash generieren (cost factor 12) und direkt eintragen
INSERT INTO benutzer (email, password_hash)
VALUES ('admin@example.com', '$2b$12$...');
```
Oder via Script:
```ts
import bcrypt from "bcryptjs";
const hash = await bcrypt.hash("password", 12);
```
### 6. Action-Links (für Email-Buttons)
Wenn Action-Links aus Emails verarbeitet werden sollen (z.B. Anfrage bestätigen per Klick):
- `app/api/admin/anfragen-action/route.ts` verarbeitet `GET ?token=...`
- Token erzeugen mit `createActionToken(id, "bestaetigt")` aus `lib/admin-auth.ts`
- Route anpassen: Was passiert nach erfolgreicher Aktion (DB-Update + Redirect)
---
## Anpassungspunkte
| Was | Wo |
|---|---|
| Session-Dauer (aktuell 2h) | `lib/admin-auth.ts``SESSION_DURATION` |
| Action-Token Gültigkeit (7 Tage) | `lib/admin-auth.ts``ACTION_TOKEN_DURATION` |
| Rate-Limit Schwellenwert (5 Versuche) | `lib/rate-limit.ts``MAX_ATTEMPTS` |
| Account-Lock Dauer (15 Min) | `lib/rate-limit.ts``LOCK_DURATION_MS` |
| Inaktivitäts-Timeout (2h) | `components/admin/SessionTimeoutProvider.tsx``TIMEOUT_MS` |
| Warn-Zeitpunkt (15 Min vor Ablauf) | `components/admin/SessionTimeoutProvider.tsx``WARN_BEFORE_MS` |
| Audit-Log Aufbewahrung (90 Tage) | `lib/audit-log.ts``deleteOldAuditLogs()` |
---
## Integrations-Prompt
Kopiere diesen Prompt in eine neue KI-Konversation, nachdem du die `files/` in dein Projekt kopiert hast. Ersetze alle `[PLATZHALTER]`.
```
Ich integriere das Admin-Auth-Modul (HMAC-Session, Rate-Limit, Token-Blacklist, Audit-Log) in mein Next.js/Supabase-Projekt.
PROJEKT-KONTEXT:
- Erster Admin-User: Email [ADMIN_EMAIL], Passwort [ADMIN_PASSWORT]
- Admin-Bereich URL-Prefix: /admin
- Supabase: bereits eingerichtet, lib/supabase.ts vorhanden
BEREITS KOPIERTE DATEIEN (aus modules/02-admin-auth/files/):
- lib/admin-auth.ts, lib/rate-limit.ts, lib/token-blacklist.ts, lib/audit-log.ts
- app/api/admin/login/route.ts
- app/api/admin/anfragen-action/route.ts
- app/admin/login/page.tsx
- app/admin/audit-logs/page.tsx
- components/admin/SessionTimeoutProvider.tsx
AUFGABEN führe sie der Reihe nach aus:
1. SUPABASE-MIGRATIONEN: Führe diese SQLs nacheinander im Supabase SQL-Editor aus:
-- Aus migrations/MIGRATIONS_AUDIT_LOGS.sql:
CREATE TABLE IF NOT EXISTS admin_audit_logs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text NOT NULL, ip_addr text NOT NULL, user_agent text NOT NULL,
success boolean NOT NULL, reason text, timestamp timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_audit_logs_email ON admin_audit_logs(email);
CREATE INDEX IF NOT EXISTS idx_audit_logs_timestamp ON admin_audit_logs(timestamp DESC);
ALTER TABLE admin_audit_logs ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Admin Logs lesen" ON admin_audit_logs FOR SELECT USING (true);
-- Aus migrations/MIGRATIONS_TOKEN_BLACKLIST.sql:
CREATE TABLE admin_session_blacklist (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
admin_id uuid NOT NULL, token_signature text NOT NULL UNIQUE,
revoked_at timestamptz DEFAULT now(), reason text NOT NULL, notes text
);
CREATE INDEX idx_admin_session_blacklist_sig ON admin_session_blacklist(token_signature);
CREATE TABLE action_token_blacklist (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
anfrage_id uuid NOT NULL, token_signature text NOT NULL UNIQUE,
action_type text NOT NULL, used_at timestamptz DEFAULT now(),
used_by_ip text, notes text
);
CREATE INDEX idx_action_token_blacklist_sig ON action_token_blacklist(token_signature);
ALTER TABLE admin_session_blacklist ENABLE ROW LEVEL SECURITY;
ALTER TABLE action_token_blacklist ENABLE ROW LEVEL SECURITY;
2. ADMIN-USER-TABELLE: Prüfe ob eine Tabelle für Admin-Benutzer existiert. Falls nicht, erstelle:
CREATE TABLE benutzer (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text UNIQUE NOT NULL, password_hash text NOT NULL,
name text, active boolean DEFAULT true, created_at timestamptz DEFAULT now()
);
3. ERSTEN ADMIN-USER ANLEGEN: Generiere einen bcrypt-Hash für [ADMIN_PASSWORT] (cost 12)
und füge diesen direkt per SQL ein:
INSERT INTO benutzer (email, password_hash, name) VALUES ('[ADMIN_EMAIL]', '[HASH]', 'Admin');
4. SUPABASE-TYPEN: Lies lib/supabase.ts und ergänze in Database.public.Tables:
benutzer: { Row: { id: string; email: string; password_hash: string; active: boolean; name: string | null } }
admin_audit_logs: { Row: { id: string; email: string; ip_addr: string; user_agent: string; success: boolean; reason: string | null; timestamp: string } }
admin_session_blacklist: { Row: { id: string; admin_id: string; token_signature: string; revoked_at: string; reason: string } }
action_token_blacklist: { Row: { id: string; anfrage_id: string; token_signature: string; action_type: string; used_at: string } }
5. ENV-VARIABLEN: Ergänze .env.local um:
NEXTAUTH_SECRET=[MIN_32_ZEICHEN_ZUFAELLIGER_STRING]
6. ADMIN-LAYOUT absichern: Lies app/admin/layout.tsx (oder erstelle es).
Importiere und wrappe mit <SessionTimeoutProvider>:
import { SessionTimeoutProvider } from "@/components/admin/SessionTimeoutProvider";
export default function AdminLayout({ children }) {
return <SessionTimeoutProvider>{children}</SessionTimeoutProvider>;
}
7. API-ROUTES schützen: Zeige mir alle Dateien unter app/api/admin/ (außer login/).
Füge in jede Route am Anfang der Handler-Funktion ein:
import { requireAdmin } from "@/lib/admin-auth";
const admin = await requireAdmin(req);
if (!admin.ok) return admin.response;
8. TEST: Starte Dev-Server, öffne /admin/login, logge dich mit [ADMIN_EMAIL] ein.
Prüfe: Weiterleitung zu /admin nach Login, /admin/audit-logs zeigt Login-Eintrag.
Lies jede Datei vor dem Bearbeiten. Melde wenn alle Schritte abgeschlossen sind.
```

View File

@ -0,0 +1,204 @@
import { Suspense } from "react";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { verifySessionToken } from "@/lib/admin-auth";
import { getAuditLogs } from "@/lib/audit-log";
import { cookies } from "next/headers";
interface AuditLogEntry {
id: string;
email: string;
ip_addr: string;
user_agent: string;
success: boolean;
timestamp: string;
reason?: string;
}
async function AuditLogsContent() {
// ──── Auth Check ────
const cookieStore = await cookies();
const token = cookieStore.get("admin_session")?.value;
if (!token) {
return (
<div className="text-center py-12">
<p className="text-red-500 font-semibold">Unauthorized</p>
<Link href="/admin/login" className="text-blue-500 underline mt-4 inline-block">
Zurück zum Login
</Link>
</div>
);
}
const session = await verifySessionToken(token);
if (!session) {
return (
<div className="text-center py-12">
<p className="text-red-500 font-semibold">Session abgelaufen</p>
<Link href="/admin/login" className="text-blue-500 underline mt-4 inline-block">
Erneut anmelden
</Link>
</div>
);
}
// ──── Audit-Logs abrufen (letzte 100) ────
const logs = await getAuditLogs({ limit: 100, offset: 0 });
// ──── Statistiken ────
const successCount = logs.filter((l) => l.success).length;
const failedCount = logs.filter((l) => !l.success).length;
const uniqueEmails = new Set(logs.map((l) => l.email)).size;
const uniqueIps = new Set(logs.map((l) => l.ip_addr)).size;
// ──── Fehlerrate in der letzten Stunde ────
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const recentLogs = logs.filter((l) => l.timestamp > oneHourAgo);
const recentFailed = recentLogs.filter((l) => !l.success).length;
return (
<div className="space-y-6">
{/* Header */}
<div className="bg-gradient-to-r from-[#1c1917] to-[#2d2b29] text-white py-8 px-6 rounded-lg">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Audit-Logs</h1>
<p className="text-white/70 text-sm mt-1">Login-Aktivitäten und Sicherheitsereignisse</p>
</div>
<Link
href="/admin"
className="inline-flex items-center gap-2 text-white/60 hover:text-white transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Zurück
</Link>
</div>
</div>
{/* Statistiken */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-white border border-slate-200 p-4 rounded-lg">
<div className="text-sm text-slate-500">Erfolgreich (Gesamt)</div>
<div className="text-2xl font-bold text-green-600">{successCount}</div>
</div>
<div className="bg-white border border-slate-200 p-4 rounded-lg">
<div className="text-sm text-slate-500">Fehlgeschlagen (Gesamt)</div>
<div className="text-2xl font-bold text-red-600">{failedCount}</div>
</div>
<div className="bg-white border border-slate-200 p-4 rounded-lg">
<div className="text-sm text-slate-500">Fehlgeschlagen (1h)</div>
<div className={`text-2xl font-bold ${recentFailed >= 5 ? "text-red-600" : "text-green-600"}`}>
{recentFailed}
</div>
</div>
<div className="bg-white border border-slate-200 p-4 rounded-lg">
<div className="text-sm text-slate-500">Eindeutige IPs</div>
<div className="text-2xl font-bold text-slate-900">{uniqueIps}</div>
</div>
</div>
{/* Warnung bei verdächtiger Aktivität */}
{recentFailed >= 5 && (
<div className="bg-red-50 border-l-4 border-red-500 p-4 rounded">
<div className="flex">
<div>
<p className="text-sm font-semibold text-red-800"> Verdächtige Aktivität erkannt</p>
<p className="text-sm text-red-700 mt-1">
{recentFailed} fehlgeschlagene Login-Versuche in der letzten Stunde
</p>
</div>
</div>
</div>
)}
{/* Logs Tabelle */}
<div className="bg-white border border-slate-200 rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="px-4 py-3 text-left font-semibold text-slate-700">Zeitstempel</th>
<th className="px-4 py-3 text-left font-semibold text-slate-700">E-Mail</th>
<th className="px-4 py-3 text-left font-semibold text-slate-700">IP-Adresse</th>
<th className="px-4 py-3 text-left font-semibold text-slate-700">Status</th>
<th className="px-4 py-3 text-left font-semibold text-slate-700">Grund</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{logs.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-slate-500">
Keine Audit-Logs vorhanden
</td>
</tr>
) : (
logs.map((log) => (
<tr key={log.id} className="hover:bg-slate-50 transition-colors">
<td className="px-4 py-3 whitespace-nowrap text-slate-600">
{new Date(log.timestamp).toLocaleString("de-DE")}
</td>
<td className="px-4 py-3 font-mono text-slate-700 truncate">{log.email}</td>
<td className="px-4 py-3 font-mono text-slate-700 text-xs">{log.ip_addr}</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
log.success
? "bg-green-100 text-green-800"
: "bg-red-100 text-red-800"
}`}
>
{log.success ? "✓ Erfolgreich" : "✗ Fehlgeschlagen"}
</span>
</td>
<td className="px-4 py-3 text-slate-600 text-xs">
{log.reason ? formatReason(log.reason) : "-"}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* User-Agent Info */}
<div className="bg-blue-50 border border-blue-200 p-4 rounded-lg">
<p className="text-sm text-blue-800">
<strong>Hinweis:</strong> User-Agent und detaillierte Geräte-Informationen sind in der Datenbank
gespeichert. Für erweiterte Analysen siehe die Supabase-Tabelle{" "}
<code className="bg-white px-2 py-1 rounded border border-blue-200 font-mono text-xs">
admin_audit_logs
</code>
</p>
</div>
</div>
);
}
function formatReason(reason: string): string {
const reasons: Record<string, string> = {
invalid_password: "Falsches Passwort",
user_not_found_or_inactive: "User nicht gefunden / inaktiv",
missing_credentials: "Fehlende Anmeldedaten",
invalid_token: "Ungültiger Token",
token_expired: "Token abgelaufen",
};
return reasons[reason] || reason;
}
export default function AuditLogsPage() {
return (
<div className="max-w-6xl mx-auto px-4 py-8">
<Suspense
fallback={
<div className="text-center py-12">
<p className="text-slate-500">Lädt Audit-Logs...</p>
</div>
}
>
<AuditLogsContent />
</Suspense>
</div>
);
}

View File

@ -0,0 +1,115 @@
"use client";
import { Suspense, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
/**
* Validiert Redirect-URL: nur interne Routes erlauben
* Verhindert Open Redirect zu externen Seiten
*/
function isInternalUrl(url: string): boolean {
if (!url.startsWith("/")) return false;
// Nur Admin- und Kunden-Routes erlauben
return url.startsWith("/admin/") || url.startsWith("/kunden/");
}
function AdminLoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const rawFrom = searchParams.get("from");
// ✅ Validierung: nur interne URLs erlauben, Fallback auf /admin/anfragen
const from = rawFrom && isInternalUrl(rawFrom) ? rawFrom : "/admin/anfragen";
const sessionExpired = searchParams.get("session_expired") === "true";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState(
sessionExpired ? "Ihre Session ist abgelaufen. Bitte melden Sie sich erneut an." : ""
);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
const res = await fetch("/api/admin/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (res.ok) {
router.push(from);
} else {
setError("Ungültige Zugangsdaten");
}
setLoading(false);
}
return (
<div className="min-h-screen bg-[#f5f5f4] flex items-center justify-center px-4">
<div className="bg-white border border-slate-200 p-8 w-full max-w-sm">
<div className="mb-6">
<h1 className="text-xl font-bold text-[#1c1917] tracking-tight">
Admin · Mietpark Hahn
</h1>
<p className="text-sm text-slate-500 mt-1">Bitte anmelden</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="email">E-Mail</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
placeholder="admin@beispiel.de"
className="mt-1 rounded-md"
required
/>
</div>
<div>
<Label htmlFor="password">Passwort</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
className="mt-1 rounded-md"
required
/>
</div>
{error && (
<p className={`text-sm ${sessionExpired ? "text-blue-600" : "text-red-500"}`}>
{error}
</p>
)}
<Button
type="submit"
disabled={loading}
className="w-full bg-[#1c1917] hover:bg-[#44403c] text-white rounded-md font-semibold border-transparent"
>
{loading ? "Wird geprüft…" : "Anmelden"}
</Button>
</form>
</div>
</div>
);
}
export default function AdminLoginPage() {
return (
<Suspense>
<AdminLoginForm />
</Suspense>
);
}

View File

@ -0,0 +1,165 @@
import { NextRequest, NextResponse } from "next/server";
import { createServiceClient } from "@/lib/supabase";
import { verifyActionToken } from "@/lib/admin-auth";
import { markActionTokenUsed } from "@/lib/token-blacklist";
import { sendeKundenStatusEmail, getKalenderTage } from "@/lib/mailer";
// Verfügbarkeit aktualisieren wenn Status sich ändert
async function aktualisiereVerfuegbarkeit(
db: ReturnType<typeof createServiceClient>,
anfrageId: string,
neuerStatus: string
) {
if (neuerStatus === "bestaetigt") {
await db
.from("verfuegbarkeit")
.update({ status: "belegt" })
.eq("anfrage_id", anfrageId);
} else if (neuerStatus === "abgelehnt") {
await db
.from("verfuegbarkeit")
.delete()
.eq("anfrage_id", anfrageId);
}
}
export async function GET(req: NextRequest) {
const token = req.nextUrl.searchParams.get("token");
if (!token) {
return NextResponse.json(
{ error: "Token erforderlich" },
{ status: 400 }
);
}
// Token validieren
const actionToken = await verifyActionToken(token);
if (!actionToken) {
return NextResponse.json(
{ error: "Token ungültig oder abgelaufen" },
{ status: 400 }
);
}
const { anfrageId, status } = actionToken;
const appUrl = process.env.APP_URL ?? "https://www.mietparkhahn.de";
const ipAddr = req.headers.get("x-forwarded-for") || req.headers.get("x-real-ip") || "unknown";
// ✅ Token als verwendet markieren (One-Time-Use)
const [, tokenSig] = token.split(".");
await markActionTokenUsed(tokenSig, anfrageId, status, ipAddr);
try {
const db = createServiceClient();
// Aktuellen Status laden (vor dem Update) für Audit Log
const { data: currentData } = await db
.from("anfragen")
.select("status")
.eq("id", anfrageId)
.single();
const alterStatus = currentData?.status || null;
// Status aktualisieren
const { error } = await db
.from("anfragen")
.update({ status })
.eq("id", anfrageId);
if (error) {
console.error(`[Action] Fehler beim Update von Anfrage ${anfrageId}:`, error);
return NextResponse.json(
{ error: "Statusaktualisierung fehlgeschlagen" },
{ status: 500 }
);
}
// Status-Änderung ins Audit Log schreiben
if (alterStatus !== status) {
const { error: auditError } = await db
.from("anfrage_status_audit")
.insert({
anfrage_id: anfrageId,
status_von: alterStatus,
status_zu: status,
bearbeitet_von: "action-link",
notizen: null,
});
if (auditError) {
console.error(`[Action] Fehler beim Schreiben des Status-Audit-Logs für Anfrage ${anfrageId}:`, auditError);
}
}
// Verfügbarkeit aktualisieren
await aktualisiereVerfuegbarkeit(db, anfrageId, status);
// Anfrage-Daten + Positionen für E-Mail laden
const { data: anfrage } = await db
.from("anfragen")
.select("firma, email, notizen")
.eq("id", anfrageId)
.single();
if (anfrage?.email) {
let positionen: any[] = [];
// Positionen laden
const { data: posData, error: posError } = await db
.from("anfragen_positionen")
.select("*")
.eq("anfrage_id", anfrageId)
.order("mietbeginn");
if (posError) {
console.error(
`[Action] Fehler beim Laden von Positionen für Anfrage ${anfrageId}:`,
posError
);
}
// Positionen formatieren für Email-Template
positionen = (posData ?? []).map((p: any) => ({
maschineName: p.maschine_name || "Unbekannte Maschine",
mietbeginn: p.mietbeginn,
mietende: p.mietende,
gesamtTage: p.gesamt_tage || getKalenderTage(p.mietbeginn, p.mietende),
lieferung: p.lieferung || false,
lieferadresse: p.lieferadresse || "",
anmerkung: p.anmerkung || "",
tagessatz: p.tagessatz,
preisStufe: null,
zubehoer: [],
}));
console.log(
`[Action] Anfrage ${anfrageId}: ${positionen.length} Positionen geladen`
);
// Kunden-Status-Email versenden
sendeKundenStatusEmail({
anfrageId,
firma: anfrage.firma,
email: anfrage.email,
neuerStatus: status as "bestaetigt" | "abgelehnt" | "abgeschlossen",
notizen: anfrage.notizen || undefined,
positionen: positionen.length > 0 ? positionen : undefined,
}).catch((err) =>
console.error("[Action] Fehler beim Versand der Status-Email:", err)
);
}
// Redirect zum Admin-Panel mit Success-Indicator
return NextResponse.redirect(
`${appUrl}/admin/anfragen/${anfrageId}?action=done`
);
} catch (err) {
console.error("[Action] Unerwarteter Fehler:", err);
return NextResponse.json(
{ error: "Ein Fehler ist aufgetreten" },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,117 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { createServiceClient } from "@/lib/supabase";
import { createSessionToken, verifySessionToken } from "@/lib/admin-auth";
import { logLoginAttempt, getFailedLoginCount, sendSecurityAlert } from "@/lib/audit-log";
import { checkRateLimit, resetRateLimit } from "@/lib/rate-limit";
import { revokeSessionToken } from "@/lib/token-blacklist";
export async function POST(req: NextRequest) {
const { email, password } = await req.json();
const ipAddr = req.headers.get("x-forwarded-for") || req.headers.get("x-real-ip") || "unknown";
const userAgent = req.headers.get("user-agent") || "unknown";
// ──── Validierung ────
if (!email || !password) {
await logLoginAttempt("", ipAddr, false, userAgent, "missing_credentials");
return NextResponse.json({ error: "E-Mail und Passwort erforderlich" }, { status: 400 });
}
// ──── Rate-Limiting ────
const rateLimitKey = `login:${email.toLowerCase()}:${ipAddr}`;
const { allowed, delayMs, locked } = checkRateLimit(rateLimitKey);
if (locked) {
await logLoginAttempt(email, ipAddr, false, userAgent, "rate_limit_locked");
const res = NextResponse.json(
{ error: "Zu viele Anmeldeversuche. Bitte später versuchen." },
{ status: 429 }
);
res.headers.set("Retry-After", String(Math.ceil(delayMs / 1000)));
return res;
}
if (!allowed && delayMs > 0) {
// Künstliche Verzögerung um Timing-Attacken zu verhindern
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
// ──── Datenbankabfrage ────
const db = createServiceClient();
const { data: admin } = await db
.from("admin_users")
.select("id, email, name, password_hash, aktiv")
.eq("email", email.toLowerCase().trim())
.single();
// ──── Admin nicht gefunden oder inaktiv ────
if (!admin || !admin.aktiv) {
await logLoginAttempt(email, ipAddr, false, userAgent, "user_not_found_or_inactive");
// Prüfe auf verdächtige Aktivität
const failedCount = await getFailedLoginCount(email, "email", 60);
if (failedCount >= 10) {
await sendSecurityAlert(
"⚠️ Viele fehlgeschlagene Login-Versuche",
`Email: ${email}\nIPs: Siehe Audit-Logs\nVersuche in der letzten Stunde: ${failedCount}`
);
}
return NextResponse.json({ error: "Ungültige Zugangsdaten" }, { status: 401 });
}
// ──── Passwort validieren ────
const valid = await bcrypt.compare(password, admin.password_hash);
if (!valid) {
await logLoginAttempt(email, ipAddr, false, userAgent, "invalid_password");
// Prüfe auf Brute-Force-Aktivität
const failedCount = await getFailedLoginCount(email, "email", 60);
if (failedCount >= 10) {
await sendSecurityAlert(
"⚠️ Viele fehlgeschlagene Login-Versuche (falsches Passwort)",
`Email: ${email}\nIPs: ${ipAddr}\nVersuche in der letzten Stunde: ${failedCount}`
);
}
return NextResponse.json({ error: "Ungültige Zugangsdaten" }, { status: 401 });
}
// ──── Login erfolgreich ────
await logLoginAttempt(email, ipAddr, true, userAgent);
resetRateLimit(rateLimitKey); // Rate-Limit zurücksetzen
const token = await createSessionToken({ id: admin.id, email: admin.email, name: admin.name });
const res = NextResponse.json({ success: true });
res.cookies.set("admin_session", token, {
httpOnly: true,
sameSite: "lax",
maxAge: 60 * 60 * 2, // ✅ Geändert: 2 Stunden statt 7 Tage
path: "/",
secure: process.env.NODE_ENV === "production",
});
return res;
}
export async function DELETE(req: NextRequest) {
const token = req.cookies.get("admin_session")?.value;
if (token) {
try {
// ✅ Verify token to get admin_id, then add signature to blacklist
const session = await verifySessionToken(token);
if (session) {
const [, sig] = token.split(".");
await revokeSessionToken(sig, session.id, "logout");
}
} catch (error) {
console.error("Error revoking session token:", error);
// Continue with logout even if revocation fails
}
}
const res = NextResponse.json({ success: true });
res.cookies.delete("admin_session");
return res;
}

View File

@ -0,0 +1,66 @@
"use client";
import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
const INACTIVITY_TIMEOUT_MS = 2 * 60 * 60 * 1000; // 2 Stunden
const WARNING_BEFORE_LOGOUT_MS = 15 * 60 * 1000; // 15 Minuten vor Logout warnen
export function SessionTimeoutProvider({ children }: { children: React.ReactNode }) {
const router = useRouter();
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const warningTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const warningShownRef = useRef<boolean>(false);
const lastActivityRef = useRef<number>(Date.now());
function resetTimeout() {
// Alte Timer löschen
if (timeoutRef.current) clearTimeout(timeoutRef.current);
if (warningTimeoutRef.current) clearTimeout(warningTimeoutRef.current);
lastActivityRef.current = Date.now();
warningShownRef.current = false;
// Warnung nach (TIMEOUT - 15min)
warningTimeoutRef.current = setTimeout(() => {
if (!warningShownRef.current) {
warningShownRef.current = true;
alert(
"⚠️ Ihre Session läuft in 15 Minuten ab. " +
"Bitte klicken Sie auf eine Taste oder bewegen Sie die Maus, um weiterzuarbeiten."
);
}
}, INACTIVITY_TIMEOUT_MS - WARNING_BEFORE_LOGOUT_MS);
// Logout nach TIMEOUT
timeoutRef.current = setTimeout(async () => {
// Session auf dem Server beenden
await fetch("/api/admin/login", { method: "DELETE" });
// Redirect zur Login-Seite mit Hinweis
router.push("/admin/login?session_expired=true");
}, INACTIVITY_TIMEOUT_MS);
}
useEffect(() => {
const events = ["mousedown", "keydown", "scroll", "touchstart"];
function handleActivity() {
resetTimeout();
}
// Event-Listener registrieren
events.forEach((event) => window.addEventListener(event, handleActivity));
// Initial timeout setzen
resetTimeout();
// Cleanup
return () => {
events.forEach((event) => window.removeEventListener(event, handleActivity));
if (timeoutRef.current) clearTimeout(timeoutRef.current);
if (warningTimeoutRef.current) clearTimeout(warningTimeoutRef.current);
};
}, [router]);
return <>{children}</>;
}

View File

@ -0,0 +1,174 @@
// Web Crypto API kompatibel mit Edge Runtime + Node.js
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { isSessionTokenRevoked, isActionTokenUsed } from "./token-blacklist";
const encoder = new TextEncoder();
interface AdminSession {
id: string;
email: string;
name: string;
exp: number;
}
interface ActionToken {
anfrageId: string;
status: "bestaetigt" | "abgelehnt" | "abgeschlossen";
exp: number;
}
function toBase64Url(buffer: ArrayBuffer | Uint8Array): string {
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}
function fromBase64Url(str: string): ArrayBuffer {
const b64 = str.replace(/-/g, "+").replace(/_/g, "/");
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
return bytes.buffer as ArrayBuffer;
}
async function getKey(secret: string): Promise<CryptoKey> {
return crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign", "verify"]
);
}
export async function createSessionToken(
session: Omit<AdminSession, "exp">
): Promise<string> {
const secret = process.env.ADMIN_SESSION_TOKEN!;
// ✅ Token expiration: 2 Stunden (matches cookie maxAge in login route)
const payload: AdminSession = {
...session,
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 2,
};
const data = toBase64Url(encoder.encode(JSON.stringify(payload)));
const key = await getKey(secret);
const sig = toBase64Url(await crypto.subtle.sign("HMAC", key, encoder.encode(data)));
return `${data}.${sig}`;
}
/**
* Auth-Wrapper für Admin-API-Routes.
*
* Prüft den admin_session-Cookie, verifiziert das HMAC-Token und gibt die
* Session zurück ODER eine fertige 401-Response (wenn nicht authentifiziert).
*
* Verwendung am Anfang einer Admin-API-Route:
* const check = await requireAdmin();
* if (check instanceof NextResponse) return check;
* const session = check; // AdminSession
*/
export async function requireAdmin(): Promise<AdminSession | NextResponse> {
const cookieStore = await cookies();
const token = cookieStore.get("admin_session")?.value;
if (!token) {
return NextResponse.json(
{ error: "Nicht authentifiziert" },
{ status: 401 }
);
}
const session = await verifySessionToken(token);
if (!session) {
return NextResponse.json(
{ error: "Session ungültig" },
{ status: 401 }
);
}
return session;
}
export async function verifySessionToken(token: string): Promise<AdminSession | null> {
try {
const secret = process.env.ADMIN_SESSION_TOKEN!;
const [data, sig] = token.split(".");
if (!data || !sig) return null;
// ✅ Prüfe ob Token revoked wurde
if (await isSessionTokenRevoked(sig)) {
return null;
}
const key = await getKey(secret);
const valid = await crypto.subtle.verify(
"HMAC",
key,
fromBase64Url(sig),
encoder.encode(data)
);
if (!valid) return null;
const session: AdminSession = JSON.parse(
new TextDecoder().decode(new Uint8Array(fromBase64Url(data)))
);
if (session.exp < Math.floor(Date.now() / 1000)) return null;
return session;
} catch {
return null;
}
}
// ──── Action-Tokens für Email-Links (Anfrage bestätigen/ablehnen/abschließen) ────
export async function createActionToken(
anfrageId: string,
status: "bestaetigt" | "abgelehnt" | "abgeschlossen",
ablaufTage = 7
): Promise<string> {
const secret = process.env.ADMIN_SESSION_TOKEN!;
const payload: ActionToken = {
anfrageId,
status,
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * ablaufTage,
};
const data = toBase64Url(encoder.encode(JSON.stringify(payload)));
const key = await getKey(secret);
const sig = toBase64Url(await crypto.subtle.sign("HMAC", key, encoder.encode(data)));
return `${data}.${sig}`;
}
export async function verifyActionToken(
token: string
): Promise<{ anfrageId: string; status: string } | null> {
try {
const secret = process.env.ADMIN_SESSION_TOKEN!;
const [data, sig] = token.split(".");
if (!data || !sig) return null;
// ✅ Prüfe ob Token bereits verwendet wurde (One-Time-Use)
if (await isActionTokenUsed(sig)) {
return null;
}
const key = await getKey(secret);
const valid = await crypto.subtle.verify(
"HMAC",
key,
fromBase64Url(sig),
encoder.encode(data)
);
if (!valid) return null;
const actionToken: ActionToken = JSON.parse(
new TextDecoder().decode(new Uint8Array(fromBase64Url(data)))
);
if (actionToken.exp < Math.floor(Date.now() / 1000)) return null;
return {
anfrageId: actionToken.anfrageId,
status: actionToken.status,
};
} catch {
return null;
}
}

View File

@ -0,0 +1,154 @@
import { createServiceClient } from "@/lib/supabase";
export interface AuditLogEntry {
id: string;
email: string;
ip_addr: string;
user_agent: string;
success: boolean;
timestamp: string;
reason?: string; // z.B. "invalid_password", "user_not_found", "account_inactive"
}
/**
* Protokolliert einen Login-Versuch (erfolgreich oder fehlgeschlagen)
*/
export async function logLoginAttempt(
email: string,
ipAddr: string,
success: boolean,
userAgent: string,
reason?: string
): Promise<void> {
try {
const db = createServiceClient();
await db.from("admin_audit_logs").insert({
email: email.toLowerCase().trim(),
ip_addr: ipAddr,
user_agent: userAgent,
success,
reason,
timestamp: new Date().toISOString(),
});
} catch (error) {
console.error("Fehler beim Audit-Logging:", error);
// Nicht werfen Login sollte nicht scheitern wenn Logging scheitert
}
}
/**
* Prüft auf verdächtige Aktivitäten (zu viele fehlgeschlagene Versuche)
* Gibt die Anzahl fehlgeschlagener Versuche in der letzten Stunde zurück
*/
export async function getFailedLoginCount(
emailOrIp: string,
type: "email" | "ip" = "email",
timeWindowMinutes = 60
): Promise<number> {
try {
const db = createServiceClient();
const since = new Date(Date.now() - timeWindowMinutes * 60 * 1000).toISOString();
const column = type === "email" ? "email" : "ip_addr";
const { count } = await db
.from("admin_audit_logs")
.select("id", { count: "exact" })
.eq(column, emailOrIp)
.eq("success", false)
.gt("timestamp", since);
return count || 0;
} catch (error) {
console.error("Fehler beim Abrufen fehlgeschlagener Logins:", error);
return 0;
}
}
/**
* Holt die Audit-Log-Einträge mit optionalen Filtern
*/
export async function getAuditLogs(options?: {
email?: string;
ipAddr?: string;
successOnly?: boolean;
limit?: number;
offset?: number;
}): Promise<AuditLogEntry[]> {
try {
const db = createServiceClient();
const limit = options?.limit ?? 100;
const offset = options?.offset ?? 0;
let query = db
.from("admin_audit_logs")
.select("*")
.order("timestamp", { ascending: false })
.range(offset, offset + limit - 1);
if (options?.email) {
query = query.eq("email", options.email.toLowerCase().trim());
}
if (options?.ipAddr) {
query = query.eq("ip_addr", options.ipAddr);
}
if (options?.successOnly !== undefined) {
query = query.eq("success", options.successOnly);
}
const { data, error } = await query;
if (error) {
console.error("Fehler beim Abrufen von Audit-Logs:", error);
return [];
}
return data || [];
} catch (error) {
console.error("Fehler beim Abrufen von Audit-Logs:", error);
return [];
}
}
/**
* Löscht alte Audit-Logs (älter als X Tage)
* Sollte als Cron-Job regelmäßig ausgeführt werden
*/
export async function deleteOldAuditLogs(daysToKeep = 90): Promise<void> {
try {
const db = createServiceClient();
const cutoffDate = new Date(Date.now() - daysToKeep * 24 * 60 * 60 * 1000).toISOString();
await db
.from("admin_audit_logs")
.delete()
.lt("timestamp", cutoffDate);
console.log(`Audit-Logs älter als ${daysToKeep} Tage gelöscht.`);
} catch (error) {
console.error("Fehler beim Löschen alter Audit-Logs:", error);
}
}
/**
* Sendet eine Alert-Email bei verdächtiger Aktivität
* TODO: Implementieren wenn Email-System erweitert ist
*/
export async function sendSecurityAlert(
subject: string,
message: string,
adminEmail: string = process.env.SMTP_TO || ""
): Promise<void> {
if (!adminEmail) {
console.warn("SMTP_TO nicht konfiguriert keine Alert-Email gesendet");
return;
}
try {
// TODO: Nodemailer/Email-Integration wenn nötig
console.log(`⚠️ SECURITY ALERT: ${subject}\n${message}`);
} catch (error) {
console.error("Fehler beim Senden der Security-Alert:", error);
}
}

View File

@ -0,0 +1,107 @@
/**
* In-Memory Rate-Limiting für Brute-Force-Schutz
* Speichert Versuche pro Identifier (IP:Email Kombination)
*/
interface RateLimitEntry {
count: number;
lastAttempt: number;
locked: boolean;
lockedUntil?: number;
}
const attempts = new Map<string, RateLimitEntry>();
// Cleanup: Alte Einträge alle 10 Minuten löschen (Memory-Leak vermeiden)
const CLEANUP_INTERVAL = 10 * 60 * 1000;
const RESET_WINDOW = 15 * 60 * 1000; // 15 Minuten Fenster
const MAX_ATTEMPTS = 5; // Max 5 Versuche pro Fenster
const LOCK_THRESHOLD = 10; // Account sperren nach 10 Versuchen
const LOCK_DURATION = 15 * 60 * 1000; // 15 Minuten Sperrung
setInterval(() => {
const now = Date.now();
for (const [key, entry] of attempts.entries()) {
if (now - entry.lastAttempt > RESET_WINDOW) {
attempts.delete(key);
}
}
}, CLEANUP_INTERVAL);
/**
* Prüfe ob Request innerhalb von Rate-Limit liegt
* @param identifier - Eindeutige ID (z.B. "login:admin@example.com:192.168.1.1")
* @returns { allowed, delayMs, locked }
*/
export function checkRateLimit(identifier: string) {
const now = Date.now();
let entry = attempts.get(identifier);
// Neuer Eintrag
if (!entry) {
attempts.set(identifier, {
count: 1,
lastAttempt: now,
locked: false,
});
return { allowed: true, delayMs: 0, locked: false };
}
// Fenster abgelaufen? → Zurücksetzen
if (now - entry.lastAttempt > RESET_WINDOW) {
attempts.set(identifier, {
count: 1,
lastAttempt: now,
locked: false,
});
return { allowed: true, delayMs: 0, locked: false };
}
// Account gesperrt?
if (entry.locked && entry.lockedUntil) {
if (now < entry.lockedUntil) {
const remainingMs = entry.lockedUntil - now;
return { allowed: false, delayMs: remainingMs, locked: true };
} else {
// Sperrung abgelaufen
entry.locked = false;
entry.count = 1;
entry.lastAttempt = now;
return { allowed: true, delayMs: 0, locked: false };
}
}
// Versuch inkrementieren
entry.count++;
entry.lastAttempt = now;
// Exponential Backoff: 1s, 2s, 4s, 8s, 16s, 30s max
const delayMs = Math.min(Math.pow(2, entry.count - 2) * 1000, 30000);
// Nach 10 Versuchen sperren
if (entry.count >= LOCK_THRESHOLD) {
entry.locked = true;
entry.lockedUntil = now + LOCK_DURATION;
return { allowed: false, delayMs: LOCK_DURATION, locked: true };
}
// Vor dem 5. Versuch noch erlaubt, danach mit Delay
const allowed = entry.count <= MAX_ATTEMPTS;
return { allowed, delayMs: allowed ? 0 : delayMs, locked: false };
}
/**
* Manuelles Zurücksetzen (z.B. nach erfolgreichem Login)
*/
export function resetRateLimit(identifier: string) {
attempts.delete(identifier);
}
/**
* Hole Versuchszahl für einen Identifier (für Monitoring)
*/
export function getAttemptCount(identifier: string): number {
const entry = attempts.get(identifier);
return entry?.count ?? 0;
}

View File

@ -0,0 +1,115 @@
/**
* Token-Revocation & Blacklist Management
* Verhindert Wiederverwendung von:
* 1. Session-Tokens nach Logout
* 2. Action-Tokens nach einmaliger Verwendung
*/
import { createServiceClient } from "./supabase";
/**
* Revoke a session token by adding its signature to the blacklist
*/
export async function revokeSessionToken(
tokenSignature: string,
adminId: string,
reason: "logout" | "password_changed" | "suspicious_activity" = "logout",
notes?: string
): Promise<boolean> {
try {
const db = createServiceClient();
const { error } = await db.from("admin_session_blacklist").insert({
admin_id: adminId,
token_signature: tokenSignature,
reason,
notes,
});
if (error) {
console.error("Failed to revoke session token:", error);
return false;
}
return true;
} catch (error) {
console.error("Error revoking session token:", error);
return false;
}
}
/**
* Check if a session token signature is revoked
*/
export async function isSessionTokenRevoked(tokenSignature: string): Promise<boolean> {
try {
const db = createServiceClient();
const { data, error } = await db
.from("admin_session_blacklist")
.select("id", { count: "exact" })
.eq("token_signature", tokenSignature)
.single();
if (error && error.code !== "PGRST116") {
// PGRST116 = no rows found, which is expected
console.error("Error checking token revocation:", error);
return false;
}
return data?.id !== undefined && data?.id !== null;
} catch (error) {
console.error("Error checking token revocation:", error);
return false;
}
}
/**
* Mark an action token as used (one-time use)
*/
export async function markActionTokenUsed(
tokenSignature: string,
anfrageId: string,
actionType: string,
ipAddr?: string
): Promise<boolean> {
try {
const db = createServiceClient();
const { error } = await db.from("action_token_blacklist").insert({
token_signature: tokenSignature,
anfrage_id: anfrageId,
action_type: actionType,
used_by_ip: ipAddr,
});
if (error) {
console.error("Failed to mark action token as used:", error);
return false;
}
return true;
} catch (error) {
console.error("Error marking action token as used:", error);
return false;
}
}
/**
* Check if an action token has already been used
*/
export async function isActionTokenUsed(tokenSignature: string): Promise<boolean> {
try {
const db = createServiceClient();
const { data, error } = await db
.from("action_token_blacklist")
.select("id", { count: "exact" })
.eq("token_signature", tokenSignature)
.single();
if (error && error.code !== "PGRST116") {
console.error("Error checking action token usage:", error);
return false;
}
return data?.id !== undefined && data?.id !== null;
} catch (error) {
console.error("Error checking action token usage:", error);
return false;
}
}

View File

@ -0,0 +1,31 @@
-- Migration: Erstelle admin_audit_logs Tabelle für Login-Sicherheit
-- Datum: 2026-04-17
-- Zweck: Protokollierung aller Admin-Login-Versuche (erfolgreich und fehlgeschlagen)
-- Tabelle erstellen
CREATE TABLE IF NOT EXISTS admin_audit_logs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text NOT NULL,
ip_addr text NOT NULL,
user_agent text NOT NULL,
success boolean NOT NULL,
reason text, -- z.B. "invalid_password", "user_not_found_or_inactive", "missing_credentials"
timestamp timestamptz DEFAULT now()
);
-- Indexes für Performance
CREATE INDEX IF NOT EXISTS idx_audit_logs_email ON admin_audit_logs(email);
CREATE INDEX IF NOT EXISTS idx_audit_logs_ip ON admin_audit_logs(ip_addr);
CREATE INDEX IF NOT EXISTS idx_audit_logs_success ON admin_audit_logs(success);
CREATE INDEX IF NOT EXISTS idx_audit_logs_timestamp ON admin_audit_logs(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_audit_logs_email_timestamp ON admin_audit_logs(email, timestamp DESC);
-- RLS (Row Level Security) - nur Admins können Logs anschauen
ALTER TABLE admin_audit_logs ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Admin können ihre eigenen Logs sehen" ON admin_audit_logs
FOR SELECT
USING (true); -- TODO: Später auf Admin-Session prüfen
-- Automatische Cleanup: Alte Logs nach 90 Tagen löschen (optional)
-- Dies kann auch manuell als Cron-Job eingerichtet werden via supabase/functions

View File

@ -0,0 +1,66 @@
-- Migration: Admin Session Token Blacklist
-- Erlaubt es, Session-Tokens vor Ablauf ungültig zu machen
CREATE TABLE admin_session_blacklist (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
admin_id uuid NOT NULL,
token_signature text NOT NULL UNIQUE, -- Base64-kodierte Signatur
revoked_at timestamptz DEFAULT now(),
reason text NOT NULL, -- "logout", "password_changed", "suspicious_activity"
notes text
);
CREATE INDEX idx_admin_session_blacklist_sig ON admin_session_blacklist(token_signature);
CREATE INDEX idx_admin_session_blacklist_admin ON admin_session_blacklist(admin_id);
CREATE INDEX idx_admin_session_blacklist_revoked ON admin_session_blacklist(revoked_at DESC);
-- Cleanup: Alte Einträge nach 7 Tagen (nach Token-Ablauf) löschen
CREATE OR REPLACE FUNCTION cleanup_old_blacklist_tokens() RETURNS void AS $$
BEGIN
DELETE FROM admin_session_blacklist
WHERE revoked_at < now() - INTERVAL '7 days';
END;
$$ LANGUAGE plpgsql;
-- Trigger: Auto-Cleanup einmal täglich (optional)
-- HINWEIS: In Supabase muss dies manuell via Cron-Funktion aufgerufen werden
---
-- Migration: Action Token Blacklist
-- Verhindert mehrfache Verwendung von Email-Action-Links (Status-Updates)
CREATE TABLE action_token_blacklist (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
anfrage_id uuid NOT NULL,
token_signature text NOT NULL UNIQUE, -- Base64-kodierte Signatur
action_type text NOT NULL, -- "bestaetigt", "abgelehnt", "abgeschlossen"
used_at timestamptz DEFAULT now(),
used_by_ip text,
notes text
);
CREATE INDEX idx_action_token_blacklist_sig ON action_token_blacklist(token_signature);
CREATE INDEX idx_action_token_blacklist_anfrage ON action_token_blacklist(anfrage_id);
CREATE INDEX idx_action_token_blacklist_used ON action_token_blacklist(used_at DESC);
-- Cleanup: Alte Einträge nach 14 Tagen löschen (nach Token-Ablauf)
CREATE OR REPLACE FUNCTION cleanup_old_action_tokens() RETURNS void AS $$
BEGIN
DELETE FROM action_token_blacklist
WHERE used_at < now() - INTERVAL '14 days';
END;
$$ LANGUAGE plpgsql;
---
-- RLS Policies
ALTER TABLE admin_session_blacklist ENABLE ROW LEVEL SECURITY;
ALTER TABLE action_token_blacklist ENABLE ROW LEVEL SECURITY;
-- Nur Service-Role kann schreiben
CREATE POLICY "Service Role can manage session blacklist" ON admin_session_blacklist
FOR ALL USING (true) WITH CHECK (false);
CREATE POLICY "Service Role can manage action token blacklist" ON action_token_blacklist
FOR ALL USING (true) WITH CHECK (false);

View File

@ -0,0 +1,181 @@
# Modul: Analytics (Seitenaufrufe + Phone-Click-Tracking)
> DSGVO-konformes Tracking ohne externe Dienste: Seitenaufrufe mit Verweildauer, Browser/OS/Gerät-Erkennung, anonymisierte IP (letztes IPv4-Oktett → 0). Zusätzlich automatisches Tracking aller `tel:`-Link-Klicks nach Quelle/Element. Admin-Dashboard mit KPI-Cards, Zeitreihen und Top-Seiten.
---
## Enthaltene Dateien
| Ziel im neuen Projekt | Inhalt |
|---|---|
| `lib/analytics.ts` | Bot-Filter, IP-Anonymisierung, Device/Browser/OS-Parser |
| `components/analytics/PageTracker.tsx` | Client-Komponente: Seitenaufruf + Verweildauer + Phone-Click-Tracking |
| `app/api/analytics/track/route.ts` | POST: Seitenaufruf speichern (öffentlich, kein Auth) |
| `app/api/analytics/track-phone-click/route.ts` | POST: Telefon-Click speichern (öffentlich) |
| `app/api/admin/analytics/phone-calls/route.ts` | GET: Aggregierte Phone-Click-Daten (requireAdmin) |
| `app/admin/analytics/page.tsx` | Admin-Dashboard mit Tabs: Seitenaufrufe + Phone-Calls |
| `migrations/MIGRATIONS_PAGE_VIEWS.sql` | Tabelle `page_views` |
| `migrations/MIGRATIONS_PHONE_CLICKS.sql` | Tabelle `phone_clicks` |
---
## Voraussetzungen
Keine zusätzlichen npm-Pakete. Benötigt:
- `lib/supabase.ts` (Service Client)
- `lib/admin-auth.ts` (für `requireAdmin` im Admin-Endpunkt, aus Modul 02)
---
## Umgebungsvariablen
Keine zusätzlichen. Nutzt bestehende Supabase-Variablen.
---
## Datenbank-Migrationen (Supabase)
```
migrations/MIGRATIONS_PAGE_VIEWS.sql → Tabelle page_views
migrations/MIGRATIONS_PHONE_CLICKS.sql → Tabelle phone_clicks
```
Tabellen-Übersicht:
```
page_views: path, timestamp, ip_anon, device_type, browser, os, referrer, session_id, duration_ms, is_bot
phone_clicks: phone_number, source_page, source_element, session_id, ip_anonymized, device_type, browser, os, timestamp
```
Supabase-Typen (`lib/supabase.ts`) ergänzen:
```ts
page_views: { Row: { id: string; path: string; timestamp: string; ip_anon: string | null; device_type: string | null; browser: string | null; os: string | null; referrer: string | null; session_id: string; duration_ms: number | null; is_bot: boolean } }
phone_clicks: { Row: { id: number; phone_number: string; source_page: string; source_element: string; session_id: string | null; ip_anonymized: string | null; device_type: string | null; browser: string | null; os: string | null; timestamp: string } }
```
---
## Einbindung Schritt für Schritt
### 1. Dateien kopieren
### 2. PageTracker in Root-Layout einbinden (`app/layout.tsx`)
```tsx
import { PageTracker } from "@/components/analytics/PageTracker";
export default function RootLayout({ children }) {
return (
<html>
<body>
<PageTracker />
{children}
</body>
</html>
);
}
```
### 3. Tel-Links mit `data-source-element` versehen
Der PageTracker hört automatisch auf Klicks von `<a href="tel:...">`. Damit die Quelle erfasst wird, muss das Attribut gesetzt sein:
```tsx
<a href="tel:+49123456789" data-source-element="header">Anrufen</a>
```
Empfohlene Element-Namen (Konvention: lowercase, kebab-case):
- `header`, `footer`, `hero`, `cta-banner`, `kontakt-form`, `sidebar`
### 4. Admin-Dashboard verlinken
```tsx
// In Admin-Navigation
<Link href="/admin/analytics">Analytics</Link>
```
### 5. Admin-Routen aus Tracking ausschließen
`PageTracker.tsx` filtert bereits `/admin/*` und `/api/*` keine Anpassung nötig.
---
## Anpassungspunkte
| Was | Wo |
|---|---|
| Ausgeschlossene Pfade | `components/analytics/PageTracker.tsx``EXCLUDED_PATHS` |
| Datenaufbewahrung (13 Monate) | Supabase Cron: `DELETE FROM page_views WHERE timestamp < now() - interval '13 months'` |
| Bot-Filter-Pattern | `lib/analytics.ts``BOT_PATTERNS` Array |
| Dashboard-Zeiträume | `app/admin/analytics/page.tsx` → Filter-Buttons |
| Geo-Lookup aktivieren | `app/api/analytics/track-phone-click/route.ts``GEO_LOOKUP_ENABLED` env var |
---
## Integrations-Prompt
Kopiere diesen Prompt in eine neue KI-Konversation, nachdem du die `files/` in dein Projekt kopiert hast. Ersetze alle `[PLATZHALTER]`.
```
Ich integriere das Analytics-Modul (DSGVO-konforme Seitenaufrufe + Phone-Click-Tracking) in mein Next.js/Supabase-Projekt.
PROJEKT-KONTEXT:
- Admin-Auth-Modul (02) ist bereits integriert (requireAdmin verfügbar)
- lib/supabase.ts mit Service Client vorhanden
- Admin-Bereich unter /admin
BEREITS KOPIERTE DATEIEN (aus modules/03-analytics/files/):
- lib/analytics.ts
- components/analytics/PageTracker.tsx
- app/api/analytics/track/route.ts
- app/api/analytics/track-phone-click/route.ts
- app/api/admin/analytics/phone-calls/route.ts
- app/admin/analytics/page.tsx
AUFGABEN führe sie der Reihe nach aus:
1. SUPABASE-MIGRATIONEN: Führe diese SQLs im Supabase SQL-Editor aus:
-- Aus migrations/MIGRATIONS_PAGE_VIEWS.sql:
CREATE TABLE IF NOT EXISTS page_views (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
path text NOT NULL, timestamp timestamptz NOT NULL DEFAULT now(),
ip_anon text, device_type text CHECK (device_type IN ('desktop','tablet','mobile')),
browser text, os text, referrer text,
session_id text NOT NULL, duration_ms int, is_bot boolean NOT NULL DEFAULT false
);
CREATE INDEX IF NOT EXISTS idx_pv_timestamp ON page_views (timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_pv_path ON page_views (path);
CREATE INDEX IF NOT EXISTS idx_pv_session ON page_views (session_id);
ALTER TABLE page_views ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Service-Role Vollzugriff" ON page_views USING (true) WITH CHECK (true);
-- Aus migrations/MIGRATIONS_PHONE_CLICKS.sql:
CREATE TABLE IF NOT EXISTS phone_clicks (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
phone_number TEXT NOT NULL, source_page TEXT NOT NULL,
source_element TEXT NOT NULL, session_id TEXT, ip_anonymized TEXT,
device_type TEXT, browser TEXT, os TEXT, timestamp TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_phone_clicks_timestamp ON phone_clicks(timestamp DESC);
ALTER TABLE phone_clicks DISABLE ROW LEVEL SECURITY;
2. SUPABASE-TYPEN: Lies lib/supabase.ts und ergänze in Database.public.Tables:
page_views: { Row: { id: string; path: string; timestamp: string; ip_anon: string | null; device_type: string | null; browser: string | null; os: string | null; referrer: string | null; session_id: string; duration_ms: number | null; is_bot: boolean } }
phone_clicks: { Row: { id: number; phone_number: string; source_page: string; source_element: string; session_id: string | null; ip_anonymized: string | null; device_type: string | null; browser: string | null; os: string | null; timestamp: string } }
3. PAGE-TRACKER einbinden: Lies app/layout.tsx.
Füge den Import und die Komponente im <body> hinzu (vor {children}):
import { PageTracker } from "@/components/analytics/PageTracker";
// Im JSX:
<PageTracker />
4. TEL-LINKS mit Tracking versehen: Suche im gesamten Projekt nach href="tel:
Füge bei jedem gefundenen Link das Attribut data-source-element="[ELEMENT_NAME]" hinzu.
Konvention für [ELEMENT_NAME]: header, footer, hero, cta-banner, kontakt-form (lowercase, kebab-case)
5. ADMIN-NAV ergänzen: Lies die Admin-Navigations-Datei (meist components/admin/AdminNav.tsx).
Füge einen Link zu /admin/analytics hinzu.
6. TEST:
a) Dev-Server starten, /kontakt oder eine andere Seite aufrufen
b) Supabase → Table page_views → neuer Eintrag sollte erscheinen
c) Auf eine Telefonnummer klicken → Supabase → Table phone_clicks → neuer Eintrag
d) /admin/analytics aufrufen → KPI-Cards sollten Daten zeigen
Lies jede Datei vor dem Bearbeiten. Melde wenn alle Schritte abgeschlossen sind.
```

View File

@ -0,0 +1,494 @@
import { Suspense } from "react";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { verifySessionToken } from "@/lib/admin-auth";
import { createServiceClient } from "@/lib/supabase";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import type { Metadata } from "next";
import { AnalyticsTabs } from "@/components/admin/AnalyticsTabs";
export const metadata: Metadata = { title: "Web-Analytics" };
export const dynamic = "force-dynamic";
interface PageView {
id: string;
path: string;
timestamp: string;
device_type: string | null;
browser: string | null;
os: string | null;
session_id: string;
duration_ms: number | null;
}
interface FilterOption {
label: string;
tage: number;
beschreibung: string;
}
const FILTER_OPTIONS: FilterOption[] = [
{ label: "Heute", tage: 1, beschreibung: "letzter Tag" },
{ label: "Woche", tage: 7, beschreibung: "letzte 7 Tage" },
{ label: "Monat", tage: 30, beschreibung: "letzte 30 Tage" },
{ label: "3 Monate", tage: 90, beschreibung: "letzte 90 Tage" },
{ label: "Jahr", tage: 365, beschreibung: "letztes Jahr" },
];
// Hilfsfunktionen (aus statistik/page.tsx übernommen)
function fmt(n: number, nachkomma = 0) {
return n.toLocaleString("de-DE", {
minimumFractionDigits: nachkomma,
maximumFractionDigits: nachkomma,
});
}
function balkenBreite(wert: number, max: number): string {
if (max === 0) return "0%";
return `${Math.max(4, Math.round((wert / max) * 100))}%`;
}
function fmtDuration(ms: number | null): string {
if (!ms) return "";
if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
return `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`;
}
// Hilfsfunktion: Deutsches Datumsformat (DD.MM.YYYY)
function fmtDateDE(isoDate: string): string {
const [year, month, day] = isoDate.split("-");
return `${day}.${month}.${year}`;
}
async function AnalyticsContent({
vonISO,
bisISO,
tage = null
}: {
vonISO: string;
bisISO: string;
tage?: number | null;
}) {
// ──── Auth Check ────────────────────────────────────────────────────────
const cookieStore = await cookies();
const token = cookieStore.get("admin_session")?.value;
if (!token) {
redirect("/admin/login");
}
const session = await verifySessionToken(token);
if (!session) {
redirect("/admin/login");
}
// ──── Daten abrufen (dynamischer Zeitraum, ohne Bots) ──────────────────────────
const db = createServiceClient();
const heute = new Date().toISOString().slice(0, 10);
// Konvertiere ISO-Dates zu vollständigen Timestamps für korrekte DB-Filterung
const vonTimestamp = vonISO + "T00:00:00Z";
const bisTimestamp = bisISO + "T23:59:59.999Z";
const { data: allViews, error } = await db
.from("page_views")
.select("id, path, timestamp, device_type, browser, os, session_id, duration_ms")
.eq("is_bot", false)
.gte("timestamp", vonTimestamp)
.lte("timestamp", bisTimestamp)
.order("timestamp", { ascending: false });
if (error) {
console.error("❌ Analytics Query Error:", error);
}
const views: PageView[] = allViews ?? [];
// ──── KPIs ──────────────────────────────────────────────────────────────
const heuteViews = views.filter((v) => v.timestamp.startsWith(heute));
const uniqueSessionsHeute = new Set(heuteViews.map((v) => v.session_id)).size;
const mitDauer = views.filter((v) => v.duration_ms != null && v.duration_ms > 0);
const avgDuration =
mitDauer.length > 0 ? Math.round(mitDauer.reduce((s, v) => s + v.duration_ms!, 0) / mitDauer.length) : 0;
const mobileCount = views.filter((v) => v.device_type === "mobile").length;
const mobileRate = views.length > 0 ? Math.round((mobileCount / views.length) * 100) : 0;
// ──── Tagesstatistik (letzte 30 Tage) ────────────────────────────────────
const tagesMap: Record<string, number> = {};
for (const v of views) {
const tag = v.timestamp.slice(0, 10);
tagesMap[tag] = (tagesMap[tag] ?? 0) + 1;
}
const tagesStats = Object.entries(tagesMap)
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, count]) => ({ date, count }));
const maxTagesWert = Math.max(0, ...tagesStats.map((s) => s.count));
// ──── Top-Seiten ────────────────────────────────────────────────────────
const pathMap: Record<string, { count: number; durSum: number; durCount: number }> = {};
for (const v of views) {
if (!pathMap[v.path]) pathMap[v.path] = { count: 0, durSum: 0, durCount: 0 };
pathMap[v.path].count++;
if (v.duration_ms) {
pathMap[v.path].durSum += v.duration_ms;
pathMap[v.path].durCount++;
}
}
const topSeiten = Object.entries(pathMap)
.map(([path, d]) => ({
path,
count: d.count,
avgDuration: d.durCount > 0 ? Math.round(d.durSum / d.durCount) : null,
}))
.sort((a, b) => b.count - a.count)
.slice(0, 20);
const maxPathCount = Math.max(0, ...topSeiten.map((s) => s.count));
// ──── Browser-Verteilung ────────────────────────────────────────────────
const browserMap: Record<string, number> = {};
for (const v of views) browserMap[v.browser ?? "Other"] = (browserMap[v.browser ?? "Other"] ?? 0) + 1;
const sortedBrowsers = Object.entries(browserMap)
.sort((a, b) => b[1] - a[1])
.slice(0, 8);
// ──── Gerät-Verteilung ──────────────────────────────────────────────────
const deviceMap: Record<string, number> = {};
for (const v of views) deviceMap[v.device_type ?? "desktop"] = (deviceMap[v.device_type ?? "desktop"] ?? 0) + 1;
// ──── OS-Verteilung ─────────────────────────────────────────────────────
const osMap: Record<string, number> = {};
for (const v of views) osMap[v.os ?? "Other"] = (osMap[v.os ?? "Other"] ?? 0) + 1;
const sortedOs = Object.entries(osMap)
.sort((a, b) => b[1] - a[1])
.slice(0, 8);
// ──── Filter-Button Label bestimmen ──────────────────────────────────
let filterDesc: string;
if (tage !== null) {
// Schnellauswahl
filterDesc = FILTER_OPTIONS.find((f) => f.tage === tage)?.beschreibung || `letzte ${tage} Tage`;
} else {
// Freier Zeitraum (deutsches Datumsformat)
filterDesc = `${fmtDateDE(vonISO)} ${fmtDateDE(bisISO)}`;
}
// Debug: Wenn Fehler oder keine Daten
if (error) {
return (
<div className="space-y-6">
<div className="bg-gradient-to-r from-[#1c1917] to-[#2d2b29] text-white py-8 px-6 rounded-lg">
<h1 className="text-2xl font-bold tracking-tight">Web-Analytics</h1>
</div>
<div className="bg-red-50 border border-red-300 p-4 rounded-lg">
<p className="text-red-800 font-semibold"> Fehler beim Laden der Daten:</p>
<p className="text-red-700 text-sm mt-2">{error.message}</p>
<p className="text-red-600 text-xs mt-3">
Prüfe in Supabase, ob die <code className="bg-red-100 px-1 rounded">page_views</code> Tabelle existiert und RLS korrekt konfiguriert ist.
</p>
</div>
</div>
);
}
const overviewContent = (
<div className="space-y-6">
{/* Header */}
<div className="bg-gradient-to-r from-[#1c1917] to-[#2d2b29] text-white py-8 px-6 rounded-lg">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Web-Analytics</h1>
<p className="text-white/70 text-sm mt-1">{filterDesc} · Bots gefiltert · IPs anonymisiert</p>
</div>
<Link
href="/admin"
className="inline-flex items-center gap-2 text-white/60 hover:text-white transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Zurück
</Link>
</div>
{/* Filter Buttons & Custom Date Range */}
<div className="space-y-3">
{/* Schnellauswahl-Buttons */}
<div className="flex flex-wrap gap-2">
{FILTER_OPTIONS.map((option) => (
<Link
key={option.tage}
href={`/admin/analytics?tage=${option.tage}`}
className={`px-3 py-1 rounded text-sm font-medium transition-all ${
tage === option.tage && tage !== null
? "bg-[#f7d334] text-[#1c1917] shadow-md"
: "bg-white/15 text-white hover:bg-white/25"
}`}
>
{option.label}
</Link>
))}
</div>
{/* Datumsauswahl-Formular */}
<form method="GET" action="/admin/analytics" className="flex flex-col sm:flex-row gap-2 items-end bg-white/10 p-3 rounded">
<div className="flex-1 min-w-0">
<label htmlFor="von" className="block text-xs font-medium text-white mb-1">
Von
</label>
<input
id="von"
type="date"
name="von"
defaultValue={vonISO}
className="w-full px-3 py-2 rounded text-sm text-slate-900 bg-white border-2 border-white font-medium"
/>
</div>
<div className="flex-1 min-w-0">
<label htmlFor="bis" className="block text-xs font-medium text-white mb-1">
Bis
</label>
<input
id="bis"
type="date"
name="bis"
defaultValue={bisISO}
className="w-full px-3 py-2 rounded text-sm text-slate-900 bg-white border-2 border-white font-medium"
/>
</div>
<button
type="submit"
className="px-4 py-2 bg-[#f7d334] text-[#1c1917] text-sm font-bold rounded hover:bg-[#d4a030] transition-all whitespace-nowrap"
>
Anwenden
</button>
</form>
</div>
</div>
{/* KPI-Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-white border border-slate-200 p-4 rounded-lg">
<div className="text-sm text-slate-500">Seitenaufrufe heute</div>
<div className="text-2xl font-bold text-slate-900">{heuteViews.length}</div>
</div>
<div className="bg-white border border-slate-200 p-4 rounded-lg">
<div className="text-sm text-slate-500">Unique Sessions heute</div>
<div className="text-2xl font-bold text-slate-900">{uniqueSessionsHeute}</div>
</div>
<div className="bg-white border border-slate-200 p-4 rounded-lg">
<div className="text-sm text-slate-500">Ø Verweildauer</div>
<div className="text-2xl font-bold text-slate-900">{fmtDuration(avgDuration)}</div>
</div>
<div className="bg-white border border-slate-200 p-4 rounded-lg">
<div className="text-sm text-slate-500">Mobile-Anteil</div>
<div className="text-2xl font-bold text-slate-900">{mobileRate}%</div>
</div>
</div>
{/* Tagesdiagramm */}
<div className="bg-white border border-slate-200 rounded-lg p-6">
<h2 className="text-lg font-bold text-slate-900 mb-4">Seitenaufrufe ({filterDesc})</h2>
<div className="space-y-2">
{tagesStats.length === 0 ? (
<p className="text-slate-500 text-sm">Keine Daten vorhanden</p>
) : (
tagesStats.map(({ date, count }) => (
<div key={date} className="flex items-center gap-3">
<div className="w-24 text-sm font-mono text-slate-600">{date}</div>
<div className="flex-1 h-8 bg-slate-100 rounded flex items-center justify-start overflow-hidden">
<div
className="h-full bg-[#f7d334] transition-all duration-300"
style={{ width: balkenBreite(count, maxTagesWert) }}
/>
</div>
<div className="w-12 text-right text-sm font-semibold text-slate-900">{count}</div>
</div>
))
)}
</div>
</div>
{/* Top-Seiten */}
<div className="bg-white border border-slate-200 rounded-lg overflow-hidden">
<div className="p-6 border-b border-slate-200">
<h2 className="text-lg font-bold text-slate-900">Top-Seiten</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="px-6 py-3 text-left font-semibold text-slate-700">Seite</th>
<th className="px-6 py-3 text-left font-semibold text-slate-700">Aufrufe</th>
<th className="px-6 py-3 text-left font-semibold text-slate-700">Ø Verweildauer</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{topSeiten.length === 0 ? (
<tr>
<td colSpan={3} className="px-6 py-8 text-center text-slate-500">
Keine Daten vorhanden
</td>
</tr>
) : (
topSeiten.map(({ path, count, avgDuration }) => (
<tr key={path} className="hover:bg-slate-50 transition-colors">
<td className="px-6 py-3 font-mono text-slate-900 max-w-sm truncate">{path}</td>
<td className="px-6 py-3">
<div className="flex items-center gap-3">
<div className="w-32 h-6 bg-slate-100 rounded flex items-center overflow-hidden">
<div
className="h-full bg-[#f7d334]"
style={{ width: balkenBreite(count, maxPathCount) }}
/>
</div>
<span className="font-semibold text-slate-900 w-8">{count}</span>
</div>
</td>
<td className="px-6 py-3 text-slate-600">{fmtDuration(avgDuration)}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Browser & OS Verteilung */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Browser */}
<div className="bg-white border border-slate-200 rounded-lg p-6">
<h2 className="text-lg font-bold text-slate-900 mb-4">Browser</h2>
<div className="space-y-3">
{sortedBrowsers.length === 0 ? (
<p className="text-slate-500 text-sm">Keine Daten vorhanden</p>
) : (
sortedBrowsers.map(([browser, count]) => (
<div key={browser} className="flex items-center gap-3">
<div className="w-24 text-sm font-medium text-slate-700">{browser}</div>
<div className="flex-1 h-6 bg-slate-100 rounded flex items-center overflow-hidden">
<div
className="h-full bg-[#f7d334]"
style={{
width: balkenBreite(count, Math.max(...sortedBrowsers.map((s) => s[1]))),
}}
/>
</div>
<div className="w-8 text-right text-sm font-semibold text-slate-900">{count}</div>
</div>
))
)}
</div>
</div>
{/* OS */}
<div className="bg-white border border-slate-200 rounded-lg p-6">
<h2 className="text-lg font-bold text-slate-900 mb-4">Betriebssystem</h2>
<div className="space-y-3">
{sortedOs.length === 0 ? (
<p className="text-slate-500 text-sm">Keine Daten vorhanden</p>
) : (
sortedOs.map(([os, count]) => (
<div key={os} className="flex items-center gap-3">
<div className="w-24 text-sm font-medium text-slate-700">{os}</div>
<div className="flex-1 h-6 bg-slate-100 rounded flex items-center overflow-hidden">
<div
className="h-full bg-[#f7d334]"
style={{
width: balkenBreite(count, Math.max(...sortedOs.map((s) => s[1]))),
}}
/>
</div>
<div className="w-8 text-right text-sm font-semibold text-slate-900">{count}</div>
</div>
))
)}
</div>
</div>
</div>
{/* Gerät-Verteilung */}
<div className="bg-white border border-slate-200 rounded-lg p-6">
<h2 className="text-lg font-bold text-slate-900 mb-4">Geräte-Verteilung</h2>
<div className="grid grid-cols-3 gap-4">
{Object.entries(deviceMap).map(([device, count]) => (
<div key={device} className="text-center">
<div className="text-2xl font-bold text-slate-900">{count}</div>
<div className="text-sm text-slate-600 capitalize">
{device === "desktop" ? "Desktop" : device === "tablet" ? "Tablet" : "Mobile"}
</div>
<div className="text-xs text-slate-500 mt-1">
{views.length > 0 ? `${Math.round((count / views.length) * 100)}%` : "0%"}
</div>
</div>
))}
</div>
</div>
{/* DSGVO-Hinweis */}
<div className="bg-blue-50 border border-blue-200 p-4 rounded-lg">
<p className="text-sm text-blue-800">
<strong>🔒 Datenschutz:</strong> IP-Adressen werden anonymisiert (letztes Oktett = 0). Es werden keine
Cookies für Analytics gesetzt. Session-IDs sind Tab-gebunden und werden nicht persistent gespeichert.
Weitere Details in der{" "}
<Link href="/datenschutz" className="underline hover:no-underline">
Datenschutzerklärung
</Link>
.
</p>
</div>
</div>
);
return <AnalyticsTabs overviewContent={overviewContent} />;
}
export default async function AnalyticsPage({ searchParams }: { searchParams: Promise<Record<string, string | string[]>> }) {
const params = await searchParams;
const von = String(params.von || "");
const bis = String(params.bis || "");
const tageParam = String(params.tage || "");
const heute = new Date().toISOString().slice(0, 10);
let vonISO: string;
let bisISO: string;
let tage: number | null;
// Hilfsfunktion: Datum um X Tage zurück (ohne Zeitzone-Probleme)
function datumMinusTage(isoDate: string, tage: number): string {
const [year, month, day] = isoDate.split("-").map(Number);
const date = new Date(year, month - 1, day);
date.setDate(date.getDate() - tage);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
// Berechne den Zeitraum basierend auf Parametern
if (von && bis) {
// Freier Datumsbereich
vonISO = von;
bisISO = bis;
tage = null; // null bedeutet: freier Bereich, nicht Schnellauswahl
} else if (tageParam) {
// Schnellauswahl: von heute rückwärts
tage = Math.max(1, Math.min(365, parseInt(tageParam, 10) || 30));
bisISO = heute;
vonISO = datumMinusTage(heute, tage);
} else {
// Standard: 30 Tage von heute rückwärts
tage = 30;
bisISO = heute;
vonISO = datumMinusTage(heute, 30);
}
return (
<div className="max-w-7xl mx-auto px-4 py-8">
<Suspense
fallback={
<div className="text-center py-12">
<p className="text-slate-500">Lädt Analytics-Daten...</p>
</div>
}
>
<AnalyticsContent vonISO={vonISO} bisISO={bisISO} tage={tage} />
</Suspense>
</div>
);
}

View File

@ -0,0 +1,240 @@
import { NextResponse, NextRequest } from "next/server";
import { createServiceClient } from "@/lib/supabase";
import { requireAdmin } from "@/lib/admin-auth";
export const dynamic = "force-dynamic";
type DateRange = "today" | "7days" | "30days";
function getDateRange(range: DateRange): { start: string; end: string } {
const end = new Date();
const start = new Date();
switch (range) {
case "today":
start.setHours(0, 0, 0, 0);
break;
case "7days":
start.setDate(start.getDate() - 7);
break;
case "30days":
start.setDate(start.getDate() - 30);
break;
}
return {
start: start.toISOString(),
end: end.toISOString(),
};
}
export async function GET(request: NextRequest) {
// Admin Check
const check = await requireAdmin();
if (check instanceof NextResponse) return check;
const searchParams = request.nextUrl.searchParams;
const range = (searchParams.get("range") || "today") as DateRange;
const { start, end } = getDateRange(range);
try {
const db = createServiceClient();
// ─── 1. KPIs: Calls today, trend, unique numbers ────────────────────────
// Calls today
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
const todayISO = todayStart.toISOString();
const { count: todayCount } = await db
.from("phone_clicks")
.select("*", { count: "exact" })
.gte("timestamp", todayISO)
.lte("timestamp", new Date().toISOString());
const callsToday = todayCount || 0;
// Calls yesterday for trend
const yesterdayStart = new Date(todayStart);
yesterdayStart.setDate(yesterdayStart.getDate() - 1);
const yesterdayEnd = new Date(yesterdayStart);
yesterdayEnd.setDate(yesterdayEnd.getDate() + 1);
const yesterdayISO = yesterdayStart.toISOString();
const yesterdayEndISO = yesterdayEnd.toISOString();
const { count: yesterdayCount } = await db
.from("phone_clicks")
.select("*", { count: "exact" })
.gte("timestamp", yesterdayISO)
.lt("timestamp", yesterdayEndISO);
const callsYesterday = yesterdayCount || 0;
const callsTodayTrend =
callsYesterday > 0
? Math.round(((callsToday - callsYesterday) / callsYesterday) * 100)
: callsToday > 0
? 100
: 0;
// Unique Numbers
const { data: uniqueData } = await db
.from("phone_clicks")
.select("phone_number")
.gte("timestamp", start)
.lte("timestamp", end);
const uniqueNumbers = new Set(
(uniqueData || []).map((d) => d.phone_number)
).size;
// Top Number
const numberCounts: Record<string, number> = {};
(uniqueData || []).forEach((item) => {
numberCounts[item.phone_number] =
(numberCounts[item.phone_number] || 0) + 1;
});
const topNumberEntries = Object.entries(numberCounts).sort(
([, a], [, b]) => b - a
);
const [topNumber, topNumberCount] = topNumberEntries[0] || [null, 0];
// Top Source Element
const { data: elementData } = await db
.from("phone_clicks")
.select("source_element")
.gte("timestamp", start)
.lte("timestamp", end);
const elementCounts: Record<string, number> = {};
(elementData || []).forEach((item) => {
elementCounts[item.source_element] =
(elementCounts[item.source_element] || 0) + 1;
});
const totalClicks = elementData?.length || 0;
const elementEntries = Object.entries(elementCounts).sort(
([, a], [, b]) => b - a
);
const [topSourceElement, topSourceElementCount] = elementEntries[0] || [
null,
0,
];
const topSourceElementPercent =
totalClicks > 0
? Math.round((topSourceElementCount / totalClicks) * 100)
: 0;
// ─── 2. Phone Numbers Table ─────────────────────────────────────────────
const { data: tableData } = await db
.from("phone_clicks")
.select("phone_number, source_page, source_element, country")
.gte("timestamp", start)
.lte("timestamp", end);
const phoneNumbersMap: Record<
string,
{
phone_number: string;
click_count: number;
trend_percent: number;
top_source_page: string;
top_source_element: string;
top_country: string;
}
> = {};
(tableData || []).forEach((item) => {
if (!phoneNumbersMap[item.phone_number]) {
phoneNumbersMap[item.phone_number] = {
phone_number: item.phone_number,
click_count: 0,
trend_percent: 0,
top_source_page: "",
top_source_element: "",
top_country: item.country || "—",
};
}
phoneNumbersMap[item.phone_number].click_count++;
});
const phoneNumbers = Object.values(phoneNumbersMap)
.sort((a, b) => b.click_count - a.click_count);
// ─── 3. Element Chart ───────────────────────────────────────────────────
const elements = Object.entries(elementCounts)
.map(([element, count]) => ({
source_element: element,
count,
percent: totalClicks > 0 ? Math.round((count / totalClicks) * 100) : 0,
}))
.sort((a, b) => b.count - a.count);
// ─── 4. Timeseries Chart ────────────────────────────────────────────────
const { data: timeseriesRaw } = await db
.from("phone_clicks")
.select("timestamp")
.gte("timestamp", start)
.lte("timestamp", end)
.order("timestamp", { ascending: true });
const timeseriesMap: Record<string, number> = {};
(timeseriesRaw || []).forEach((item) => {
const date = item.timestamp.split("T")[0];
timeseriesMap[date] = (timeseriesMap[date] || 0) + 1;
});
const timeseries = Object.entries(timeseriesMap).map(([date, count]) => ({
date,
count,
}));
// ─── 5. Geo Chart ───────────────────────────────────────────────────────
const { data: geoRaw } = await db
.from("phone_clicks")
.select("country")
.gte("timestamp", start)
.lte("timestamp", end)
.not("country", "is", null);
const geoCounts: Record<string, number> = {};
(geoRaw || []).forEach((item) => {
if (item.country) {
geoCounts[item.country] = (geoCounts[item.country] || 0) + 1;
}
});
const geoTotal = geoRaw?.length || 0;
const geo = Object.entries(geoCounts)
.map(([country, count]) => ({
country,
count,
percent: geoTotal > 0 ? Math.round((count / geoTotal) * 100) : 0,
}))
.sort((a, b) => b.count - a.count)
.slice(0, 10);
return NextResponse.json({
kpis: {
callsToday,
callsTodayTrend,
uniqueNumbers,
topNumber,
topNumberCount,
topSourceElement,
topSourceElementPercent,
},
phoneNumbers,
elements,
timeseries,
geo,
});
} catch (error) {
console.error("Phone calls analytics error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,64 @@
import { createServiceClient } from "@/lib/supabase";
import { isBot, anonymizeIp, parseDevice, parseBrowser, parseOs } from "@/lib/analytics";
export async function POST(request: Request) {
try {
const body = await request.json();
const { phone_number, source_page, source_element, session_id } = body;
// Validierung
if (!phone_number || !source_page || !source_element) {
return Response.json(
{ error: "Missing required fields: phone_number, source_page, source_element" },
{ status: 400 }
);
}
// IP + User-Agent extrahieren
const ip =
request.headers.get("x-forwarded-for")?.split(",")[0].trim() ||
request.headers.get("x-real-ip") ||
"0.0.0.0";
const ua = request.headers.get("user-agent") || "";
// Bot-Filter (silent skip)
if (isBot(ua)) {
return Response.json({ ok: true });
}
// Parsing
const device = parseDevice(ua);
const browser = parseBrowser(ua);
const os = parseOs(ua);
const ipAnon = anonymizeIp(ip);
// DB Insert
const supabase = createServiceClient();
const { error } = await supabase.from("phone_clicks").insert({
phone_number,
source_page,
source_element,
session_id: session_id || null,
ip_anonymized: ipAnon,
country: null,
region: null,
city: null,
device_type: device,
browser,
os,
user_agent: ua,
is_bot: false,
});
if (error) {
console.error("Phone click tracking error:", error);
return Response.json({ ok: true }); // Silent fail
}
return Response.json({ ok: true });
} catch (error) {
console.error("Phone click tracking exception:", error);
return Response.json({ ok: true }); // Silent fail
}
}

View File

@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from "next/server";
import { createServiceClient } from "@/lib/supabase";
import { anonymizeIp, isBot, parseDevice, parseBrowser, parseOs } from "@/lib/analytics";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
interface TrackBody {
path: string;
session_id: string;
referrer?: string;
view_id?: string; // Für duration_ms-Updates
duration_ms?: number; // Nur bei pagehide-Updates
}
/**
* POST /api/analytics/track
*
* Insert-Modus: { path, session_id, referrer? }
* DB INSERT, view_id zurückgeben
*
* Update-Modus: { view_id, duration_ms }
* DB UPDATE duration_ms
*
* Bot-Anfragen: Keine DB-Eintrag, aber 200 OK zurückgeben
*/
export async function POST(req: NextRequest) {
try {
const body: TrackBody = await req.json();
// Minimale Validierung
if (!body.path || !body.session_id) {
return NextResponse.json({ ok: false }, { status: 400 });
}
const ua = req.headers.get("user-agent") ?? "";
// ──── Bot-Filter: Keine DB-Einträge für Bots ────────────────────────────
if (isBot(ua)) {
return NextResponse.json({ ok: true, bot: true });
}
const db = createServiceClient();
// ──── Update-Modus: Verweildauer setzen ────────────────────────────────
if (body.view_id && body.duration_ms !== undefined) {
await db
.from("page_views")
.update({ duration_ms: body.duration_ms })
.eq("id", body.view_id)
.is("duration_ms", null); // Idempotent: nur updaten wenn noch nicht gesetzt
return NextResponse.json({ ok: true });
}
// ──── Insert-Modus: Neuen Seitenaufruf anlegen ─────────────────────────
const rawIp =
req.headers.get("x-forwarded-for")?.split(",")[0].trim() ??
req.headers.get("x-real-ip") ??
"0.0.0.0";
const { data, error } = await db
.from("page_views")
.insert({
path: body.path,
session_id: body.session_id,
referrer: body.referrer ?? null,
ip_anon: anonymizeIp(rawIp),
device_type: parseDevice(ua),
browser: parseBrowser(ua),
os: parseOs(ua),
is_bot: false,
})
.select("id")
.single();
if (error) {
console.error("[analytics/track] DB error:", error);
return NextResponse.json({ ok: false }, { status: 500 });
}
// view_id zurückgeben für späteren duration_ms-Update
return NextResponse.json({ ok: true, view_id: data.id });
} catch (err) {
console.error("[analytics/track] Error:", err);
return NextResponse.json({ ok: false }, { status: 500 });
}
}

View File

@ -0,0 +1,128 @@
"use client";
import { useEffect, useRef } from "react";
import { usePathname } from "next/navigation";
// Admin-Routen und API-Routen tracken wir nicht
const EXCLUDED_PREFIXES = ["/admin", "/api", "/_next"];
/**
* PageTracker Client-seitige Komponente für Web-Analytics
*
* Wird in app/layout.tsx eingebunden (global, einmalig)
* - Erfasst Seitenaufrufe
* - Misst Verweildauer via pagehide-Event
* - Verwendet sessionStorage für Session-ID (nicht persistent)
* - Sendet Daten an /api/analytics/track
*/
export function PageTracker() {
const pathname = usePathname();
const viewIdRef = useRef<string | null>(null);
const startTimeRef = useRef<number>(Date.now());
/**
* Gibt oder erstellt eine Session-ID aus sessionStorage
* Diese ID ist Tab-gebunden (nicht persistent über Reload)
*/
function getSessionId(): string {
let sid = sessionStorage.getItem("_mpv_sid");
if (!sid) {
// UUID v4 via Crypto API (kein externe Library)
sid = crypto.randomUUID();
sessionStorage.setItem("_mpv_sid", sid);
}
return sid;
}
useEffect(() => {
// Skip ausgeschlossene Routen
if (EXCLUDED_PREFIXES.some((p) => pathname.startsWith(p))) return;
startTimeRef.current = Date.now();
viewIdRef.current = null;
// ──── Seitenaufruf tracken ────────────────────────────────────────────
fetch("/api/analytics/track", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: pathname,
session_id: getSessionId(),
referrer: typeof document !== "undefined" ? document.referrer || undefined : undefined,
}),
})
.then((r) => r.json())
.then((d) => {
viewIdRef.current = d.view_id ?? null;
})
.catch(() => {
// Tracking-Fehler nie nach oben propagieren
});
// ──── Verweildauer beim Verlassen der Seite senden ────────────────────
function sendDuration() {
if (!viewIdRef.current) return;
const ms = Date.now() - startTimeRef.current;
// sendBeacon ist zuverlässiger als fetch bei pagehide (safari, mobile)
// Blob mit application/json damit req.json() im Handler funktioniert
const blob = new Blob(
[
JSON.stringify({
path: pathname,
session_id: getSessionId(),
view_id: viewIdRef.current,
duration_ms: ms,
}),
],
{ type: "application/json" }
);
navigator.sendBeacon("/api/analytics/track", blob);
}
// ──── Phone-Click Tracking ────────────────────────────────────────
function trackPhoneClick(event: Event) {
const target = event.target as HTMLElement;
const link = target.closest('a[href^="tel:"]');
if (!link) return;
const phoneHref = link.getAttribute("href");
const phoneNumber = phoneHref?.replace("tel:", "").trim();
if (!phoneNumber) return;
const sourceElement =
link.getAttribute("data-source-element") ||
link.closest("[data-source-element]")?.getAttribute("data-source-element") ||
"unknown";
fetch("/api/analytics/track-phone-click", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
phone_number: phoneNumber,
source_page: pathname,
source_element: sourceElement,
session_id: getSessionId(),
}),
}).catch(() => {
// Fehler ignorieren
});
}
// pagehide: zuverlässiger als beforeunload (Safari, Mobile)
window.addEventListener("pagehide", sendDuration);
// Phone-Click Tracking
document.addEventListener("click", trackPhoneClick);
return () => {
document.removeEventListener("click", trackPhoneClick);
window.removeEventListener("pagehide", sendDuration);
// Auch beim Route-Wechsel (SPA) die Verweildauer senden
sendDuration();
};
}, [pathname]);
return null; // Kein Markup
}

View File

@ -0,0 +1,72 @@
/**
* Analytics-Utilities für Page-View-Tracking
* Verwendbar in Middleware und API Routes (keine Next.js-Abhängigkeiten)
*/
// ──── Bot-Filter ────────────────────────────────────────────────────────────
// Regex für Crawler/Bots (nicht tracken)
const BOT_PATTERNS =
/bot|crawl|spider|slurp|mediapartners|adsbot|facebookexternalhit|twitterbot|linkedinbot|whatsapp|telegram|pinterest|slack|discordbot|applebot|bingpreview|google-read-aloud|ia_archiver|mj12bot|ahrefs|semrush|dotbot|rogerbot|screaming\.?frog/i;
export function isBot(ua: string): boolean {
return BOT_PATTERNS.test(ua);
}
// ──── IP-Anonymisierung ────────────────────────────────────────────────────
// DSGVO: Keine Rückverfolgung einzelner Personen
// IPv4: letztes Oktett auf 0 (192.168.1.123 → "192.168.1.0")
// IPv6: Auf /48 kürzen
export function anonymizeIp(ip: string): string {
// IPv4-mapped IPv6 (::ffff:1.2.3.4)
const mapped = ip.match(/^::ffff:(\d+\.\d+\.\d+)\.\d+$/i);
if (mapped) return `${mapped[1]}.0`;
// IPv4
const v4 = ip.match(/^(\d+\.\d+\.\d+)\.\d+$/);
if (v4) return `${v4[1]}.0`;
// IPv6: erste 3 Gruppen behalten, Rest nullen
const v6parts = ip.split(":");
if (v6parts.length >= 3) return `${v6parts.slice(0, 3).join(":")}::`;
return "0.0.0.0";
}
// ──── User-Agent-Parsing ────────────────────────────────────────────────────
export type DeviceType = "mobile" | "tablet" | "desktop";
export type BrowserName = "Chrome" | "Firefox" | "Safari" | "Edge" | "Opera" | "Other";
export type OsName = "Windows" | "macOS" | "iOS" | "Android" | "Linux" | "Other";
/**
* Erkennt ob der User ein Mobile-, Tablet- oder Desktop-Gerät nutzt
*/
export function parseDevice(ua: string): DeviceType {
if (/tablet|ipad|playbook|silk/i.test(ua)) return "tablet";
if (/mobile|android.*mobile|iphone|ipod|blackberry|windows phone/i.test(ua)) return "mobile";
return "desktop";
}
/**
* Erkennt den Browser
* Reihenfolge wichtig: Edge vor Chrome (Edge enthält auch "Chrome/" im UA)
*/
export function parseBrowser(ua: string): BrowserName {
if (/edg\//i.test(ua)) return "Edge";
if (/opr\//i.test(ua)) return "Opera";
if (/firefox\//i.test(ua)) return "Firefox";
if (/chrome\//i.test(ua)) return "Chrome";
if (/safari\//i.test(ua)) return "Safari";
return "Other";
}
/**
* Erkennt das Betriebssystem
*/
export function parseOs(ua: string): OsName {
if (/windows/i.test(ua)) return "Windows";
if (/iphone|ipad|ipod/i.test(ua)) return "iOS";
if (/android/i.test(ua)) return "Android";
if (/mac os x|macintosh/i.test(ua)) return "macOS";
if (/linux/i.test(ua)) return "Linux";
return "Other";
}

View File

@ -0,0 +1,36 @@
-- Migration: Erstelle page_views Tabelle für Web-Analytics
-- Datum: 2026-04-17
-- Zweck: Protokollierung von Seitenaufrufen, Verweildauer, Browser, Gerät, anonymisierte IP
CREATE TABLE IF NOT EXISTS page_views (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
path text NOT NULL,
timestamp timestamptz NOT NULL DEFAULT now(),
ip_anon text, -- IPv4: x.x.x.0 (DSGVO-anonymisiert)
device_type text CHECK (device_type IN ('desktop','tablet','mobile')),
browser text, -- Chrome/Firefox/Safari/Edge/Opera/Other
os text, -- Windows/macOS/iOS/Android/Linux/Other
referrer text,
session_id text NOT NULL, -- UUID aus sessionStorage
duration_ms int, -- Verweildauer in Millisekunden
is_bot boolean NOT NULL DEFAULT false
);
-- Indizes für häufige Analytics-Abfragen
CREATE INDEX IF NOT EXISTS idx_pv_timestamp ON page_views (timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_pv_path ON page_views (path);
CREATE INDEX IF NOT EXISTS idx_pv_session ON page_views (session_id);
CREATE INDEX IF NOT EXISTS idx_pv_is_bot ON page_views (is_bot) WHERE is_bot = false;
CREATE INDEX IF NOT EXISTS idx_pv_path_date ON page_views (path, timestamp DESC) WHERE is_bot = false;
-- RLS: Nur Service-Role kann schreiben, Reads später für Admin
ALTER TABLE page_views ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Service-Role Vollzugriff" ON page_views
USING (true)
WITH CHECK (true);
-- Automatisches Cleanup nach 13 Monaten (DSGVO-Datensparsamkeit)
-- Kann via Supabase scheduled function oder pg_cron ausgelöst werden
-- HINWEIS: Diesen Query regelmäßig per Cron Job aufrufen:
-- DELETE FROM page_views WHERE timestamp < now() - interval '13 months';

View File

@ -0,0 +1,29 @@
-- docs/MIGRATIONS_PHONE_CLICKS.sql
-- Phone-Click Tracking Table
CREATE TABLE IF NOT EXISTS phone_clicks (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
phone_number TEXT NOT NULL,
source_page TEXT NOT NULL,
source_element TEXT NOT NULL,
session_id TEXT,
ip_anonymized TEXT,
country TEXT,
region TEXT,
city TEXT,
device_type TEXT,
browser TEXT,
os TEXT,
user_agent TEXT,
is_bot BOOLEAN DEFAULT false,
timestamp TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_phone_clicks_timestamp ON phone_clicks(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_phone_clicks_phone_number ON phone_clicks(phone_number);
CREATE INDEX IF NOT EXISTS idx_phone_clicks_source_page ON phone_clicks(source_page);
CREATE INDEX IF NOT EXISTS idx_phone_clicks_source_element ON phone_clicks(source_element);
CREATE INDEX IF NOT EXISTS idx_phone_clicks_country ON phone_clicks(country);
-- RLS (Row Level Security) deaktivieren für Admin-Zugriff
ALTER TABLE phone_clicks DISABLE ROW LEVEL SECURITY;

View File

@ -0,0 +1,239 @@
# Modul: Kunden-Portal (Supabase Auth + Dashboard)
> Vollständiges Kundenkonto-System auf Basis von Supabase Auth: Registrierung mit Email-Bestätigung (signierter Confirmation-Link), Login mit Magic Link oder Passwort, Kundendashboard mit Anfragen-Übersicht (Status, Positionen, Admin-Notizen), Profilverwaltung, Logout.
---
## Enthaltene Dateien
| Ziel im neuen Projekt | Inhalt |
|---|---|
| `app/kunden/login/page.tsx` | Login-Seite (Supabase Auth, Redirect nach Login) |
| `app/kunden/registrieren/page.tsx` | Registrierungs-Seite (mit API-Aufruf) |
| `app/kunden/dashboard/page.tsx` | Kundendashboard (Anfragen, Status-Badges, Positionen) |
| `app/auth/callback/page.tsx` | OAuth/Magic-Link Callback Handler |
| `app/api/kunden/anfragen/route.ts` | GET: Anfragen des eingeloggten Kunden |
| `app/api/kunden/registrieren/route.ts` | POST: Registrierung via Supabase Admin API + Email |
---
## Voraussetzungen
```bash
npm install @supabase/supabase-js @supabase/ssr
```
Benötigt außerdem:
- `lib/supabase.ts` mit Browser-Client und Service-Client
- `lib/mailer.ts` (Modul 01) für Registrierungs-Bestätigungs-Email (optional, Supabase sendet selbst)
- Modul 04 (Reservation System) wenn Kunden ihre Anfragen sehen sollen
---
## Umgebungsvariablen (`.env.local`)
```env
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...
SUPABASE_SERVICE_ROLE_KEY=eyJ... # Für Registrierung via Admin API
APP_URL=https://example.com # Basis-URL für Confirmation-Links
```
---
## Datenbank-Setup (Supabase)
### 1. Supabase Auth aktivieren
Supabase Dashboard → Authentication → Settings:
- Email-Bestätigung aktivieren
- Redirect URL eintragen: `https://example.com/auth/callback`
### 2. Optional: Kunden-Profil-Tabelle
```sql
-- Verknüpft auth.users mit zusätzlichen Kundendaten
CREATE TABLE kunden_profile (
id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
firma text,
telefon text,
erstellt_am timestamptz DEFAULT now()
);
-- Trigger: Profil automatisch beim User-Create anlegen
CREATE OR REPLACE FUNCTION handle_new_user()
RETURNS trigger AS $$
BEGIN
INSERT INTO public.kunden_profile (id)
VALUES (NEW.id);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE PROCEDURE handle_new_user();
```
### 3. Row Level Security für Anfragen
```sql
-- Kunden sehen nur ihre eigenen Anfragen
ALTER TABLE anfragen ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Kunden sehen eigene Anfragen"
ON anfragen FOR SELECT
USING (auth.uid() = kunde_id);
```
---
## Einbindung Schritt für Schritt
### 1. Dateien kopieren
### 2. Supabase Middleware einrichten (`middleware.ts` im Projekt-Root)
```ts
import { createServerClient } from "@supabase/ssr";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export async function middleware(request: NextRequest) {
let response = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ cookies: { /* ... */ } }
);
await supabase.auth.getUser(); // Session refreshen
return response;
}
export const config = { matcher: ["/kunden/:path*"] };
```
### 3. Supabase Browser-Client in `lib/supabase.ts`
```ts
import { createBrowserClient } from "@supabase/ssr";
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}
```
### 4. Dashboard an eigene Datenstruktur anpassen
`app/kunden/dashboard/page.tsx` erwartet:
- `anfragen` Tabelle mit `status`, `firma`, `erstellt_am`
- `anfragen_positionen` mit `maschine_name`, `mietbeginn`, `mietende`, `gesamt_preis`
- RLS-Policy: Kunden sehen nur `anfragen WHERE kunde_id = auth.uid()`
### 5. Navigation ergänzen
```tsx
<Link href="/kunden/login">Mein Konto</Link>
<Link href="/kunden/dashboard">Meine Anfragen</Link>
```
---
## Anpassungspunkte
| Was | Wo |
|---|---|
| Registrierungs-Email | `lib/mailer.ts``sendeRegistrierungsBestaetigung()` |
| Passwort-Mindestlänge (8 Zeichen) | `app/api/kunden/registrieren/route.ts` → Zod-Schema |
| Dashboard-Felder | `app/kunden/dashboard/page.tsx` |
| Auth-Provider (Magic Link, OAuth) | Supabase Dashboard → Auth → Providers |
| Session-Cookie-Dauer | Supabase Dashboard → Auth → Settings → JWT Expiry |
| Kunden-Profil-Felder | `kunden_profile` Tabelle + Profilseite |
---
## Integrations-Prompt
Kopiere diesen Prompt in eine neue KI-Konversation, nachdem du die `files/` in dein Projekt kopiert hast. Ersetze alle `[PLATZHALTER]`.
```
Ich integriere das Kunden-Portal (Supabase Auth, Registrierung, Kundendashboard) in mein Next.js/Supabase-Projekt.
PROJEKT-KONTEXT:
- Modul 01 (Email) ist bereits integriert (mailer.ts verfügbar)
- Modul 04 (Reservierungssystem) ist bereits integriert (anfragen-Tabelle existiert)
- Kundenbereich URL-Prefix: /kunden
- App-URL: [https://beispiel.de]
BEREITS KOPIERTE DATEIEN (aus modules/06-kunden-portal/files/):
- app/kunden/login/page.tsx
- app/kunden/registrieren/page.tsx
- app/kunden/dashboard/page.tsx
- app/auth/callback/page.tsx
- app/api/kunden/anfragen/route.ts
- app/api/kunden/registrieren/route.ts
AUFGABEN führe sie der Reihe nach aus:
1. SUPABASE AUTH KONFIGURIEREN:
Gehe zu Supabase Dashboard → Authentication → URL Configuration.
Trage ein:
- Site URL: [https://beispiel.de]
- Redirect URLs: [https://beispiel.de]/auth/callback
2. SUPABASE-PAKETE installieren (falls noch nicht vorhanden):
npm install @supabase/supabase-js @supabase/ssr
3. SUPABASE CLIENTS in lib/supabase.ts ergänzen:
Lies lib/supabase.ts. Füge hinzu (falls Browser-Client fehlt):
import { createBrowserClient } from "@supabase/ssr";
export function createBrowserSupabaseClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}
4. MIDDLEWARE erstellen: Erstelle middleware.ts im Projekt-Root:
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export async function middleware(request: NextRequest) {
let response = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ cookies: {
getAll() { return request.cookies.getAll(); },
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options));
},
}}
);
await supabase.auth.getUser();
return response;
}
export const config = { matcher: ["/kunden/:path*", "/auth/:path*"] };
5. RLS-POLICY für Anfragen: Führe im Supabase SQL-Editor aus:
ALTER TABLE anfragen ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Kunden sehen eigene Anfragen"
ON anfragen FOR SELECT USING (auth.uid() = kunde_id);
6. REGISTRIERUNGS-EMAIL anpassen: Lies lib/mailer.ts.
Passe die Funktion sendeRegistrierungsBestaetigung() an (oder erstelle sie falls nicht vorhanden):
- Ersetze "Mietpark Hahn" durch "[PROJEKTNAME]"
- Passe den Betreff an: "Bitte bestätigen Sie Ihre E-Mail [PROJEKTNAME]"
7. DASHBOARD anpassen: Lies app/kunden/dashboard/page.tsx.
- Ersetze "Mietanfrage/Maschine" durch die eigene Bezeichnung
- Passe angezeigte Felder aus anfragen_positionen an
8. NAVIGATION ergänzen: Füge in den öffentlichen Header hinzu:
<Link href="/kunden/login">Mein Konto</Link>
Nach Login: <Link href="/kunden/dashboard">Meine Anfragen</Link>
9. TEST:
a) /kunden/registrieren → Registrierung abschließen
b) Bestätigungs-Email öffnen, Link klicken → /auth/callback → /kunden/dashboard
c) /kunden/dashboard → Anfragen des Kunden erscheinen
d) /kunden/login → Logout → Redirect zu /kunden/login
Lies jede Datei vor dem Bearbeiten. Melde wenn alle Schritte abgeschlossen sind.
```

View File

@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";
import { createServiceClient } from "@/lib/supabase";
// Validiert den Bearer-Token und gibt die E-Mail-Adresse zurück
async function getKundeEmail(authHeader: string | null): Promise<string | null> {
if (!authHeader?.startsWith("Bearer ")) return null;
const token = authHeader.slice(7);
const anonClient = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
const { data: { user }, error } = await anonClient.auth.getUser(token);
if (error || !user?.email) return null;
return user.email;
}
export async function GET(req: NextRequest) {
const email = await getKundeEmail(req.headers.get("authorization"));
if (!email) {
return NextResponse.json({ error: "Nicht authentifiziert" }, { status: 401 });
}
const db = createServiceClient();
// Anfragen nach E-Mail + zugehörige Positionen laden
const { data: anfragen, error } = await db
.from("anfragen")
.select(`
id,
created_at,
status,
firma,
telefon,
email,
notizen,
anfragen_positionen (
id,
maschine_name,
mietbeginn,
mietende,
gesamt_tage,
lieferung,
tagessatz
)
`)
.eq("email", email)
.order("created_at", { ascending: false });
if (error) {
return NextResponse.json({ error: "Datenbankfehler" }, { status: 500 });
}
return NextResponse.json({ anfragen: anfragen ?? [] });
}

View File

@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { createServiceClient } from "@/lib/supabase";
import { sendeRegistrierungsBestaetigung } from "@/lib/mailer";
const Schema = z.object({
email: z.string().email(),
password: z.string().min(8),
firma: z.string().optional(),
});
export async function POST(req: NextRequest) {
const body = await req.json().catch(() => null);
const result = Schema.safeParse(body);
if (!result.success) {
return NextResponse.json({ error: "Ungültige Eingabe" }, { status: 422 });
}
const { email, password, firma } = result.data;
const appUrl = process.env.APP_URL ?? "https://www.mietparkhahn.de";
const db = createServiceClient();
// Bestätigungslink über Supabase Admin API generieren
// → Erstellt den User + gibt einen signierten Bestätigungslink zurück
const { data: linkData, error } = await db.auth.admin.generateLink({
type: "signup",
email,
password,
options: {
data: { firma: firma ?? "" },
redirectTo: `${appUrl}/auth/callback`,
},
});
if (error) {
// User existiert bereits
if (error.message.includes("already registered") || error.message.includes("already been registered")) {
return NextResponse.json({ error: "already_registered" }, { status: 409 });
}
console.error("[Registrierung] Fehler:", error.message);
return NextResponse.json({ error: error.message }, { status: 500 });
}
const bestaetigungsLink = linkData.properties?.action_link;
if (!bestaetigungsLink) {
return NextResponse.json({ error: "Kein Bestätigungslink erhalten" }, { status: 500 });
}
// Bestätigungs-E-Mail über eigenes IONOS-SMTP versenden
await sendeRegistrierungsBestaetigung({ email, firma, bestaetigungsLink });
return NextResponse.json({ success: true });
}

View File

@ -0,0 +1,96 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { CheckCircle2, XCircle } from "lucide-react";
import Link from "next/link";
import { supabase } from "@/lib/supabase";
export default function AuthCallbackPage() {
const router = useRouter();
const [status, setStatus] = useState<"loading" | "success" | "error">("loading");
useEffect(() => {
// Supabase JS verarbeitet automatisch den ?code= oder #access_token= aus der URL
// und etabliert die Session (detectSessionInUrl ist standardmäßig aktiviert).
// Wir warten kurz und prüfen dann die Session.
const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => {
if (event === "SIGNED_IN" && session) {
setStatus("success");
setTimeout(() => router.replace("/kunden/dashboard"), 1500);
} else if (event === "TOKEN_REFRESHED" && session) {
setStatus("success");
setTimeout(() => router.replace("/kunden/dashboard"), 1500);
}
});
// Fallback: Session direkt prüfen (falls onAuthStateChange zu spät feuert)
supabase.auth.getSession().then(({ data: { session } }) => {
if (session) {
setStatus("success");
setTimeout(() => router.replace("/kunden/dashboard"), 1500);
} else {
// Noch kurz warten, Supabase braucht manchmal einen Moment
setTimeout(() => {
supabase.auth.getSession().then(({ data: { session: s } }) => {
if (s) {
setStatus("success");
setTimeout(() => router.replace("/kunden/dashboard"), 1500);
} else {
setStatus("error");
}
});
}, 2000);
}
});
return () => subscription.unsubscribe();
}, [router]);
if (status === "loading") {
return (
<div className="min-h-[80vh] flex items-center justify-center px-4">
<div className="text-center">
<div className="w-10 h-10 border-2 border-[#f7d334] border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-slate-500 text-sm">E-Mail-Adresse wird bestätigt</p>
</div>
</div>
);
}
if (status === "success") {
return (
<div className="min-h-[80vh] flex items-center justify-center px-4">
<div className="w-full max-w-md text-center border border-slate-200 bg-white p-8">
<div className="w-12 h-12 bg-green-50 rounded-full flex items-center justify-center mx-auto mb-4">
<CheckCircle2 className="w-6 h-6 text-green-600" />
</div>
<h2 className="text-xl font-bold text-slate-900 mb-2">E-Mail bestätigt!</h2>
<p className="text-slate-500 text-sm">
Sie werden automatisch weitergeleitet
</p>
</div>
</div>
);
}
return (
<div className="min-h-[80vh] flex items-center justify-center px-4">
<div className="w-full max-w-md text-center border border-slate-200 bg-white p-8">
<div className="w-12 h-12 bg-red-50 rounded-full flex items-center justify-center mx-auto mb-4">
<XCircle className="w-6 h-6 text-red-500" />
</div>
<h2 className="text-xl font-bold text-slate-900 mb-2">Bestätigung fehlgeschlagen</h2>
<p className="text-slate-500 text-sm mb-6">
Der Bestätigungslink ist abgelaufen oder ungültig. Bitte registrieren Sie sich erneut.
</p>
<Link
href="/kunden/registrieren"
className="inline-flex items-center justify-center bg-[#f7d334] hover:bg-[#fcd34d] text-[#1c1917] rounded-md font-semibold px-6 py-2.5 text-sm transition-all duration-200 hover:scale-110"
>
Zur Registrierung
</Link>
</div>
</div>
);
}

View File

@ -0,0 +1,203 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { LogOut, Package, Clock, CheckCircle2, XCircle, AlertCircle, UserCog } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { supabase } from "@/lib/supabase";
import type { User } from "@supabase/supabase-js";
const STATUS_MAP: Record<string, { label: string; icon: React.ElementType; color: string }> = {
offen: { label: "Offen", icon: Clock, color: "text-amber-700 bg-amber-50 border-amber-200" },
bestaetigt: { label: "Bestätigt", icon: CheckCircle2, color: "text-green-700 bg-green-50 border-green-200" },
abgelehnt: { label: "Abgelehnt", icon: XCircle, color: "text-red-700 bg-red-50 border-red-200" },
abgeschlossen: { label: "Abgeschlossen", icon: CheckCircle2, color: "text-slate-600 bg-slate-50 border-slate-200" },
};
interface Anfrage {
id: string;
created_at: string;
status: string;
firma: string;
telefon: string;
email: string;
notizen: string;
anfragen_positionen: {
id: string;
maschine_name: string;
mietbeginn: string;
mietende: string;
gesamt_tage: number;
lieferung: boolean;
tagessatz: number | null;
}[];
}
function formatDatum(iso: string) {
return new Date(iso).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
export default function KundenDashboardPage() {
const router = useRouter();
const [user, setUser] = useState<User | null>(null);
const [anfragen, setAnfragen] = useState<Anfrage[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function init() {
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
router.push("/kunden/login");
return;
}
setUser(session.user);
// Anfragen laden
const res = await fetch("/api/kunden/anfragen", {
headers: { Authorization: `Bearer ${session.access_token}` },
});
if (res.ok) {
const json = await res.json();
setAnfragen(json.anfragen ?? []);
}
setLoading(false);
}
init();
}, [router]);
async function handleLogout() {
await supabase.auth.signOut();
router.push("/kunden/login");
}
if (loading) {
return (
<div className="min-h-[60dvh] flex items-center justify-center">
<p className="text-slate-400">Wird geladen</p>
</div>
);
}
return (
<div className="max-w-4xl mx-auto px-4 py-10">
{/* Header */}
<div className="flex items-start justify-between gap-4 mb-8">
<div>
<h1 className="text-2xl font-bold text-slate-900 tracking-tight">Mein Bereich</h1>
<p className="text-slate-500 text-sm mt-1">{user?.email}</p>
</div>
<div className="flex items-center gap-2">
<Link
href="/kunden/profil"
className="inline-flex items-center gap-1.5 text-sm border border-slate-200 rounded-md px-3 py-1.5 text-slate-600 hover:text-[#1c1917] hover:bg-slate-50 transition-colors"
>
<UserCog className="w-3.5 h-3.5" />
Profil
</Link>
<Button
variant="outline"
size="sm"
onClick={handleLogout}
className="border-slate-200 rounded-md text-slate-600 hover:text-[#1c1917] flex items-center gap-1.5"
>
<LogOut className="w-3.5 h-3.5" />
Abmelden
</Button>
</div>
</div>
<Separator className="mb-8" />
{/* Anfragen */}
<div>
<div className="flex items-center justify-between mb-5">
<h2 className="font-semibold text-slate-900">Meine Anfragen</h2>
<Link
href="/mietpark"
className="text-sm text-[#1c1917] hover:underline underline-offset-2 font-medium"
>
+ Neue Anfrage
</Link>
</div>
{anfragen.length === 0 ? (
<div className="border border-dashed border-slate-200 py-12 text-center">
<Package className="w-8 h-8 text-slate-300 mx-auto mb-3" />
<p className="text-slate-500 mb-1">Noch keine Anfragen vorhanden</p>
<p className="text-sm text-slate-400 mb-4">
Stöbern Sie im Mietpark und stellen Sie Ihre erste Anfrage.
</p>
<Link
href="/mietpark"
className="inline-flex items-center justify-center bg-[#f7d334] hover:bg-[#fcd34d] text-[#1c1917] rounded-md font-semibold px-5 py-2 text-sm transition-all duration-200 hover:scale-110"
>
Zum Mietpark
</Link>
</div>
) : (
<div className="space-y-4">
{anfragen.map((anfrage) => {
const st = STATUS_MAP[anfrage.status] ?? STATUS_MAP.offen;
const Icon = st.icon;
return (
<div key={anfrage.id} className="border border-slate-200 bg-white">
{/* Anfrage-Header */}
<div className="flex items-center justify-between gap-3 px-4 py-3 border-b border-slate-100">
<div className="flex items-center gap-2.5">
<span className={`inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 border rounded-full ${st.color}`}>
<Icon className="w-3 h-3" />
{st.label}
</span>
<span className="text-xs text-slate-400">
{formatDatum(anfrage.created_at)}
</span>
</div>
<span className="text-xs text-slate-300 font-mono hidden sm:block">
#{anfrage.id.slice(0, 8)}
</span>
</div>
{/* Positionen */}
<div className="divide-y divide-slate-50">
{anfrage.anfragen_positionen.map((pos) => (
<div key={pos.id} className="px-4 py-3 flex items-center justify-between gap-4">
<div>
<p className="font-medium text-sm text-slate-900">{pos.maschine_name}</p>
<p className="text-xs text-slate-400 mt-0.5">
{formatDatum(pos.mietbeginn)} {formatDatum(pos.mietende)} · {pos.gesamt_tage} Tag{pos.gesamt_tage !== 1 ? "e" : ""}
{pos.lieferung && " · Lieferung"}
</p>
</div>
<span className="font-mono text-sm font-semibold text-[#1c1917] flex-shrink-0">
{pos.tagessatz != null
? `${(pos.tagessatz * pos.gesamt_tage).toLocaleString("de-DE")}`
: "Auf Anfrage"}
</span>
</div>
))}
</div>
{/* Notiz vom Verleih */}
{anfrage.notizen && (
<div className="px-4 py-3 bg-[#fef3c7] border-t border-amber-100">
<div className="flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-amber-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-800">{anfrage.notizen}</p>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,119 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { LogIn } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { supabase } from "@/lib/supabase";
import { config } from "@/lib/config";
export default function KundenLoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [passwort, setPasswort] = useState("");
const [fehler, setFehler] = useState("");
const [loading, setLoading] = useState(false);
async function handleLogin(e: React.FormEvent) {
e.preventDefault();
setFehler("");
setLoading(true);
const { error } = await supabase.auth.signInWithPassword({
email,
password: passwort,
});
if (error) {
if (error.message.toLowerCase().includes("email not confirmed")) {
setFehler("Bitte bestätigen Sie zuerst Ihre E-Mail-Adresse. Schauen Sie in Ihren Posteingang.");
} else {
setFehler("E-Mail oder Passwort ungültig.");
}
} else {
router.push("/kunden/dashboard");
}
setLoading(false);
}
return (
<div className="min-h-[80dvh] flex items-center justify-center px-4">
<div className="w-full max-w-md">
{/* Header */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-12 h-12 bg-[#1c1917] rounded-md mb-4">
<LogIn className="w-6 h-6 text-[#f7d334]" />
</div>
<h1 className="text-2xl font-bold text-slate-900 tracking-tight">Kunden-Login</h1>
<p className="text-slate-500 text-sm mt-2">
Melden Sie sich an, um Ihre Mietanfragen einzusehen.
</p>
</div>
<div className="border border-slate-200 bg-white p-6 space-y-4">
<form onSubmit={handleLogin} className="space-y-4">
<div>
<Label htmlFor="kl-email">E-Mail-Adresse</Label>
<Input
id="kl-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="ihre@email.de"
required
className="mt-1 rounded-md"
autoComplete="email"
/>
</div>
<div>
<Label htmlFor="kl-passwort">Passwort</Label>
<Input
id="kl-passwort"
type="password"
value={passwort}
onChange={(e) => setPasswort(e.target.value)}
placeholder="••••••••"
required
className="mt-1 rounded-md"
autoComplete="current-password"
/>
</div>
{fehler && (
<p className="text-sm text-red-500 bg-red-50 border border-red-200 px-3 py-2 rounded-md">
{fehler}
</p>
)}
<Button
type="submit"
disabled={loading}
className="w-full bg-[#f7d334] hover:bg-[#fcd34d] text-[#1c1917] rounded-md font-semibold border-transparent py-2.5"
>
{loading ? "Wird angemeldet…" : "Anmelden"}
</Button>
</form>
<div className="border-t border-slate-100 pt-4 text-center space-y-2">
<p className="text-sm text-slate-500">
Noch kein Konto?{" "}
<Link href="/kunden/registrieren" className="text-[#1c1917] font-medium hover:underline underline-offset-2">
Jetzt registrieren
</Link>
</p>
</div>
</div>
<p className="text-center text-xs text-slate-400 mt-4">
Fragen?{" "}
<a href={`tel:${config.company.phonePlain}`} data-source-element="kunden-login" className="hover:underline">
{config.company.phone}
</a>
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,176 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { UserPlus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { config } from "@/lib/config";
export default function KundenRegistrierenPage() {
const [email, setEmail] = useState("");
const [passwort, setPasswort] = useState("");
const [passwortWdh, setPasswortWdh] = useState("");
const [firma, setFirma] = useState("");
const [fehler, setFehler] = useState("");
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
async function handleRegistrieren(e: React.FormEvent) {
e.preventDefault();
setFehler("");
if (passwort.length < 8) {
setFehler("Das Passwort muss mindestens 8 Zeichen lang sein.");
return;
}
if (passwort !== passwortWdh) {
setFehler("Die Passwörter stimmen nicht überein.");
return;
}
setLoading(true);
const res = await fetch("/api/kunden/registrieren", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password: passwort, firma }),
});
const json = await res.json();
if (!res.ok) {
if (json.error === "already_registered") {
setFehler("Diese E-Mail ist bereits registriert. Bitte direkt anmelden.");
} else {
setFehler(`Registrierung fehlgeschlagen: ${json.error}`);
}
} else {
setSuccess(true);
}
setLoading(false);
}
if (success) {
return (
<div className="min-h-[80dvh] flex items-center justify-center px-4">
<div className="w-full max-w-md text-center border border-slate-200 bg-white p-8">
<div className="w-12 h-12 bg-green-50 rounded-full flex items-center justify-center mx-auto mb-4">
<UserPlus className="w-6 h-6 text-green-600" />
</div>
<h2 className="text-xl font-bold text-slate-900 mb-2">Registrierung erfolgreich!</h2>
<p className="text-slate-500 text-sm mb-6">
Bitte bestätigen Sie Ihre E-Mail-Adresse. Nach der Bestätigung können Sie sich anmelden.
</p>
<Link
href="/kunden/login"
className="inline-flex items-center justify-center bg-[#f7d334] hover:bg-[#fcd34d] text-[#1c1917] rounded-md font-semibold px-6 py-2.5 text-sm transition-all duration-200 hover:scale-110"
>
Zur Anmeldung
</Link>
</div>
</div>
);
}
return (
<div className="min-h-[80dvh] flex items-center justify-center px-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-12 h-12 bg-[#1c1917] rounded-md mb-4">
<UserPlus className="w-6 h-6 text-[#f7d334]" />
</div>
<h1 className="text-2xl font-bold text-slate-900 tracking-tight">Konto erstellen</h1>
<p className="text-slate-500 text-sm mt-2">
Registrieren Sie sich, um Ihre Mietanfragen zu verwalten.
</p>
</div>
<div className="border border-slate-200 bg-white p-6 space-y-4">
<form onSubmit={handleRegistrieren} className="space-y-4">
<div>
<Label htmlFor="kr-firma">Firma / Name</Label>
<Input
id="kr-firma"
value={firma}
onChange={(e) => setFirma(e.target.value)}
placeholder="Muster GmbH oder Max Muster"
className="mt-1 rounded-md"
autoComplete="organization"
/>
</div>
<div>
<Label htmlFor="kr-email">E-Mail-Adresse *</Label>
<Input
id="kr-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="ihre@email.de"
required
className="mt-1 rounded-md"
autoComplete="email"
/>
</div>
<div>
<Label htmlFor="kr-pw">Passwort * (min. 8 Zeichen)</Label>
<Input
id="kr-pw"
type="password"
value={passwort}
onChange={(e) => setPasswort(e.target.value)}
placeholder="••••••••"
required
className="mt-1 rounded-md"
autoComplete="new-password"
/>
</div>
<div>
<Label htmlFor="kr-pw2">Passwort wiederholen *</Label>
<Input
id="kr-pw2"
type="password"
value={passwortWdh}
onChange={(e) => setPasswortWdh(e.target.value)}
placeholder="••••••••"
required
className="mt-1 rounded-md"
autoComplete="new-password"
/>
</div>
{fehler && (
<p className="text-sm text-red-500 bg-red-50 border border-red-200 px-3 py-2 rounded-md">
{fehler}
</p>
)}
<Button
type="submit"
disabled={loading}
className="w-full bg-[#f7d334] hover:bg-[#fcd34d] text-[#1c1917] rounded-md font-semibold border-transparent py-2.5"
>
{loading ? "Wird registriert…" : "Konto erstellen"}
</Button>
</form>
<div className="border-t border-slate-100 pt-4 text-center">
<p className="text-sm text-slate-500">
Bereits registriert?{" "}
<Link href="/kunden/login" className="text-[#1c1917] font-medium hover:underline underline-offset-2">
Jetzt anmelden
</Link>
</p>
</div>
</div>
<p className="text-center text-xs text-slate-400 mt-4">
Fragen?{" "}
<a href={`tel:${config.company.phonePlain}`} data-source-element="kunden-registrieren" className="hover:underline">
{config.company.phone}
</a>
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,169 @@
# Modul: KPI-Dashboard (Admin-Statistik)
> Server-seitig gerendertes Admin-Statistik-Dashboard: 4 KPI-Cards (Anfragen, bestätigte Mieten, Miettage, Umsatz), Umsatz-Balkendiagramm (letzte 6 Monate, CSS-basiert), Maschinenauslastungs-Tabelle (Top-Ressourcen), integrierter Gantt-Kalender (±3 Monate). Vollständig ohne externe Chart-Bibliothek.
---
## Enthaltene Dateien
| Ziel im neuen Projekt | Inhalt |
|---|---|
| `app/admin/statistik/page.tsx` | Server Component: KPI-Cards + Umsatzdiagramm + Gantt |
| `app/admin/statistik/GanttKalender.tsx` | Wiederverwendbare Gantt-Komponente (auch in Modul 04) |
| `app/api/admin/statistik/route.ts` | GET: Aggregierte KPI-Daten (requireAdmin) |
---
## Voraussetzungen
Keine zusätzlichen npm-Pakete. Benötigt:
- `lib/supabase.ts` (Service Client)
- `lib/admin-auth.ts` (Modul 02: `requireAdmin`)
- Modul 04 (Reservation System) Tabellen `anfragen`, `anfragen_positionen`
---
## Umgebungsvariablen
Keine zusätzlichen.
---
## Datenbank
Liest aus bestehenden Tabellen (kein Schema-Change nötig):
- `anfragen` Anzahl, Status-Verteilung, Zeiträume
- `anfragen_positionen` Tagessätze, Miettage, Umsatz
- `anfrage_status_audit` optional für Audit-Timeline
---
## Einbindung Schritt für Schritt
### 1. Dateien kopieren
### 2. Admin-Navigation ergänzen
```tsx
<Link href="/admin/statistik">Statistik</Link>
```
### 3. Statistik-API anpassen (`app/api/admin/statistik/route.ts`)
Die API aggregiert aktuell folgende Felder:
```ts
{
kpis: {
anfragen_gesamt: number,
bestaetigt: number,
umsatz_gesamt: number,
miettage_gesamt: number
},
monatsUmsatz: Array<{ monat: string, umsatz: number, anzahl: number }>, // 6 Monate
maschinenAuslastung: Array<{ name: string, tage: number, umsatz: number }>,
planDaten: PlanPosition[] // für GanttKalender
}
```
Abfrage anpassen wenn Tabellen-/Feldnamen abweichen.
### 4. Seite an eigenes Design anpassen (`app/admin/statistik/page.tsx`)
- KPI-Cards: Farben, Icons, Bezeichnungen
- Diagramm: Anzahl Monate, Skala
- Auslastungs-Tabelle: Spalten
### 5. GanttKalender-Props
```tsx
<GanttKalender
planDaten={planDaten} // PlanPosition[] aus API
heuteISO="2026-04-25"
startISO="2026-03-01" // 3 Monate Vorlauf
tageGesamt={180} // ±3 Monate
/>
```
`PlanPosition` Interface:
```ts
interface PlanPosition {
id: string;
anfrage_id: string;
maschine_name: string;
maschine_kategorie: string;
mietbeginn: string; // "YYYY-MM-DD"
mietende: string; // "YYYY-MM-DD"
gesamt_tage: number;
tagessatz: number | null;
anfrage_status: string; // "offen"|"bestaetigt"|"abgelehnt"|"abgeschlossen"
firma: string | null;
}
```
---
## Anpassungspunkte
| Was | Wo |
|---|---|
| KPI-Bezeichnungen | `app/admin/statistik/page.tsx` → KPI-Card Labels |
| Umsatz-Formel (Tagessatz × Tage) | `app/api/admin/statistik/route.ts``umsatz_calc` |
| Nur bestätigte Anfragen zählen | `app/api/admin/statistik/route.ts``status = 'bestaetigt'` Filter |
| Diagramm Zeitraum (6 Monate) | `app/api/admin/statistik/route.ts``INTERVAL '6 months'` |
| Gantt Zeitraum | `app/admin/statistik/page.tsx``startISO` + `tageGesamt` |
| Status-Farben im Gantt | `app/admin/statistik/GanttKalender.tsx``STATUS_COLORS` |
---
## Integrations-Prompt
Kopiere diesen Prompt in eine neue KI-Konversation, nachdem du die `files/` in dein Projekt kopiert hast. Ersetze alle `[PLATZHALTER]`.
```
Ich integriere das KPI-Dashboard (Statistik-Übersicht mit Gantt) in mein Next.js/Supabase-Projekt.
PROJEKT-KONTEXT:
- Modul 02 (Admin-Auth) ist bereits integriert (requireAdmin verfügbar)
- Modul 04 (Reservierungssystem) ist bereits integriert (Tabellen: anfragen, anfragen_positionen, anfrage_status_audit)
- Ressourcen-Bezeichnung: [MASCHINEN/ARTIKEL/...]
- Währung: [EUR / CHF / ...]
BEREITS KOPIERTE DATEIEN (aus modules/07-kpi-dashboard/files/):
- app/admin/statistik/page.tsx
- app/admin/statistik/GanttKalender.tsx
- app/api/admin/statistik/route.ts
AUFGABEN führe sie der Reihe nach aus:
1. API ANPASSEN: Lies app/api/admin/statistik/route.ts vollständig.
Prüfe ob alle referenzierten Tabellen-/Feldnamen mit den tatsächlichen übereinstimmen:
- Tabelle "anfragen" → Status-Felder, Datum-Felder
- Tabelle "anfragen_positionen" → Preis-Felder (tagessatz, gesamt_preis)
Passe ggf. Feldnamen in den SQL-Abfragen an.
2. KPI-TEXTE ANPASSEN: Lies app/admin/statistik/page.tsx.
Passe folgende Labels an die eigene Domäne an:
- "Mietanfragen" → "[ANFRAGEN_BEZEICHNUNG]"
- "Bestätigte Mieten" → "[BESTÄTIGTE_BEZEICHNUNG]"
- "Miettage" → "[ZEITEINHEIT_BEZEICHNUNG]"
- "Umsatz" → "Umsatz ([WÄHRUNG])"
Ersetze alle Vorkommen von "Mietpark Hahn" und "Maschine/Gerät" durch die eigenen Begriffe.
3. GANTT ANPASSEN: Lies app/admin/statistik/GanttKalender.tsx.
Falls Modul 04 bereits mit angepassten Ressourcen-Bezeichnungen eingebunden wurde,
prüfe ob die PlanPosition-Felder noch stimmen (maschine_name, maschine_kategorie).
Passe den Tooltip-Text an (aktuell: "Firma Maschine X Tage").
4. ADMIN-NAV ergänzen: Füge einen Link zu /admin/statistik hinzu.
5. GANTT ZEITRAUM konfigurieren: Lies app/admin/statistik/page.tsx.
Passe startISO und tageGesamt an die gewünschte Standardansicht an:
- Kurzfristiger Betrieb (< 1 Monat): tageGesamt=60
- Mittelfristiger Betrieb: tageGesamt=180 (Standard)
- Langzeitplanung: tageGesamt=365
6. TEST:
a) Dev-Server starten, /admin/statistik aufrufen
b) KPI-Cards zeigen Zahlen aus anfragen-Tabelle
c) Umsatzdiagramm zeigt letzte 6 Monate
d) Gantt-Kalender zeigt bestätigte Anfragen als Balken
e) Balken anklicken → Weiterleitung zu /admin/anfragen/[id]
Lies jede Datei vor dem Bearbeiten. Melde wenn alle Schritte abgeschlossen sind.
```

View File

@ -0,0 +1,366 @@
"use client";
import { useRef } from "react";
import { useRouter } from "next/navigation";
import { Lock } from "lucide-react";
interface PlanPosition {
id: string;
anfrage_id: string;
maschine_name: string;
maschine_kategorie: string;
mietbeginn: string;
mietende: string;
gesamt_tage: number;
tagessatz: number | null;
anfrage_status: string;
firma: string;
}
/** Minimaler Sperrung-Typ fuer den Gantt. maschine_name wird benoetigt,
* weil der Gantt Zeilen anhand des Namens gruppiert. */
export interface SperrungGantt {
sperr_id: string;
maschine_name: string;
von: string;
bis: string;
grund: string;
notiz?: string;
}
interface Props {
planDaten: PlanPosition[];
heuteISO: string;
startISO?: string; // Optional: Startdatum (default: heuteISO)
tageGesamt?: number; // Optional: Sichtbare Tage (default: 95)
sperrungen?: SperrungGantt[];
}
const STATUS_FARBE: Record<string, string> = {
bestaetigt: "bg-green-500 border-green-600",
offen: "bg-amber-400 border-amber-500",
abgeschlossen: "bg-slate-400 border-slate-500",
abgelehnt: "bg-red-300 border-red-400",
};
const STATUS_LABEL: Record<string, string> = {
bestaetigt: "Bestätigt",
offen: "Offen",
abgeschlossen: "Abgeschlossen",
abgelehnt: "Abgelehnt",
};
// Sperrungen werden in einem neutralen Dunkelgrau mit diagonaler Schraffur
// dargestellt, damit sie sich visuell klar von Anfrage-Balken abheben.
const SPERRUNG_STYLE =
"bg-slate-700 border-slate-800 text-white " +
"bg-[repeating-linear-gradient(45deg,rgba(255,255,255,0.08)_0_6px,transparent_6px_12px)]";
function addDays(iso: string, days: number): string {
const d = new Date(iso + "T00:00:00");
d.setDate(d.getDate() + days);
return d.toISOString().slice(0, 10);
}
function daysBetween(a: string, b: string): number {
const da = new Date(a + "T00:00:00");
const db = new Date(b + "T00:00:00");
return Math.round((db.getTime() - da.getTime()) / 86400000);
}
function formatDate(iso: string): string {
return new Date(iso + "T00:00:00").toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
});
}
function fmt(n: number) {
return n.toLocaleString("de-DE", { minimumFractionDigits: 0, maximumFractionDigits: 0 });
}
const TAGE_GESAMT = 95; // ca. 3 Monate + etwas Puffer
const TAG_BREITE = 28; // px pro Tag
export function GanttKalender({
planDaten,
heuteISO,
startISO,
tageGesamt,
sperrungen = [],
}: Props) {
const scrollRef = useRef<HTMLDivElement>(null);
const router = useRouter();
// Defaults setzen
const kalenderStart = startISO ?? heuteISO;
const TAGE_GESAMT = tageGesamt ?? 95;
const kalenderEnde = addDays(kalenderStart, TAGE_GESAMT - 1);
// Nur relevante Status anzeigen (nicht abgelehnt)
const sichtbar = planDaten.filter((p) => p.anfrage_status !== "abgelehnt");
// Sperrungen auf den sichtbaren Zeitraum eingrenzen
const sperrungenSichtbar = sperrungen.filter(
(s) => s.bis >= kalenderStart && s.von <= kalenderEnde
);
// Eindeutige Maschinen: zusammenfuehren aus Anfragen + Sperrungen, damit
// Sperrungen fuer reine "Leer-Maschinen" ebenfalls sichtbar sind.
const maschinenSet = new Set<string>();
for (const p of sichtbar) maschinenSet.add(p.maschine_name);
for (const s of sperrungenSichtbar) maschinenSet.add(s.maschine_name);
const maschinenOhneDouble = Array.from(maschinenSet).sort();
// Kalender-Tage generieren (ab kalenderStart statt immer ab heute)
const tage: string[] = [];
for (let i = 0; i < TAGE_GESAMT; i++) {
tage.push(addDays(kalenderStart, i));
}
// Monats-Separatoren
const monate: { label: string; start: number; breite: number }[] = [];
let currentMonat = "";
let startIdx = 0;
tage.forEach((d, i) => {
const m = d.slice(0, 7);
if (m !== currentMonat) {
if (currentMonat) {
monate.push({
label: new Date(currentMonat + "-01").toLocaleDateString("de-DE", {
month: "long",
year: "numeric",
}),
start: startIdx * TAG_BREITE,
breite: (i - startIdx) * TAG_BREITE,
});
}
currentMonat = m;
startIdx = i;
}
});
if (currentMonat) {
monate.push({
label: new Date(currentMonat + "-01").toLocaleDateString("de-DE", {
month: "long",
year: "numeric",
}),
start: startIdx * TAG_BREITE,
breite: (tage.length - startIdx) * TAG_BREITE,
});
}
const gesamtBreite = TAGE_GESAMT * TAG_BREITE;
if (maschinenOhneDouble.length === 0) {
return (
<div className="text-center py-12 text-slate-400 text-sm">
Keine geplanten Vermietungen oder Sperrungen im gewählten Zeitraum.
</div>
);
}
return (
<div>
{/* Legende */}
<div className="flex flex-wrap gap-4 mb-4 text-xs text-slate-600">
{Object.entries(STATUS_LABEL)
.filter(([k]) => k !== "abgelehnt")
.map(([k, v]) => (
<div key={k} className="flex items-center gap-1.5">
<span className={`inline-block w-3 h-3 rounded-sm border ${STATUS_FARBE[k]}`} />
{v}
</div>
))}
<div className="flex items-center gap-1.5">
<span className={`inline-block w-3 h-3 rounded-sm border ${SPERRUNG_STYLE}`} />
Gesperrt
</div>
</div>
{/* Scrollbarer Gantt */}
<div
ref={scrollRef}
className="overflow-x-auto border border-slate-200 bg-white"
style={{ maxHeight: "520px", overflowY: "auto" }}
>
<div style={{ minWidth: gesamtBreite + 160 }}>
{/* Monats-Header */}
<div className="flex sticky top-0 z-20 bg-[#1c1917] text-white">
<div
className="flex-shrink-0 text-xs font-medium px-3 py-2 border-r border-white/10"
style={{ width: 160 }}
>
Maschine
</div>
<div className="relative flex-1 overflow-hidden" style={{ width: gesamtBreite }}>
{monate.map((m) => (
<div
key={m.label}
className="absolute top-0 bottom-0 flex items-center px-2 text-xs font-semibold text-white/90 border-r border-white/10"
style={{ left: m.start, width: m.breite }}
>
{m.label}
</div>
))}
</div>
</div>
{/* Tages-Header */}
<div className="flex sticky top-8 z-10 bg-slate-50 border-b border-slate-200">
<div className="flex-shrink-0 border-r border-slate-200" style={{ width: 160 }} />
<div className="relative flex" style={{ width: gesamtBreite }}>
{tage.map((d, i) => {
const wochentag = new Date(d + "T00:00:00").getDay();
const istHeute = d === heuteISO;
const istSo = wochentag === 0;
const istMo = wochentag === 1;
const tag = d.slice(8);
return (
<div
key={d}
className={`flex-shrink-0 text-center text-[10px] py-1 border-r border-slate-100
${istHeute ? "bg-[#f7d334] text-[#1c1917] font-bold" : ""}
${istSo && !istHeute ? "bg-red-50 text-red-300" : ""}
${istMo && !istHeute ? "border-l border-l-slate-300" : ""}
${!istHeute && !istSo ? "text-slate-400" : ""}
`}
style={{ width: TAG_BREITE }}
title={new Date(d + "T00:00:00").toLocaleDateString("de-DE", {
weekday: "short",
day: "2-digit",
month: "2-digit",
})}
>
{tag}
</div>
);
})}
</div>
</div>
{/* Maschinen-Zeilen */}
{maschinenOhneDouble.map((maschine, rowIdx) => {
const positionen = sichtbar.filter((p) => p.maschine_name === maschine);
const rowBg = rowIdx % 2 === 0 ? "bg-white" : "bg-slate-50/60";
return (
<div key={maschine} className={`flex border-b border-slate-100 group ${rowBg}`} style={{ minHeight: 44 }}>
{/* Maschinenname */}
<div
className="flex-shrink-0 flex items-center px-3 border-r border-slate-200 text-xs font-medium text-slate-700 group-hover:bg-slate-100/60 transition-colors"
style={{ width: 160 }}
>
<span className="truncate" title={maschine}>{maschine}</span>
</div>
{/* Balkenfläche */}
<div className="relative flex-1" style={{ width: gesamtBreite, minHeight: 44 }}>
{/* Heute-Linie */}
<div
className="absolute top-0 bottom-0 w-px bg-[#f7d334]/40 z-10"
style={{ left: 0 }}
/>
{/* Wochenenden */}
{tage.map((d, i) => {
const wt = new Date(d + "T00:00:00").getDay();
if (wt !== 0) return null;
return (
<div
key={d}
className="absolute top-0 bottom-0 bg-red-50/50"
style={{ left: i * TAG_BREITE, width: TAG_BREITE }}
/>
);
})}
{/* Sperrungs-Balken (unter den Anfragen gerendert, damit
Anfragen bei einer unerwarteten Ueberlappung oben liegen.
Durch die Collision-Pruefung in /api/admin/sperrungen
sollte das in der Praxis aber nicht vorkommen.) */}
{sperrungenSichtbar
.filter((s) => s.maschine_name === maschine)
.map((s) => {
const startOff = daysBetween(kalenderStart, s.von);
const endOff = daysBetween(kalenderStart, s.bis);
const left = Math.max(0, startOff) * TAG_BREITE;
const right = Math.min(TAGE_GESAMT, endOff + 1) * TAG_BREITE;
const width = right - left;
if (width <= 0) return null;
return (
<div
key={`sperr-${s.sperr_id}`}
className={`absolute top-1.5 bottom-1.5 rounded-sm border text-[10px] flex items-center gap-1 px-1.5 overflow-hidden group/sperr z-0 ${SPERRUNG_STYLE}`}
style={{ left, width: Math.max(width - 2, 4) }}
title={`Gesperrt · ${s.grund} · ${formatDate(s.von)}${formatDate(s.bis)}`}
>
<Lock className="w-2.5 h-2.5 flex-shrink-0" />
<span className="truncate font-medium leading-tight">
{s.grund}
</span>
{/* Tooltip bei Hover */}
<div className="absolute left-0 top-full mt-1 z-30 hidden group-hover/sperr:block bg-[#1c1917] text-white text-[11px] rounded px-2 py-1.5 shadow-lg whitespace-nowrap pointer-events-none min-w-[160px]">
<p className="font-semibold flex items-center gap-1">
<Lock className="w-3 h-3" /> Sperrung
</p>
<p className="text-white/70 mt-0.5">{s.grund}</p>
<p className="text-white/70">
{formatDate(s.von)} {formatDate(s.bis)}
</p>
{s.notiz && (
<p className="text-white/50 text-[10px] mt-1 border-t border-white/10 pt-1 max-w-[220px] whitespace-normal">
{s.notiz}
</p>
)}
</div>
</div>
);
})}
{/* Vermietungs-Balken */}
{positionen.map((p) => {
const startOff = daysBetween(kalenderStart, p.mietbeginn);
const endOff = daysBetween(kalenderStart, p.mietende);
const left = Math.max(0, startOff) * TAG_BREITE;
const right = Math.min(TAGE_GESAMT, endOff + 1) * TAG_BREITE;
const width = right - left;
if (width <= 0) return null;
const umsatz = p.tagessatz != null
? p.tagessatz * p.gesamt_tage
: null;
return (
<div
key={p.id}
className={`absolute top-1.5 bottom-1.5 rounded-sm border text-white text-[10px] flex items-center px-1.5 overflow-hidden cursor-pointer group/bar ${STATUS_FARBE[p.anfrage_status] ?? "bg-slate-400 border-slate-500"} hover:shadow-lg transition-shadow`}
style={{ left, width: Math.max(width - 2, 4) }}
title={`${p.firma} · ${formatDate(p.mietbeginn)}${formatDate(p.mietende)} · ${p.gesamt_tage} Tage${umsatz != null ? ` · ${fmt(umsatz)}` : ""}`}
onClick={() => router.push(`/admin/anfragen/${p.anfrage_id}`)}
>
<span className="truncate font-medium leading-tight">{p.firma}</span>
{/* Tooltip bei Hover */}
<div className="absolute left-0 top-full mt-1 z-30 hidden group-hover/bar:block bg-[#1c1917] text-white text-[11px] rounded px-2 py-1.5 shadow-lg whitespace-nowrap pointer-events-none min-w-[160px]">
<p className="font-semibold">{p.firma}</p>
<p className="text-white/70 mt-0.5">{STATUS_LABEL[p.anfrage_status]}</p>
<p className="text-white/70">{formatDate(p.mietbeginn)} {formatDate(p.mietende)}</p>
<p className="text-white/70">{p.gesamt_tage} Miettage</p>
{umsatz != null && (
<p className="text-[#f7d334] font-semibold mt-0.5">{fmt(umsatz)} netto</p>
)}
<p className="text-white/50 text-[10px] mt-1 border-t border-white/10 pt-1">Klicken zum Öffnen </p>
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,343 @@
import { createServiceClient } from "@/lib/supabase";
import { AdminNav } from "@/components/admin/AdminNav";
import { GanttKalender } from "./GanttKalender";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Statistik & Planung" };
export const dynamic = "force-dynamic";
function fmt(n: number, nachkomma = 0) {
return n.toLocaleString("de-DE", {
minimumFractionDigits: nachkomma,
maximumFractionDigits: nachkomma,
});
}
// Maximale Balkenbreite in % relativ zum Top-Wert
function balkenBreite(wert: number, max: number): string {
if (max === 0) return "0%";
return `${Math.max(4, Math.round((wert / max) * 100))}%`;
}
export default async function StatistikPage() {
const db = createServiceClient();
const heute = new Date();
heute.setHours(0, 0, 0, 0);
const heuteISO = heute.toISOString().slice(0, 10);
const vor12Monaten = new Date(heute);
vor12Monaten.setMonth(vor12Monaten.getMonth() - 12);
const vor12ISO = vor12Monaten.toISOString().slice(0, 10);
const in3Monaten = new Date(heute);
in3Monaten.setMonth(in3Monaten.getMonth() + 3);
in3Monaten.setDate(in3Monaten.getDate() + 1);
const in3ISO = in3Monaten.toISOString().slice(0, 10);
// ── DB-Abfragen ──────────────────────────────────────────────────────────
const { data: anfragen } = await db
.from("anfragen")
.select("id, status, created_at, firma");
const { data: allePositionen } = await db
.from("anfragen_positionen")
.select(
"id, anfrage_id, maschine_name, maschine_kategorie, mietbeginn, mietende, gesamt_tage, tagessatz"
)
.gte("mietbeginn", vor12ISO)
.order("mietbeginn");
const { data: planRoh } = await db
.from("anfragen_positionen")
.select(
"id, anfrage_id, maschine_name, maschine_kategorie, mietbeginn, mietende, gesamt_tage, tagessatz"
)
.lte("mietbeginn", in3ISO)
.gte("mietende", heuteISO)
.order("mietbeginn");
const anfrageMap = new Map(
(anfragen ?? []).map((a) => [a.id, a])
);
// Status-Sets (nur abgeschlossene Mietvorgänge zählen, die zuvor bestätigt waren)
// Lade Audit Log um zu verifizieren: bestätigt → abgeschlossenen Workflow
const { data: auditLogs } = await db
.from("anfrage_status_audit")
.select("anfrage_id, status_zu");
// Map: anfrage_id → hat bestätigt Status durchlaufen?
const hatBestaetigt = new Map<string, boolean>();
(auditLogs ?? []).forEach((log: any) => {
if (log.status_zu === "bestaetigt") {
hatBestaetigt.set(log.anfrage_id, true);
}
});
const bestaetigtIds = new Set(
(anfragen ?? [])
.filter((a) => a.status === "abgeschlossen" && hatBestaetigt.has(a.id))
.map((a) => a.id)
);
// ── KPIs ────────────────────────────────────────────────────────────────
const kpiPos = (allePositionen ?? []).filter((p) => bestaetigtIds.has(p.anfrage_id));
const umsatzGesamt = kpiPos.reduce(
(s, p) => s + (p.tagessatz != null ? p.tagessatz * p.gesamt_tage : 0),
0
);
const mietTageGesamt = kpiPos.reduce((s, p) => s + (p.gesamt_tage ?? 0), 0);
const offenAnzahl = (anfragen ?? []).filter((a) => a.status === "offen").length;
// ── Auslastung pro Maschine ──────────────────────────────────────────────
const maschinenMap: Record<
string,
{ name: string; kategorie: string; tage: number; umsatz: number; einsaetze: number }
> = {};
for (const p of allePositionen ?? []) {
if (!bestaetigtIds.has(p.anfrage_id)) continue;
const key = p.maschine_name;
if (!maschinenMap[key]) {
maschinenMap[key] = {
name: p.maschine_name,
kategorie: p.maschine_kategorie ?? "",
tage: 0,
umsatz: 0,
einsaetze: 0,
};
}
maschinenMap[key].tage += p.gesamt_tage ?? 0;
maschinenMap[key].umsatz +=
p.tagessatz != null ? p.tagessatz * p.gesamt_tage : 0;
maschinenMap[key].einsaetze += 1;
}
const maschinenStats = Object.values(maschinenMap).sort((a, b) => b.tage - a.tage);
const maxTage = maschinenStats[0]?.tage ?? 1;
// ── Monatliche Entwicklung (letzte 6 Monate) ────────────────────────────
const monatsStats: {
monat: string;
label: string;
einsaetze: number;
tage: number;
umsatz: number;
}[] = [];
for (let i = 5; i >= 0; i--) {
const d = new Date(heute);
d.setMonth(d.getMonth() - i);
const monat = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
const label = d.toLocaleDateString("de-DE", { month: "short", year: "2-digit" });
const mPos = (allePositionen ?? []).filter(
(p) => bestaetigtIds.has(p.anfrage_id) && p.mietbeginn?.startsWith(monat)
);
monatsStats.push({
monat,
label,
einsaetze: mPos.length,
tage: mPos.reduce((s, p) => s + (p.gesamt_tage ?? 0), 0),
umsatz: mPos.reduce(
(s, p) => s + (p.tagessatz != null ? p.tagessatz * p.gesamt_tage : 0),
0
),
});
}
const maxUmsatz = Math.max(...monatsStats.map((m) => m.umsatz), 1);
// ── Planungs-Daten anreichern ─────────────────────────────────────────
const planDaten = (planRoh ?? []).map((p) => {
const a = anfrageMap.get(p.anfrage_id);
return {
...p,
anfrage_status: a?.status ?? "offen",
firma: a?.firma ?? "",
};
});
return (
<div className="min-h-screen bg-[#f5f5f4]">
<AdminNav />
<div className="max-w-7xl mx-auto px-4 py-8 space-y-8">
<div>
<h1 className="text-2xl font-bold text-[#1c1917] tracking-tight">
Statistik & Planung
</h1>
<p className="text-sm text-slate-500 mt-0.5">
Letzte 12 Monate · Bestätigte Vermietungen
</p>
</div>
{/* ── KPI-Cards ─────────────────────────────────────────────────── */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{
label: "Anfragen gesamt",
wert: fmt(anfragen?.length ?? 0),
sub: `${offenAnzahl} offen`,
farbe: "border-l-slate-400",
},
{
label: "Bestätigte Mieten",
wert: fmt(bestaetigtIds.size),
sub: "letzte 12 Monate",
farbe: "border-l-green-500",
},
{
label: "Vermietete Tage",
wert: fmt(mietTageGesamt),
sub: `Ø ${fmt(mietTageGesamt / Math.max(bestaetigtIds.size, 1), 1)} Tage/Miete`,
farbe: "border-l-[#f7d334]",
},
{
label: "Netto-Umsatz",
wert: `${fmt(umsatzGesamt)}`,
sub: `Ø ${fmt(umsatzGesamt / Math.max(6, 1))} €/Monat`,
farbe: "border-l-blue-500",
},
].map((k) => (
<div
key={k.label}
className={`bg-white border border-slate-200 border-l-4 ${k.farbe} p-4`}
>
<p className="text-xs text-slate-500 uppercase tracking-wide font-medium mb-1">
{k.label}
</p>
<p className="text-2xl font-bold text-[#1c1917] font-mono">{k.wert}</p>
<p className="text-xs text-slate-400 mt-1">{k.sub}</p>
</div>
))}
</div>
{/* ── Monatsüberblick (Balkendiagramm) ─────────────────────────── */}
<div className="bg-white border border-slate-200 p-5">
<h2 className="font-semibold text-slate-900 mb-5">
Umsatz letzte 6 Monate (netto)
</h2>
<div className="flex items-end gap-3 h-32">
{monatsStats.map((m) => (
<div key={m.monat} className="flex-1 flex flex-col items-center gap-1">
<span className="text-[10px] font-mono text-slate-500">
{m.umsatz > 0 ? `${fmt(m.umsatz)}` : ""}
</span>
<div className="w-full relative flex items-end" style={{ height: 80 }}>
<div
className="w-full bg-[#f7d334] rounded-sm transition-all"
style={{
height: `${Math.max(m.umsatz > 0 ? 8 : 0, Math.round((m.umsatz / maxUmsatz) * 80))}px`,
}}
/>
</div>
<span className="text-[10px] text-slate-400 font-medium">{m.label}</span>
<span className="text-[10px] text-slate-300">{m.einsaetze} Eins.</span>
</div>
))}
</div>
</div>
{/* ── Gantt-Kalender ───────────────────────────────────────────── */}
<div className="bg-white border border-slate-200 p-5">
<div className="flex items-baseline justify-between gap-4 mb-4">
<h2 className="font-semibold text-slate-900">
Vermietungsplanung aktuelle &amp; nächste 3 Monate
</h2>
<span className="text-xs text-slate-400 whitespace-nowrap">
{planDaten.filter((p) => p.anfrage_status !== "abgelehnt").length} Positionen
</span>
</div>
<GanttKalender planDaten={planDaten} heuteISO={heuteISO} />
</div>
{/* ── Auslastungstabelle pro Maschine ─────────────────────────── */}
<div className="bg-white border border-slate-200 p-5">
<h2 className="font-semibold text-slate-900 mb-4">
Auslastung pro Maschine
<span className="ml-2 text-sm font-normal text-slate-400">letzte 12 Monate</span>
</h2>
{maschinenStats.length === 0 ? (
<p className="text-sm text-slate-400 py-4 text-center">
Noch keine bestätigten Vermietungen vorhanden.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 text-xs text-slate-500 uppercase tracking-wide">
<th className="text-left py-2 pr-4 font-medium">Maschine</th>
<th className="text-left py-2 pr-4 font-medium hidden sm:table-cell">Kategorie</th>
<th className="text-right py-2 pr-4 font-medium">Einsätze</th>
<th className="text-right py-2 pr-4 font-medium">Miettage</th>
<th className="py-2 pr-4 font-medium hidden md:table-cell" style={{ minWidth: 120 }}>
Auslastung
</th>
<th className="text-right py-2 font-medium">Umsatz netto</th>
</tr>
</thead>
<tbody>
{maschinenStats.map((m, i) => (
<tr
key={m.name}
className={`border-b border-slate-100 last:border-0 ${
i % 2 === 0 ? "bg-white" : "bg-slate-50/50"
}`}
>
<td className="py-2.5 pr-4 font-medium text-slate-800">{m.name}</td>
<td className="py-2.5 pr-4 text-slate-500 hidden sm:table-cell text-xs">
{m.kategorie}
</td>
<td className="py-2.5 pr-4 text-right font-mono text-slate-700">
{m.einsaetze}×
</td>
<td className="py-2.5 pr-4 text-right font-mono font-semibold text-[#1c1917]">
{fmt(m.tage)}
</td>
<td className="py-2.5 pr-4 hidden md:table-cell">
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-slate-100 rounded-full overflow-hidden">
<div
className="h-full bg-[#f7d334] rounded-full"
style={{ width: balkenBreite(m.tage, maxTage) }}
/>
</div>
<span className="text-xs text-slate-400 w-8 text-right">
{Math.round((m.tage / maxTage) * 100)}%
</span>
</div>
</td>
<td className="py-2.5 text-right font-mono text-slate-700">
{m.umsatz > 0 ? `${fmt(m.umsatz)}` : ""}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 border-[#f7d334]">
<td className="py-3 pr-4 font-bold text-slate-900" colSpan={2}>
Gesamt
</td>
<td className="py-3 pr-4 text-right font-mono font-bold text-slate-900">
{fmt(maschinenStats.reduce((s, m) => s + m.einsaetze, 0))}×
</td>
<td className="py-3 pr-4 text-right font-mono font-bold text-[#1c1917]">
{fmt(mietTageGesamt)}
</td>
<td className="hidden md:table-cell" />
<td className="py-3 text-right font-mono font-bold text-[#1c1917]">
{fmt(umsatzGesamt)}
</td>
</tr>
</tfoot>
</table>
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,175 @@
import { NextResponse } from "next/server";
import { createServiceClient } from "@/lib/supabase";
import { requireAdmin } from "@/lib/admin-auth";
export const dynamic = "force-dynamic";
export async function GET() {
const check = await requireAdmin();
if (check instanceof NextResponse) return check;
const db = createServiceClient();
const heute = new Date();
heute.setHours(0, 0, 0, 0);
const heuteISO = heute.toISOString().slice(0, 10);
// Fenster: 12 Monate zurück für Statistiken
const vor12Monaten = new Date(heute);
vor12Monaten.setMonth(vor12Monaten.getMonth() - 12);
const vor12ISO = vor12Monaten.toISOString().slice(0, 10);
// Fenster: 3 Monate voraus für Planungsübersicht
const in3Monaten = new Date(heute);
in3Monaten.setMonth(in3Monaten.getMonth() + 3);
in3Monaten.setDate(in3Monaten.getDate() + 1);
const in3ISO = in3Monaten.toISOString().slice(0, 10);
// ── Alle Anfragen (Status + Datum) ─────────────────────────────────────
const { data: anfragen } = await db
.from("anfragen")
.select("id, status, created_at, firma, telefon, email")
.order("created_at", { ascending: false });
// ── Alle Positionen (Statistik: letzte 12 Monate) ──────────────────────
const { data: allePositionen } = await db
.from("anfragen_positionen")
.select(
"id, anfrage_id, maschine_name, maschine_kategorie, mietbeginn, mietende, gesamt_tage, tagessatz, lieferung"
)
.gte("mietbeginn", vor12ISO)
.order("mietbeginn");
// ── Planungs-Positionen (heute bis +3 Monate) ──────────────────────────
const { data: planPositionen } = await db
.from("anfragen_positionen")
.select(
"id, anfrage_id, maschine_name, maschine_kategorie, mietbeginn, mietende, gesamt_tage, tagessatz"
)
.lte("mietbeginn", in3ISO)
.gte("mietende", heuteISO)
.order("mietbeginn");
const anfrageMap = new Map(
(anfragen ?? []).map((a) => [a.id, a])
);
// ── KPI-Berechnung (nur abgeschlossene Mietvorgänge zählen, die zuvor bestätigt waren) ──
// Lade Audit Log um zu verifizieren: bestätigt → abgeschlossenen Workflow
const { data: auditLogs } = await db
.from("anfrage_status_audit")
.select("anfrage_id, status_zu");
// Map: anfrage_id → hat bestätigt Status durchlaufen?
const hatBestaetigt = new Map<string, boolean>();
(auditLogs ?? []).forEach((log: any) => {
if (log.status_zu === "bestaetigt") {
hatBestaetigt.set(log.anfrage_id, true);
}
});
const bestaetigtIds = new Set(
(anfragen ?? [])
.filter((a) => a.status === "abgeschlossen" && hatBestaetigt.has(a.id))
.map((a) => a.id)
);
const kpiPositionen = (allePositionen ?? []).filter((p) =>
bestaetigtIds.has(p.anfrage_id)
);
const umsatzGesamt = kpiPositionen.reduce(
(sum, p) => sum + (p.tagessatz != null ? p.tagessatz * p.gesamt_tage : 0),
0
);
const mietTageGesamt = kpiPositionen.reduce(
(sum, p) => sum + (p.gesamt_tage ?? 0),
0
);
// ── Auslastung pro Maschine ─────────────────────────────────────────────
const maschinenMap: Record<
string,
{ name: string; kategorie: string; tage: number; umsatz: number; anfragen: number }
> = {};
for (const p of allePositionen ?? []) {
if (!bestaetigtIds.has(p.anfrage_id)) continue;
const key = p.maschine_name;
if (!maschinenMap[key]) {
maschinenMap[key] = {
name: p.maschine_name,
kategorie: p.maschine_kategorie ?? "",
tage: 0,
umsatz: 0,
anfragen: 0,
};
}
maschinenMap[key].tage += p.gesamt_tage ?? 0;
maschinenMap[key].umsatz +=
p.tagessatz != null ? p.tagessatz * p.gesamt_tage : 0;
maschinenMap[key].anfragen += 1;
}
const maschinenStats = Object.values(maschinenMap).sort(
(a, b) => b.tage - a.tage
);
// ── Monatliche Entwicklung (letzte 6 Monate) ───────────────────────────
const monatsStats: {
monat: string;
label: string;
anfragen: number;
tage: number;
umsatz: number;
}[] = [];
for (let i = 5; i >= 0; i--) {
const d = new Date(heute);
d.setMonth(d.getMonth() - i);
const monat = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
const label = d.toLocaleDateString("de-DE", { month: "short", year: "2-digit" });
const mPositionen = (allePositionen ?? []).filter(
(p) =>
bestaetigtIds.has(p.anfrage_id) &&
p.mietbeginn?.startsWith(monat)
);
monatsStats.push({
monat,
label,
anfragen: mPositionen.length,
tage: mPositionen.reduce((s, p) => s + (p.gesamt_tage ?? 0), 0),
umsatz: mPositionen.reduce(
(s, p) =>
s + (p.tagessatz != null ? p.tagessatz * p.gesamt_tage : 0),
0
),
});
}
// ── Planungs-Daten mit Anfragestatus anreichern ─────────────────────────
const planDaten = (planPositionen ?? []).map((p) => {
const anfrage = anfrageMap.get(p.anfrage_id);
return {
...p,
anfrage_status: anfrage?.status ?? "offen",
firma: anfrage?.firma ?? "",
};
});
return NextResponse.json({
kpi: {
anfragenGesamt: anfragen?.length ?? 0,
bestaetigtGesamt: bestaetigtIds.size,
umsatzGesamt,
mietTageGesamt,
},
maschinenStats,
monatsStats,
planDaten,
heuteISO,
in3ISO,
});
}

182
package-lock.json generated
View File

@ -8,6 +8,10 @@
"name": "mbo-tech-it", "name": "mbo-tech-it",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@supabase/ssr": "^0.10.2",
"@supabase/supabase-js": "^2.104.1",
"bcryptjs": "^3.0.3",
"lucide-react": "^1.11.0",
"next": "^15.2.0", "next": "^15.2.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nodemailer": "^8.0.6", "nodemailer": "^8.0.6",
@ -15,6 +19,7 @@
"react-dom": "^19.0.0" "react-dom": "^19.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22", "@types/node": "^22",
"@types/nodemailer": "^8.0.0", "@types/nodemailer": "^8.0.0",
"@types/react": "^19", "@types/react": "^19",
@ -725,6 +730,104 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/@supabase/auth-js": {
"version": "2.104.1",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.104.1.tgz",
"integrity": "sha512-pqFnDKekq1isqlqnzqzyJ3mzmho+o+FjfVTqhKY3PFlwj2anx3OPznO1kbo1ZEwD8zg1r4EAFf/7pplLyX0ocQ==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.104.1",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.104.1.tgz",
"integrity": "sha512-JjAH4JN9rZzxh4plQnILPrQZXAG6ccoRS6z9hQAGmXpRSwJA+7CWbsDV2R82I8MROlGDsjqj1Ot/cWpTfdf6xg==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/phoenix": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.0.tgz",
"integrity": "sha512-RHSx8bHS02xwfHdAbX5Lpbo6PXbgyf7lTaXTlwtFDPwOIw64NnVRwFAXGojHhjtVYI+PEPNSWwkL90f4agN3bw==",
"license": "MIT"
},
"node_modules/@supabase/postgrest-js": {
"version": "2.104.1",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.104.1.tgz",
"integrity": "sha512-RqlLpvgXsjcc27fLyHNGm3zN0KDWXbkdTdaFtaEdX83RsTEqH7BAmshH7zoUMml5lL04naUeRjS3B81O6jZcJw==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.104.1",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.104.1.tgz",
"integrity": "sha512-dVJHhFB2ErBd0/2qE9G8CedCrGoAtBfL9Q4zbSMXO7b1Cpld916ljSiX21mURUqijPf1WoPQG4Bp/averUzk/g==",
"license": "MIT",
"dependencies": {
"@supabase/phoenix": "^0.4.0",
"@types/ws": "^8.18.1",
"tslib": "2.8.1",
"ws": "^8.18.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/ssr": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.10.2.tgz",
"integrity": "sha512-JFbchN63CXLFHJRNT7udec4/RoD9PmXkSGko3QSO6vUuqGBtSzdmxR7FPfQNr7SuFd65I7Xv46q66ALjEN1cgQ==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.2"
},
"peerDependencies": {
"@supabase/supabase-js": "^2.102.1"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.104.1",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.104.1.tgz",
"integrity": "sha512-2bQaLbkRshctkUVuqamwYZDEd+0cGSc9DY9sjh92DcA5hu1F/1AP8p6gxGr76sgdK9Ngi0rh+2Kdh+uC4hcnGA==",
"license": "MIT",
"dependencies": {
"iceberg-js": "^0.8.1",
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.104.1",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.104.1.tgz",
"integrity": "sha512-E0H/CtVmaGjiAy+ieZ5ZB/1EqxXcGdaFaAc23AE5zaYfz6NtCNDcmaEdoGPYMPFH5pE6drGG6e3ljPmkFoGVxQ==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.104.1",
"@supabase/functions-js": "2.104.1",
"@supabase/postgrest-js": "2.104.1",
"@supabase/realtime-js": "2.104.1",
"@supabase/storage-js": "2.104.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@swc/helpers": { "node_modules/@swc/helpers": {
"version": "0.5.15", "version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@ -734,11 +837,17 @@
"tslib": "^2.8.0" "tslib": "^2.8.0"
} }
}, },
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.19.15", "version": "22.19.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
@ -774,6 +883,15 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/any-promise": { "node_modules/any-promise": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
@ -852,6 +970,15 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/binary-extensions": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@ -996,6 +1123,19 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cssesc": { "node_modules/cssesc": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@ -1175,6 +1315,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/iceberg-js": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/is-binary-path": { "node_modules/is-binary-path": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@ -1267,6 +1416,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/lucide-react": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.11.0.tgz",
"integrity": "sha512-UOhjdztXCgdBReRcIhsvz2siIBogfv/lhJEIViCpLt924dO+GDms9T7DNoucI23s6kEPpe988m5N0D2ajnzb2g==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/merge2": { "node_modules/merge2": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@ -2072,7 +2230,6 @@
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
@ -2112,6 +2269,27 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
} }
} }
} }

View File

@ -9,6 +9,10 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@supabase/ssr": "^0.10.2",
"@supabase/supabase-js": "^2.104.1",
"bcryptjs": "^3.0.3",
"lucide-react": "^1.11.0",
"next": "^15.2.0", "next": "^15.2.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nodemailer": "^8.0.6", "nodemailer": "^8.0.6",
@ -16,6 +20,7 @@
"react-dom": "^19.0.0" "react-dom": "^19.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22", "@types/node": "^22",
"@types/nodemailer": "^8.0.0", "@types/nodemailer": "^8.0.0",
"@types/react": "^19", "@types/react": "^19",