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:
parent
08377f3a8a
commit
9e56f1b5a3
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { SessionTimeoutProvider } from "@/components/admin/SessionTimeoutProvider";
|
||||||
|
|
||||||
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <SessionTimeoutProvider>{children}</SessionTimeoutProvider>;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
redirect("/admin/analytics");
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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)",
|
||||||
|
|
|
||||||
|
|
@ -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 ?? [] });
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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` +
|
||||||
|
|
|
||||||
|
|
@ -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 ?? [] });
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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}</>;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
|
@ -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 }> {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
256
lib/supabase.ts
256
lib/supabase.ts
|
|
@ -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!
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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*"],
|
||||||
|
};
|
||||||
|
|
@ -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.
|
||||||
|
```
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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 – ${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 & 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 & 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,5–3t) für Garten- und enge Baustellenarbeiten, Kettenbagger (5–16t) 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. 30–40% 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,5–3t) für Garten- und enge Baustellenarbeiten, Kettenbagger (5–16t) 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. 30–40% 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}`);
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
```
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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}</>;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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.
|
||||||
|
```
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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.
|
||||||
|
```
|
||||||
|
|
@ -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 ?? [] });
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
```
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue