feat(branding): add global dynamic theme system and fix 2fa mail

This commit is contained in:
DanielS
2026-08-14 15:57:29 +02:00
parent 009b167cbd
commit 9ecdc645e8
22 changed files with 1764 additions and 313 deletions

View File

@@ -8,11 +8,16 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Download, Upload, Database, AlertTriangle, Loader2, KeyRound,
Server, CheckCircle2, XCircle, Eye, EyeOff, Wifi, Sliders, ShieldAlert, FileArchive
Download, Upload, Database, Loader2, KeyRound,
Server, CheckCircle2, XCircle, Eye, EyeOff, Wifi, Sliders, ShieldAlert, FileArchive,
Building2, MapPin, Receipt, Palette, Save
} from 'lucide-react';
import { createClient } from '@/lib/supabase/client';
import { saveLicServerConfig, testLicServerConnection } from '@/lib/actions/licserver-config';
import { getBrandingSettings, saveBrandingSettings } from '@/lib/actions/branding';
import type { BrandingSettings } from '@/lib/constants/branding';
import { ColorThemePicker } from '@/components/admin/ColorThemePicker';
import { useTheme } from '@/components/ThemeProvider';
import { useRouter } from 'next/navigation';
const containerVariants = {
@@ -56,6 +61,25 @@ export default function AdminSettings() {
const [licStatus, setLicStatus] = useState<{ ok: boolean; message: string } | null>(null);
const [licMsg, setLicMsg] = useState('');
const [licMsgType, setLicMsgType] = useState<'success' | 'error' | ''>('');
// Branding State
const [branding, setBranding] = useState<BrandingSettings>({
companyName: '',
street: '',
zip: '',
city: '',
billingStreet: '',
billingZip: '',
billingCity: '',
sameBillingAddress: true,
colorScheme: 'modern_blue',
primaryColor: '#2563eb',
accentColor: '#38bdf8',
});
const [brandingSaving, setBrandingSaving] = useState(false);
const [brandingMsg, setBrandingMsg] = useState('');
const [brandingMsgType, setBrandingMsgType] = useState<'success' | 'error' | ''>('');
const router = useRouter();
useEffect(() => {
@@ -68,10 +92,10 @@ export default function AdminSettings() {
return;
}
// Parallele Abfrage von User-Rolle & LicServer Settings
const [userRes, licRes] = await Promise.all([
const [userRes, licRes, brandRes] = await Promise.all([
supabase.from('users').select('role').eq('id', user.id).single(),
supabase.from('settings').select('licserver_base_url, licserver_api_key').eq('id', 'licserver').maybeSingle()
supabase.from('settings').select('licserver_base_url, licserver_api_key').eq('id', 'licserver').maybeSingle(),
getBrandingSettings()
]);
if (userRes.error || !userRes.data || userRes.data.role === 'verwaltung') {
@@ -86,6 +110,10 @@ export default function AdminSettings() {
setLicUrl(licRes.data.licserver_base_url || '');
setLicKey(licRes.data.licserver_api_key || '');
}
if (brandRes) {
setBranding(brandRes);
}
} catch (err) {
console.error("Fehler bei checkAccess in Einstellungen:", err);
} finally {
@@ -161,6 +189,29 @@ export default function AdminSettings() {
}
};
const { refreshBranding } = useTheme();
const handleSaveBranding = async () => {
setBrandingSaving(true);
setBrandingMsg('');
try {
const res = await saveBrandingSettings(branding);
if (res.success) {
await refreshBranding();
setBrandingMsgType('success');
setBrandingMsg('Firmendaten & Farbschema erfolgreich gespeichert.');
} else {
setBrandingMsgType('error');
setBrandingMsg(res.error || 'Fehler beim Speichern.');
}
} catch (err: any) {
setBrandingMsgType('error');
setBrandingMsg(err.message || 'Fehler beim Speichern.');
} finally {
setBrandingSaving(false);
}
};
if (loading) {
return <div className="p-8 text-white flex justify-center items-center"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>;
}
@@ -176,7 +227,7 @@ export default function AdminSettings() {
<div>
<h1 className="text-3xl font-extrabold tracking-tight">Admin Einstellungen</h1>
<p className="text-slate-400 text-xs mt-1">
Zentrales Bento-Dashboard für System-, Backup- und Lizenz-Konfigurationen.
Zentrales Dashboard für Firmendaten, Branding, Backup &amp; Lizenz-Konfigurationen.
</p>
</div>
</motion.div>
@@ -203,7 +254,7 @@ export default function AdminSettings() {
animate="visible"
className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-5"
>
{/* Bento Item 1: System Modus & Banner (1 Spalte) */}
{/* Bento Item 1: System Modus & Banner */}
<motion.div
variants={cardVariants}
whileHover={{ y: -3 }}
@@ -226,7 +277,7 @@ export default function AdminSettings() {
</div>
</motion.div>
{/* Bento Item 2: Daten Export (1 Spalte) */}
{/* Bento Item 2: Daten Export */}
<motion.div
variants={cardVariants}
whileHover={{ y: -3 }}
@@ -239,7 +290,7 @@ export default function AdminSettings() {
<div>
<h3 className="font-bold text-base text-white">DB Backup</h3>
<p className="text-xs text-slate-400 mt-1 leading-relaxed">
Lädt alle Produkte, Firmen, Lizenzen & Einstellungen als ZIP-Archiv herunter.
Lädt alle Produkte, Firmen, Lizenzen &amp; Einstellungen als ZIP-Archiv herunter.
</p>
</div>
</div>
@@ -256,7 +307,7 @@ export default function AdminSettings() {
</Button>
</motion.div>
{/* Bento Item 3: Daten Import (2 Spalten / Span 2 auf Desktop) */}
{/* Bento Item 3: Daten Import */}
<motion.div
variants={cardVariants}
whileHover={{ y: -3 }}
@@ -309,7 +360,214 @@ export default function AdminSettings() {
</div>
</motion.div>
{/* Bento Item 4: LicServer Konfiguration (Breites Bento: 4 Spalten auf Large) */}
{/* Bento Item 4: Firmendaten & Anschriften (4 Spalten) */}
<motion.div
variants={cardVariants}
whileHover={{ y: -3 }}
className="md:col-span-3 lg:col-span-4 p-6 rounded-2xl bg-slate-900/60 border border-slate-800 backdrop-blur-md space-y-5 hover:border-blue-500/30 transition-all duration-300 shadow-md"
>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 border-b border-slate-800/80 pb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-blue-500/10 border border-blue-500/20 text-blue-400 flex items-center justify-center">
<Building2 className="w-5 h-5" />
</div>
<div>
<h3 className="font-bold text-lg text-white">Firmendaten &amp; Rechnungsadresse</h3>
<p className="text-xs text-slate-400">Verwaltung der eigenen Unternehmensanschrift für Rechnungen und Dokumente</p>
</div>
</div>
<Button
onClick={handleSaveBranding}
disabled={brandingSaving}
className="bg-blue-600 hover:bg-blue-500 text-white rounded-xl px-5 h-10 text-xs font-bold shrink-0"
>
{brandingSaving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Save className="w-4 h-4 mr-2" />}
Änderungen Speichern
</Button>
</div>
{brandingMsg && (
<div className={`p-3 rounded-xl text-xs font-medium border ${
brandingMsgType === 'success' ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-400' : 'bg-destructive/10 border-destructive/20 text-destructive'
}`}>
{brandingMsg}
</div>
)}
<div className="space-y-4">
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs font-bold text-slate-300">Firmenname *</Label>
<Input
value={branding.companyName}
onChange={(e) => setBranding({ ...branding, companyName: e.target.value })}
placeholder="z. B. Meine Firma GmbH"
className="bg-slate-950/80 border-slate-800 text-white text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-bold text-slate-300">Logo-URL (Optional)</Label>
<Input
value={branding.logoUrl || ''}
onChange={(e) => setBranding({ ...branding, logoUrl: e.target.value })}
placeholder="https://domain.de/logo.png oder /assets/logo.png"
className="bg-slate-950/80 border-slate-800 text-white text-sm"
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-bold text-slate-300">Entwickler / Subline Beschriftung (Footer & Header)</Label>
<Input
value={branding.developerFooter || ''}
onChange={(e) => setBranding({ ...branding, developerFooter: e.target.value })}
placeholder="B2B Shop made by hephex"
className="bg-slate-950/80 border-slate-800 text-white text-sm"
/>
</div>
{/* Anschrift */}
<div className="grid md:grid-cols-2 gap-4 pt-2 border-t border-slate-800">
<div className="space-y-3">
<span className="text-xs font-bold text-slate-300 flex items-center gap-1.5">
<MapPin className="w-3.5 h-3.5 text-blue-400" /> Hauptanschrift
</span>
<div className="space-y-1.5">
<Label className="text-xs text-slate-400">Straße &amp; Nr.</Label>
<Input
value={branding.street}
onChange={(e) => setBranding({ ...branding, street: e.target.value })}
placeholder="Musterstraße 12"
className="bg-slate-950/80 border-slate-800 text-white text-xs"
/>
</div>
<div className="grid grid-cols-3 gap-2">
<div>
<Label className="text-xs text-slate-400">PLZ</Label>
<Input
value={branding.zip}
onChange={(e) => setBranding({ ...branding, zip: e.target.value })}
placeholder="12345"
className="bg-slate-950/80 border-slate-800 text-white text-xs"
/>
</div>
<div className="col-span-2">
<Label className="text-xs text-slate-400">Ort</Label>
<Input
value={branding.city}
onChange={(e) => setBranding({ ...branding, city: e.target.value })}
placeholder="Musterstadt"
className="bg-slate-950/80 border-slate-800 text-white text-xs"
/>
</div>
</div>
</div>
{/* Rechnungsadresse */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-slate-300 flex items-center gap-1.5">
<Receipt className="w-3.5 h-3.5 text-blue-400" /> Rechnungsadresse
</span>
<label className="flex items-center gap-1.5 text-xs text-slate-400 cursor-pointer">
<input
type="checkbox"
checked={branding.sameBillingAddress}
onChange={(e) => setBranding({ ...branding, sameBillingAddress: e.target.checked })}
className="w-3.5 h-3.5 rounded border-slate-700 bg-slate-950 text-blue-500"
/>
Gleiche wie Hauptanschrift
</label>
</div>
{!branding.sameBillingAddress ? (
<div className="space-y-3 p-3 rounded-xl bg-slate-950/40 border border-slate-800">
<div className="space-y-1.5">
<Label className="text-xs text-slate-400">Rechnungsstraße &amp; Nr.</Label>
<Input
value={branding.billingStreet}
onChange={(e) => setBranding({ ...branding, billingStreet: e.target.value })}
placeholder="Rechnungsstraße 45"
className="bg-slate-950/80 border-slate-800 text-white text-xs"
/>
</div>
<div className="grid grid-cols-3 gap-2">
<div>
<Label className="text-xs text-slate-400">PLZ</Label>
<Input
value={branding.billingZip}
onChange={(e) => setBranding({ ...branding, billingZip: e.target.value })}
placeholder="54321"
className="bg-slate-950/80 border-slate-800 text-white text-xs"
/>
</div>
<div className="col-span-2">
<Label className="text-xs text-slate-400">Ort</Label>
<Input
value={branding.billingCity}
onChange={(e) => setBranding({ ...branding, billingCity: e.target.value })}
placeholder="Rechnungsstadt"
className="bg-slate-950/80 border-slate-800 text-white text-xs"
/>
</div>
</div>
</div>
) : (
<div className="p-3 rounded-xl bg-slate-950/40 border border-slate-800/60 text-xs text-slate-500 italic">
Verwendet automatisch die Hauptanschrift oben.
</div>
)}
</div>
</div>
</div>
</motion.div>
{/* Bento Item 5: Farbschema & Design-System (4 Spalten) */}
<motion.div
variants={cardVariants}
whileHover={{ y: -3 }}
className="md:col-span-3 lg:col-span-4 p-6 rounded-2xl bg-slate-900/60 border border-slate-800 backdrop-blur-md space-y-5 hover:border-violet-500/30 transition-all duration-300 shadow-md"
>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 border-b border-slate-800/80 pb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-violet-500/10 border border-violet-500/20 text-violet-400 flex items-center justify-center">
<Palette className="w-5 h-5" />
</div>
<div>
<h3 className="font-bold text-lg text-white">Farbschema &amp; Webshop Styling</h3>
<p className="text-xs text-slate-400">Wählen Sie das primäre Farbschema für Buttons, Akzente und Highlights</p>
</div>
</div>
<Button
onClick={handleSaveBranding}
disabled={brandingSaving}
className="bg-violet-600 hover:bg-violet-500 text-white rounded-xl px-5 h-10 text-xs font-bold shrink-0"
>
{brandingSaving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Save className="w-4 h-4 mr-2" />}
Farben Speichern
</Button>
</div>
<ColorThemePicker
colorScheme={branding.colorScheme}
primaryColor={branding.primaryColor}
accentColor={branding.accentColor}
companyName={branding.companyName}
onChange={(scheme, primary, accent) =>
setBranding({
...branding,
colorScheme: scheme,
primaryColor: primary,
accentColor: accent,
})
}
/>
</motion.div>
{/* Bento Item 6: LicServer Konfiguration */}
<motion.div
variants={cardVariants}
whileHover={{ y: -3 }}
@@ -326,7 +584,6 @@ export default function AdminSettings() {
</div>
</div>
{/* LicServer Status Pill */}
{licStatus && (
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-semibold border ${
licStatus.ok
@@ -348,7 +605,6 @@ export default function AdminSettings() {
)}
<div className="grid md:grid-cols-2 gap-4">
{/* Base URL */}
<div className="space-y-1.5">
<Label htmlFor="lic-url" className="text-xs font-bold text-slate-300 flex items-center gap-1.5">
<Server className="w-3.5 h-3.5 text-slate-400" /> Server URL
@@ -362,7 +618,6 @@ export default function AdminSettings() {
/>
</div>
{/* API Key */}
<div className="space-y-1.5">
<Label htmlFor="lic-key" className="text-xs font-bold text-slate-300 flex items-center gap-1.5">
<KeyRound className="w-3.5 h-3.5 text-slate-400" /> API-Key
@@ -388,7 +643,6 @@ export default function AdminSettings() {
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center justify-end gap-3 pt-2">
<Button
id="lic-save-btn"

View File

@@ -2,7 +2,6 @@
@tailwind components;
@tailwind utilities;
html {
color-scheme: dark;
}
@@ -19,6 +18,17 @@ html {
--oklch-text-muted: 0.70 0.01 148;
--oklch-border: 0.31 0.012 148;
/* Dynamische Branding Variablen */
--primary-custom: #2563eb;
--accent-custom: #38bdf8;
--success-custom: #10b981;
--warning-custom: #f59e0b;
--destructive-custom: #ef4444;
--bg-glow-1: #3b82f6;
--bg-glow-2: #1d4ed8;
--gradient-from: #2563eb;
--gradient-to: #1e40af;
/* Classic HSL Mappings */
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
@@ -42,6 +52,75 @@ html {
--radius: 0.75rem;
}
/* Dynamische Brand-Farbanpassungen */
.bg-blue-600, .bg-blue-500, .bg-violet-600, .bg-purple-600 {
background-color: var(--primary-custom) !important;
}
.hover\:bg-blue-500:hover, .hover\:bg-violet-500:hover {
background-color: var(--primary-custom) !important;
filter: brightness(1.1);
}
.text-blue-500, .text-blue-400, .text-blue-600, .text-violet-400, .text-sky-400 {
color: var(--primary-custom) !important;
}
.border-blue-500, .border-blue-600, .border-violet-500 {
border-color: var(--primary-custom) !important;
}
.from-blue-600, .from-blue-500, .from-violet-600 {
--tw-gradient-from: var(--gradient-from, var(--primary-custom)) !important;
--tw-gradient-to: rgb(255 255 255 / 0) !important;
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to) !important;
}
.to-indigo-600, .to-indigo-500, .to-sky-500, .to-sky-400, .to-violet-500 {
--tw-gradient-to: var(--gradient-to, var(--accent-custom)) !important;
}
/* Dynamische Signalfarben (Success, Warning, Destructive) */
.bg-emerald-500, .bg-green-500, .bg-emerald-600, .bg-green-600 {
background-color: var(--success-custom) !important;
}
.text-emerald-400, .text-green-400, .text-emerald-500, .text-green-500, .text-green-600 {
color: var(--success-custom) !important;
}
.border-emerald-500, .border-green-500 {
border-color: var(--success-custom) !important;
}
.bg-amber-500, .bg-amber-600 {
background-color: var(--warning-custom) !important;
}
.text-amber-400, .text-amber-500, .text-amber-600 {
color: var(--warning-custom) !important;
}
.border-amber-500, .border-amber-600 {
border-color: var(--warning-custom) !important;
}
.bg-red-500, .bg-red-600, .bg-rose-500, .bg-rose-600 {
background-color: var(--destructive-custom) !important;
}
.text-red-500, .text-red-400, .text-rose-400, .text-rose-500 {
color: var(--destructive-custom) !important;
}
.border-red-500, .border-rose-500 {
border-color: var(--destructive-custom) !important;
}
.blur-\[120px\] {
background-color: var(--bg-glow-1) !important;
}
input, select, textarea, option {
background-color: oklch(var(--oklch-surface)) !important;
color: oklch(var(--oklch-text-main)) !important;
@@ -49,9 +128,9 @@ input, select, textarea, option {
}
input:focus, select:focus, textarea:focus {
border-color: oklch(var(--oklch-primary)) !important;
border-color: var(--primary-custom) !important;
outline: none !important;
box-shadow: 0 0 0 2px oklch(var(--oklch-primary) / 0.2) !important;
box-shadow: 0 0 0 2px var(--primary-custom) !important;
}
option {

View File

@@ -1,6 +1,6 @@
import type { Metadata } from "next";
import { Geist } from "next/font/google";
import { ThemeProvider } from "next-themes";
import { ThemeProvider } from "@/components/ThemeProvider";
import { InactivityTracker } from "@/components/inactivity-tracker";
import { HeaderWrapper } from "@/components/HeaderWrapper";
import { Navbar } from "@/components/Navbar";

View File

@@ -2,29 +2,6 @@
import React, { useEffect, useRef } from 'react'
interface OKLCHColor {
l: number
c: number
h: number
}
// Monochromes Farbkonzept im OKLCH-Raum
const BASE_BG = { l: 0.145, c: 0.01, h: 148 }
const BASE_DIM = { l: 0.50, c: 0.01, h: 148 }
const HIGHLIGHT_BRIGHT = { l: 0.95, c: 0.01, h: 148 }
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t
}
function lerpOKLCH(c1: OKLCHColor, c2: OKLCHColor, t: number): OKLCHColor {
return {
l: lerp(c1.l, c2.l, t),
c: lerp(c1.c, c2.c, t),
h: lerp(c1.h, c2.h, t)
}
}
export function AsciiShaderBackground({ className = '' }: { className?: string }) {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
@@ -47,6 +24,32 @@ export function AsciiShaderBackground({ className = '' }: { className?: string }
let scrollProgress = 0
let targetScrollProgress = 0
const getDynamicHue = (): number => {
try {
const hex = getComputedStyle(document.documentElement).getPropertyValue('--primary-custom').trim() || '#2563eb'
let c = hex.replace('#', '')
if (c.length === 3) c = c.split('').map(x => x + x).join('')
const r = parseInt(c.substring(0, 2), 16) / 255
const g = parseInt(c.substring(2, 4), 16) / 255
const b = parseInt(c.substring(4, 6), 16) / 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
let h = 0
if (max !== min) {
const d = max - min
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break
case g: h = (b - r) / d + 2; break
case b: h = (r - g) / d + 4; break
}
h /= 6
}
return Math.round(h * 360)
} catch (e) {
return 217
}
}
const resize = () => {
const width = window.innerWidth
const height = window.innerHeight
@@ -99,54 +102,40 @@ export function AsciiShaderBackground({ className = '' }: { className?: string }
return
}
// Scroll-gesteuerter Dimmer
const scrollDimFactor = (1 - scrollProgress * 0.4) * scrollFade
const currentDimColor = {
...BASE_DIM,
l: BASE_DIM.l * scrollDimFactor
}
ctx.font = `${fontSize}px monospace`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const timeSec = time * 0.001
const activeHue = getDynamicHue()
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const x = c * fontSize + fontSize / 2
const y = r * fontSize + fontSize / 2
// Abstandsvektor zur gedämpften Mausposition
const dx = x - mouse.x
const dy = y - mouse.y
const dist = Math.sqrt(dx * dx + dy * dy)
// Maus-Störung (Vektorfeld-Ablenkung): Phase & Amplitude lokal beeinflussen
const mouseInfluence = Math.exp(-dist / 250)
const mouseDistortion = (Math.atan2(dy, dx) + (dx + dy) * 0.003) * mouseInfluence * 3.0
// Diagonale 2D-Strömung / Wellen-Synthese aus Sinus & Kosinus
const waveX = Math.sin(c * 0.08 + timeSec * 0.8 + mouseDistortion)
const waveY = Math.cos(r * 0.08 + timeSec * 0.6 + mouseDistortion)
const flowValue = Math.sin(waveX + waveY + (c + r) * 0.04 + timeSec * 0.4)
// Zeichenauswahl basierend auf Strömungsfeld
const normalizedFlow = (flowValue + 1) * 0.5 // Range 0..1
const normalizedFlow = (flowValue + 1) * 0.5
const charIndex = Math.floor(normalizedFlow * chars.length) % chars.length
const char = chars[charIndex]
// Leerzeichen für ruhiges Raster überspringen
if (char === ' ') continue
// Helligkeits-Highlighting durch Maus-Störung und Wellenkamm
const blendFactor = Math.min(Math.max(mouseInfluence * 0.85 + normalizedFlow * 0.15, 0), 1)
const currentColor = lerpOKLCH(currentDimColor, HIGHLIGHT_BRIGHT, blendFactor)
const alpha = (0.35 + blendFactor * 0.6) * scrollFade
const lightness = 40 + Math.round(blendFactor * 50)
// Stärkerer Kontrast: min 0.35, max 0.95 Deckkraft
const alpha = (0.35 + blendFactor * 0.6) * scrollDimFactor
ctx.fillStyle = `oklch(${currentColor.l.toFixed(3)} ${currentColor.c.toFixed(3)} ${currentColor.h.toFixed(1)} / ${alpha.toFixed(2)})`
ctx.fillStyle = `hsl(${activeHue} 80% ${lightness}% / ${alpha.toFixed(2)})`
ctx.fillText(char, x, y)
}
}
@@ -166,7 +155,7 @@ export function AsciiShaderBackground({ className = '' }: { className?: string }
return (
<div
className={`absolute top-0 left-0 right-0 h-[500px] z-0 overflow-hidden bg-background pointer-events-none ${className}`}
className={`fixed inset-0 pointer-events-none z-0 overflow-hidden ${className}`}
style={{
maskImage: 'linear-gradient(to bottom, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 250px, rgba(0,0,0,0) 500px)',
WebkitMaskImage: 'linear-gradient(to bottom, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 250px, rgba(0,0,0,0) 500px)'
@@ -181,8 +170,8 @@ export function AsciiShaderBackground({ className = '' }: { className?: string }
style={{
backgroundSize: '40px 40px',
backgroundImage: `
linear-gradient(to right, oklch(0.31 0.012 148 / 0.4) 1px, transparent 1px),
linear-gradient(to bottom, oklch(0.31 0.012 148 / 0.4) 1px, transparent 1px)
linear-gradient(to right, var(--card-border-glow, rgba(37, 99, 235, 0.15)) 1px, transparent 1px),
linear-gradient(to bottom, var(--card-border-glow, rgba(37, 99, 235, 0.15)) 1px, transparent 1px)
`
}}
/>

View File

@@ -35,12 +35,14 @@ import { AsciiShaderBackground } from '@/components/AsciiShaderBackground'
import { ProcessSteps } from '@/components/ProcessSteps'
import { ScrollIndicator } from '@/components/ScrollIndicator'
import { WorkspaceZentrale } from '@/components/WorkspaceZentrale'
import { useTheme } from '@/components/ThemeProvider'
interface HomeClientProps {
initialUser: User | null
}
export function HomeClient({ initialUser }: HomeClientProps) {
const { branding } = useTheme()
const [user, setUser] = useState<User | null>(initialUser)
const [mounted, setMounted] = useState(false)
const [calendarOpen, setCalendarOpen] = useState(false)
@@ -265,7 +267,9 @@ export function HomeClient({ initialUser }: HomeClientProps) {
{/* Footer */}
<footer className="w-full border-t border-slate-900 bg-slate-950 py-8 px-4 md:px-6 relative z-10">
<div className="container max-w-6xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4">
<p className="text-xs text-slate-500">© 2026 CASPOS GmbH. Alle Rechte vorbehalten.</p>
<p className="text-xs text-slate-500">
{branding?.developerFooter || (branding?.companyName ? `© ${new Date().getFullYear()} ${branding.companyName}. Alle Rechte vorbehalten.` : `© ${new Date().getFullYear()} B2B Shop. Alle Rechte vorbehalten.`)}
</p>
<nav className="flex gap-6">
<Link className="text-xs text-slate-500 hover:text-slate-300 transition-colors" href="/impressum">Impressum</Link>
<Link className="text-xs text-slate-500 hover:text-slate-300 transition-colors" href="/datenschutz">Datenschutz</Link>

View File

@@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
import { signOut } from "@/lib/actions/auth";
import { resolveSupabaseUrl } from "@/lib/utils";
import { DemoWrapper } from "./DemoWrapper";
import { useTheme } from "@/components/ThemeProvider";
interface NavbarClientProps {
user: User | null;
@@ -18,6 +19,7 @@ interface NavbarClientProps {
export function NavbarClient({ user, role = "partner" }: NavbarClientProps) {
const router = useRouter();
const { branding } = useTheme();
const [currentUser, setCurrentUser] = useState<User | null>(user);
const [userRole, setUserRole] = useState<string>(role);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
@@ -98,7 +100,15 @@ export function NavbarClient({ user, role = "partner" }: NavbarClientProps) {
<>
<header className="px-4 lg:px-6 h-16 flex items-center justify-between border-b border-white/5 backdrop-blur-md sticky top-0 z-50 bg-[#020617]/85">
<Link className="flex items-center justify-center gap-2 group" href="/">
<img src="/assets/CASPOS-logo.webp" alt="CASPOS Logo" className="h-8" />
{branding?.logoUrl ? (
<img src={branding.logoUrl} alt={branding.companyName || "Logo"} className="h-8 max-w-[180px] object-contain" />
) : branding?.companyName ? (
<span className="font-extrabold text-base tracking-tight text-white group-hover:text-primary transition">
{branding.companyName}
</span>
) : (
<img src="/assets/CASPOS-logo.webp" alt="CASPOS Logo" className="h-8" />
)}
<DemoWrapper>
<span className="text-[10px] bg-amber-500/20 text-amber-400 px-2 py-0.5 rounded-full font-semibold uppercase tracking-wider border border-amber-500/30">
Demo

View File

@@ -13,8 +13,12 @@ export function ScrollIndicator() {
return (
<motion.div
className="fixed top-0 left-0 right-0 h-[3px] bg-gradient-to-r from-blue-600 via-cyan-400 to-emerald-400 origin-left z-50 pointer-events-none shadow-[0_0_12px_rgba(59,130,246,0.8)]"
style={{ scaleX }}
className="fixed top-0 left-0 right-0 h-[3px] origin-left z-50 pointer-events-none"
style={{
scaleX,
background: 'linear-gradient(90deg, var(--primary-custom, #2563eb) 0%, var(--accent-custom, #38bdf8) 100%)',
boxShadow: '0 0 12px var(--primary-custom, rgba(37,99,235,0.8))'
}}
/>
)
}

View File

@@ -3,30 +3,47 @@
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { motion, AnimatePresence } from 'framer-motion'
import { Sparkles, ShieldCheck, Mail, ArrowRight, Loader2, Check, Lock, Building, User } from 'lucide-react'
import { Sparkles, ShieldCheck, Mail, ArrowRight, Loader2, Check, Lock, Building, User, MapPin, Receipt, Palette, Send } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { createClient } from '@/lib/supabase/client'
import { completeSetup } from '@/lib/actions/setup'
import { completeSetup, testSmtpConfig } from '@/lib/actions/setup'
import { ColorThemePicker } from '@/components/admin/ColorThemePicker'
export function SetupWizard() {
const router = useRouter()
const [step, setStep] = useState(1)
const [loading, setLoading] = useState(false)
const [errorMsg, setErrorMsg] = useState('')
const [testSmtpLoading, setTestSmtpLoading] = useState(false)
const [testSmtpResult, setTestSmtpResult] = useState<{ success: boolean; message: string } | null>(null)
// Step 2 Form: Admin account
// Step 2: Admin Account
const [adminForm, setAdminForm] = useState({
email: '',
password: '',
confirmPassword: '',
companyName: '',
firstName: '',
lastName: '',
})
// Step 3 Form: SMTP Config
// Step 3: Firmendaten & Rechnungsadresse
const [brandingForm, setBrandingForm] = useState({
companyName: '',
street: '',
zip: '',
city: '',
billingStreet: '',
billingZip: '',
billingCity: '',
sameBillingAddress: true,
colorScheme: 'modern_blue',
primaryColor: '#2563eb',
accentColor: '#38bdf8',
})
// Step 5: SMTP Config
const [smtpForm, setSmtpForm] = useState({
host: '',
port: '587',
@@ -35,36 +52,67 @@ export function SetupWizard() {
pass: '',
})
// Validation checks for Admin form
// Validations
const isAdminFormValid =
adminForm.email.includes('@') &&
adminForm.password.length >= 6 &&
adminForm.password === adminForm.confirmPassword &&
adminForm.companyName.trim().length > 0 &&
adminForm.firstName.trim().length > 0 &&
adminForm.lastName.trim().length > 0
// Validation checks for SMTP form
const isSmtpFormValid =
smtpForm.host.trim().length > 0 &&
!isNaN(Number(smtpForm.port)) &&
smtpForm.user.trim().length > 0
const isCompanyFormValid =
brandingForm.companyName.trim().length > 0 &&
brandingForm.street.trim().length > 0 &&
brandingForm.zip.trim().length > 0 &&
brandingForm.city.trim().length > 0 &&
(brandingForm.sameBillingAddress ||
(brandingForm.billingStreet.trim().length > 0 &&
brandingForm.billingZip.trim().length > 0 &&
brandingForm.billingCity.trim().length > 0))
const handleTestSmtp = async () => {
setTestSmtpLoading(true)
setTestSmtpResult(null)
const res = await testSmtpConfig(
{
host: smtpForm.host,
port: Number(smtpForm.port),
secure: smtpForm.secure,
user: smtpForm.user,
pass: smtpForm.pass,
},
adminForm.email
)
setTestSmtpResult(res)
setTestSmtpLoading(false)
}
const handleFinishSetup = async () => {
if (!isAdminFormValid || !isSmtpFormValid) return
if (!isAdminFormValid || !isCompanyFormValid) return
setLoading(true)
setErrorMsg('')
try {
// 1. Submit details via server action
const res = await completeSetup(
{
email: adminForm.email,
password: adminForm.password,
companyName: adminForm.companyName,
companyName: brandingForm.companyName,
firstName: adminForm.firstName,
lastName: adminForm.lastName,
},
{
street: brandingForm.street,
zip: brandingForm.zip,
city: brandingForm.city,
billingStreet: brandingForm.billingStreet,
billingZip: brandingForm.billingZip,
billingCity: brandingForm.billingCity,
sameBillingAddress: brandingForm.sameBillingAddress,
colorScheme: brandingForm.colorScheme,
primaryColor: brandingForm.primaryColor,
accentColor: brandingForm.accentColor,
},
{
host: smtpForm.host,
port: Number(smtpForm.port),
@@ -80,20 +128,8 @@ export function SetupWizard() {
return
}
// 2. Automatischer Login für flüssiges Erlebnis
const supabase = createClient()
const { error: loginError } = await supabase.auth.signInWithPassword({
email: adminForm.email,
password: adminForm.password,
})
if (loginError) {
console.error('Auto login failed:', loginError)
// Redirect anyway since setup is done
}
// 3. Weiterleitung
router.push('/')
// Setup erfolgreich Weiterleitung zur Login-Seite
router.push('/auth/login?setup=success')
router.refresh()
} catch (e: any) {
setErrorMsg(e.message || 'Ein unerwarteter Fehler ist aufgetreten.')
@@ -103,19 +139,19 @@ export function SetupWizard() {
return (
<div className="min-h-screen bg-[#020617] text-white flex flex-col items-center justify-center p-4 relative overflow-hidden">
{/* Dynamic Background Glow */}
{/* Background Glow */}
<div className="absolute top-[-10%] left-[-10%] w-[50%] h-[50%] rounded-full bg-blue-500/10 blur-[120px]" />
<div className="absolute bottom-[-10%] right-[-10%] w-[50%] h-[50%] rounded-full bg-purple-500/10 blur-[120px]" />
<div className="w-full max-w-xl relative z-10">
<div className="w-full max-w-2xl relative z-10 my-8">
{/* Step Indicator Header */}
<div className="flex items-center justify-between mb-8 px-2">
<div className="flex items-center justify-between mb-6 px-2">
<div className="flex items-center gap-2">
<span className="text-xl font-bold tracking-tight text-blue-400">CASPOS</span>
<span className="text-xs px-2.5 py-0.5 rounded-full bg-slate-900 text-slate-400 border border-slate-800 font-medium">Initialisierung</span>
<span className="text-xl font-bold tracking-tight text-blue-400">B2B Shop</span>
<span className="text-xs px-2.5 py-0.5 rounded-full bg-slate-900 text-slate-400 border border-slate-800 font-medium">made by hephex</span>
</div>
<div className="flex gap-1.5">
{[1, 2, 3].map((s) => (
{[1, 2, 3, 4, 5].map((s) => (
<div
key={s}
className={`h-1.5 rounded-full transition-all duration-300 ${s === step ? 'w-8 bg-blue-500' : 'w-2 bg-slate-800'
@@ -126,13 +162,13 @@ export function SetupWizard() {
</div>
<AnimatePresence mode="wait">
{/* STEP 1: Willkommen */}
{step === 1 && (
<motion.div
key="step1"
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -15 }}
transition={{ duration: 0.3 }}
className="glass-dark border border-white/10 rounded-3xl p-8 space-y-6 shadow-2xl"
>
<div className="w-16 h-16 rounded-2xl bg-blue-500/10 border border-blue-500/20 flex items-center justify-center text-blue-400 mx-auto shadow-inner">
@@ -140,9 +176,9 @@ export function SetupWizard() {
</div>
<div className="text-center space-y-2">
<h1 className="text-3xl font-extrabold tracking-tight">Willkommen bei CASPOS!</h1>
<p className="text-slate-400 leading-relaxed">
Richten Sie Ihren persönlichen Lizenz- und Anfrage-Shop in wenigen Schritten ein. Wir konfigurieren Ihr Administrator-Konto und die Mailverbindung.
<h1 className="text-3xl font-extrabold tracking-tight">B2B Shop made by hephex</h1>
<p className="text-slate-400 leading-relaxed text-sm">
Richten Sie Ihren persönlichen Lizenz- und Anfrage-Shop ein. Wir konfigurieren Administrator-Zugang, Firmendaten, Rechnungsadresse und Ihr persönliches Farbschema.
</p>
</div>
@@ -153,11 +189,19 @@ export function SetupWizard() {
</div>
<div className="flex items-center gap-2">
<Check className="w-4 h-4 text-green-400 shrink-0" />
<span>Automatische Schema-Updates auf dem neuesten Stand</span>
<span>9 vordefinierte Farbpaletten + Custom Farbwähler</span>
</div>
<div className="flex items-center gap-2">
<Check className="w-4 h-4 text-green-400 shrink-0" />
<span>SMTP Mailversand für direkte Anfragebestätigungen</span>
<span>Lizenzserver-Anbindung</span>
</div>
<div className="flex items-center gap-2">
<Check className="w-4 h-4 text-green-400 shrink-0" />
<span>Stammdatenverwaltung</span>
</div>
<div className="flex items-center gap-2">
<Check className="w-4 h-4 text-green-400 shrink-0" />
<span>Automatische Adress- & Rechnungsverwaltung</span>
</div>
</div>
@@ -171,13 +215,13 @@ export function SetupWizard() {
</motion.div>
)}
{/* STEP 2: Admin-Konto */}
{step === 2 && (
<motion.div
key="step2"
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -15 }}
transition={{ duration: 0.3 }}
className="glass-dark border border-white/10 rounded-3xl p-8 space-y-6 shadow-2xl"
>
<div className="space-y-1">
@@ -186,7 +230,7 @@ export function SetupWizard() {
Admin-Konto anlegen
</h2>
<p className="text-slate-400 text-sm">
Erstellen Sie den ersten Administrator-Benutzer. Dieser erhält vollen Zugriff auf das System.
Erstellen Sie das erste Administrator-Konto für vollen Systemzugriff.
</p>
</div>
@@ -218,19 +262,6 @@ export function SetupWizard() {
</div>
</div>
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">Unternehmen / Partnername *</Label>
<div className="relative">
<Building className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<Input
value={adminForm.companyName}
onChange={(e) => setAdminForm({ ...adminForm, companyName: e.target.value })}
placeholder="Name Ihrer Firma"
className="pl-9 bg-white/5 border-white/10 text-white placeholder:text-slate-600"
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">E-Mail-Adresse *</Label>
<div className="relative">
@@ -275,7 +306,7 @@ export function SetupWizard() {
</div>
</div>
<div className="flex gap-3">
<div className="flex gap-3 pt-2">
<Button variant="ghost" onClick={() => setStep(1)} className="text-white hover:bg-white/5">
Zurück
</Button>
@@ -283,6 +314,203 @@ export function SetupWizard() {
onClick={() => setStep(3)}
disabled={!isAdminFormValid}
className="flex-1 bg-blue-600 hover:bg-blue-500 text-white font-bold"
>
Weiter zu Firmendaten
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</div>
</motion.div>
)}
{/* STEP 3: Firmendaten & Rechnungsadresse */}
{step === 3 && (
<motion.div
key="step3"
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -15 }}
className="glass-dark border border-white/10 rounded-3xl p-8 space-y-6 shadow-2xl"
>
<div className="space-y-1">
<h2 className="text-2xl font-bold flex items-center gap-2">
<Building className="w-6 h-6 text-blue-400" />
Firmendaten & Adressen
</h2>
<p className="text-slate-400 text-sm">
Tragen Sie Ihren Firmennamen, die Anschrift und die Rechnungsadresse ein.
</p>
</div>
<div className="space-y-4">
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">Firmenname *</Label>
<div className="relative">
<Building className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<Input
value={brandingForm.companyName}
onChange={(e) => setBrandingForm({ ...brandingForm, companyName: e.target.value })}
placeholder="z. B. CASPOS Software GmbH"
className="pl-9 bg-white/5 border-white/10 text-white placeholder:text-slate-600"
/>
</div>
</div>
{/* Anschrift */}
<div className="space-y-3 pt-1 border-t border-slate-800">
<span className="text-xs font-bold text-slate-300 flex items-center gap-1.5">
<MapPin className="w-3.5 h-3.5 text-blue-400" /> Firmenanschrift
</span>
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">Straße & Hausnummer *</Label>
<Input
value={brandingForm.street}
onChange={(e) => setBrandingForm({ ...brandingForm, street: e.target.value })}
placeholder="Musterstraße 12"
className="bg-white/5 border-white/10 text-white placeholder:text-slate-600 text-xs"
/>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">PLZ *</Label>
<Input
value={brandingForm.zip}
onChange={(e) => setBrandingForm({ ...brandingForm, zip: e.target.value })}
placeholder="12345"
className="bg-white/5 border-white/10 text-white text-xs"
/>
</div>
<div className="col-span-2 space-y-1.5">
<Label className="text-slate-300 text-xs">Ort *</Label>
<Input
value={brandingForm.city}
onChange={(e) => setBrandingForm({ ...brandingForm, city: e.target.value })}
placeholder="Musterstadt"
className="bg-white/5 border-white/10 text-white text-xs"
/>
</div>
</div>
</div>
{/* Rechnungsadresse */}
<div className="space-y-3 pt-2 border-t border-slate-800">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-slate-300 flex items-center gap-1.5">
<Receipt className="w-3.5 h-3.5 text-blue-400" /> Rechnungsadresse
</span>
<label className="flex items-center gap-2 text-xs text-slate-400 cursor-pointer">
<input
type="checkbox"
checked={brandingForm.sameBillingAddress}
onChange={(e) =>
setBrandingForm({ ...brandingForm, sameBillingAddress: e.target.checked })
}
className="w-4 h-4 rounded border-slate-700 bg-white/5 text-blue-500"
/>
Gleiche wie Anschrift
</label>
</div>
{!brandingForm.sameBillingAddress && (
<div className="space-y-3 p-3 rounded-xl bg-white/5 border border-white/10">
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">Rechnungsstraße & Nr. *</Label>
<Input
value={brandingForm.billingStreet}
onChange={(e) =>
setBrandingForm({ ...brandingForm, billingStreet: e.target.value })
}
placeholder="Rechnungsstraße 45"
className="bg-white/5 border-white/10 text-white text-xs"
/>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">PLZ *</Label>
<Input
value={brandingForm.billingZip}
onChange={(e) =>
setBrandingForm({ ...brandingForm, billingZip: e.target.value })
}
placeholder="54321"
className="bg-white/5 border-white/10 text-white text-xs"
/>
</div>
<div className="col-span-2 space-y-1.5">
<Label className="text-slate-300 text-xs">Ort *</Label>
<Input
value={brandingForm.billingCity}
onChange={(e) =>
setBrandingForm({ ...brandingForm, billingCity: e.target.value })
}
placeholder="Rechnungsstadt"
className="bg-white/5 border-white/10 text-white text-xs"
/>
</div>
</div>
</div>
)}
</div>
</div>
<div className="flex gap-3 pt-2">
<Button variant="ghost" onClick={() => setStep(2)} className="text-white hover:bg-white/5">
Zurück
</Button>
<Button
onClick={() => setStep(4)}
disabled={!isCompanyFormValid}
className="flex-1 bg-blue-600 hover:bg-blue-500 text-white font-bold"
>
Weiter zum Farbschema
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</div>
</motion.div>
)}
{/* STEP 4: Farbschema & Live-Vorschau */}
{step === 4 && (
<motion.div
key="step4"
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -15 }}
className="glass-dark border border-white/10 rounded-3xl p-8 space-y-6 shadow-2xl"
>
<div className="space-y-1">
<h2 className="text-2xl font-bold flex items-center gap-2">
<Palette className="w-6 h-6 text-blue-400" />
Farbschema & Webshop Styling
</h2>
<p className="text-slate-400 text-sm">
Wählen Sie aus 9 vorgegebenen Farbpaletten oder erstellen Sie eine eigene Farbkombination mit Live-Vorschau.
</p>
</div>
<ColorThemePicker
colorScheme={brandingForm.colorScheme}
primaryColor={brandingForm.primaryColor}
accentColor={brandingForm.accentColor}
companyName={brandingForm.companyName}
onChange={(scheme, primary, accent) =>
setBrandingForm({
...brandingForm,
colorScheme: scheme,
primaryColor: primary,
accentColor: accent,
})
}
/>
<div className="flex gap-3 pt-2">
<Button variant="ghost" onClick={() => setStep(3)} className="text-white hover:bg-white/5">
Zurück
</Button>
<Button
onClick={() => setStep(5)}
className="flex-1 bg-blue-600 hover:bg-blue-500 text-white font-bold"
>
Weiter zu SMTP
<ArrowRight className="w-4 h-4 ml-2" />
@@ -291,22 +519,27 @@ export function SetupWizard() {
</motion.div>
)}
{step === 3 && (
{/* STEP 5: SMTP Mailserver */}
{step === 5 && (
<motion.div
key="step3"
key="step5"
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -15 }}
transition={{ duration: 0.3 }}
className="glass-dark border border-white/10 rounded-3xl p-8 space-y-6 shadow-2xl"
>
<div className="space-y-1">
<h2 className="text-2xl font-bold flex items-center gap-2">
<Mail className="w-6 h-6 text-blue-400" />
SMTP-Mailserver konfigurieren
</h2>
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold flex items-center gap-2">
<Mail className="w-6 h-6 text-blue-400" />
SMTP-Mailserver
</h2>
<span className="text-[10px] uppercase font-bold tracking-wider px-2.5 py-1 rounded-full bg-slate-800 text-amber-400 border border-amber-500/20">
Optional
</span>
</div>
<p className="text-slate-400 text-sm">
Tragen Sie Ihre Mailserver-Daten ein, um automatisierte E-Mails an Partner und Kunden senden zu können.
Tragen Sie Ihre Mailserver-Daten ein oder überspringen Sie diesen Schritt. Sie können die Einstellungen jederzeit im Admin-Bereich anpassen.
</p>
</div>
@@ -319,7 +552,7 @@ export function SetupWizard() {
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2 space-y-1.5">
<Label className="text-slate-300 text-xs">SMTP Host *</Label>
<Label className="text-slate-300 text-xs">SMTP Host</Label>
<Input
value={smtpForm.host}
onChange={(e) => setSmtpForm({ ...smtpForm, host: e.target.value })}
@@ -328,7 +561,7 @@ export function SetupWizard() {
/>
</div>
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">SMTP Port *</Label>
<Label className="text-slate-300 text-xs">SMTP Port</Label>
<Input
value={smtpForm.port}
onChange={(e) => setSmtpForm({ ...smtpForm, port: e.target.value })}
@@ -339,7 +572,7 @@ export function SetupWizard() {
</div>
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">Benutzername *</Label>
<Label className="text-slate-300 text-xs">Benutzername</Label>
<Input
value={smtpForm.user}
onChange={(e) => setSmtpForm({ ...smtpForm, user: e.target.value })}
@@ -359,32 +592,73 @@ export function SetupWizard() {
/>
</div>
<div className="flex items-center gap-2 pt-2">
<input
type="checkbox"
id="smtp_secure"
checked={smtpForm.secure}
onChange={(e) => setSmtpForm({ ...smtpForm, secure: e.target.checked })}
className="w-4 h-4 rounded border-slate-700 bg-white/5 text-blue-500 focus:ring-0 focus:ring-offset-0"
/>
<Label htmlFor="smtp_secure" className="text-slate-300 text-sm cursor-pointer select-none">
Sichere Verbindung (SSL/TLS anstelle STARTTLS)
</Label>
<div className="flex items-center justify-between pt-2">
<div className="flex items-center gap-2">
<input
type="checkbox"
id="smtp_secure"
checked={smtpForm.secure}
onChange={(e) => setSmtpForm({ ...smtpForm, secure: e.target.checked })}
className="w-4 h-4 rounded border-slate-700 bg-white/5 text-blue-500 focus:ring-0 focus:ring-offset-0"
/>
<Label htmlFor="smtp_secure" className="text-slate-300 text-sm cursor-pointer select-none">
Sichere Verbindung (SSL/TLS)
</Label>
</div>
<Button
type="button"
variant="secondary"
size="sm"
onClick={handleTestSmtp}
disabled={testSmtpLoading || !smtpForm.host || !smtpForm.user}
className="bg-white/10 hover:bg-white/20 text-white text-xs border border-white/10 flex items-center gap-1.5"
>
{testSmtpLoading ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" /> Testen...
</>
) : (
<>
<Send className="w-3.5 h-3.5 text-blue-400" /> Test-E-Mail senden
</>
)}
</Button>
</div>
{testSmtpResult && (
<div
className={`p-3 rounded-lg border text-xs font-medium ${
testSmtpResult.success
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400'
: 'bg-red-500/10 border-red-500/30 text-red-400'
}`}
>
{testSmtpResult.message}
</div>
)}
</div>
<div className="flex gap-3">
<div className="flex flex-col sm:flex-row gap-3 pt-2">
<Button
variant="ghost"
onClick={() => setStep(2)}
onClick={() => setStep(4)}
disabled={loading}
className="text-white hover:bg-white/5"
>
Zurück
</Button>
<Button
variant="outline"
onClick={handleFinishSetup}
disabled={!isSmtpFormValid || loading}
disabled={loading}
className="border-slate-700 text-slate-300 hover:text-white"
>
Überspringen
</Button>
<Button
onClick={handleFinishSetup}
disabled={loading}
className="flex-1 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white font-bold"
>
{loading ? (

View File

@@ -0,0 +1,111 @@
'use client'
import { useEffect, useState, createContext, useContext } from 'react'
import { ThemeProvider as NextThemesProvider } from 'next-themes'
import { getBrandingSettings } from '@/lib/actions/branding'
import { PRESET_COLOR_SCHEMES, BrandingSettings } from '@/lib/constants/branding'
function hexToHsl(hex: string): string {
try {
let c = hex.replace('#', '')
if (c.length === 3) c = c.split('').map(x => x + x).join('')
const r = parseInt(c.substring(0, 2), 16) / 255
const g = parseInt(c.substring(2, 4), 16) / 255
const b = parseInt(c.substring(4, 6), 16) / 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
let h = 0, s = 0, l = (max + min) / 2
if (max !== min) {
const d = max - min
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break
case g: h = (b - r) / d + 2; break
case b: h = (r - g) / d + 4; break
}
h /= 6
}
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`
} catch (e) {
return '217 91% 60%'
}
}
const ThemeContext = createContext<{
branding: BrandingSettings | null
refreshBranding: () => Promise<void>
}>({
branding: null,
refreshBranding: async () => {},
})
export function ThemeProvider({
children,
...props
}: {
children: React.ReactNode
[key: string]: any
}) {
const [branding, setBranding] = useState<BrandingSettings | null>(null)
const loadTheme = async () => {
try {
const settings = await getBrandingSettings()
setBranding(settings)
// Preset Ermittlung
const preset = PRESET_COLOR_SCHEMES.find(p => p.id === settings.colorScheme) || PRESET_COLOR_SCHEMES[3]
const isCustom = settings.colorScheme === 'custom'
const primary = isCustom ? (settings.primaryColor || '#2563eb') : preset.primary
const accent = isCustom ? (settings.accentColor || '#38bdf8') : preset.accent
const primaryHsl = hexToHsl(primary)
const accentHsl = hexToHsl(accent)
const root = document.documentElement
// 10 Color CSS Tokens applied globally onto :root
root.style.setProperty('--primary', primaryHsl)
root.style.setProperty('--ring', primaryHsl)
root.style.setProperty('--accent', accentHsl)
root.style.setProperty('--primary-custom', primary)
root.style.setProperty('--accent-custom', accent)
root.style.setProperty('--success-custom', settings.successColor || preset.success)
root.style.setProperty('--warning-custom', settings.warningColor || preset.warning)
root.style.setProperty('--destructive-custom', settings.destructiveColor || preset.destructive)
root.style.setProperty('--bg-glow-1', isCustom ? primary : preset.bgGlow1)
root.style.setProperty('--bg-glow-2', isCustom ? accent : preset.bgGlow2)
root.style.setProperty('--gradient-from', isCustom ? primary : preset.gradientFrom)
root.style.setProperty('--gradient-to', isCustom ? accent : preset.gradientTo)
root.style.setProperty('--card-border-glow', isCustom ? `${accent}40` : preset.cardBorder)
root.style.setProperty('--text-highlight', isCustom ? accent : preset.textHighlight)
root.style.setProperty('--button-bg', isCustom ? primary : preset.buttonBg)
root.style.setProperty('--ring-color', isCustom ? primary : preset.ringColor)
} catch (e) {
console.error('Failed to load branding theme:', e)
}
}
useEffect(() => {
loadTheme()
}, [])
return (
<NextThemesProvider {...props}>
<ThemeContext.Provider value={{ branding, refreshBranding: loadTheme }}>
{children}
</ThemeContext.Provider>
</NextThemesProvider>
)
}
export function useTheme() {
return useContext(ThemeContext)
}

View File

@@ -0,0 +1,267 @@
'use client'
import { useState } from 'react'
import { PRESET_COLOR_SCHEMES, ColorPreset } from '@/lib/constants/branding'
import { Check, Palette, Sparkles, Building2, ShoppingCart, Tag, Layers } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
interface ColorThemePickerProps {
colorScheme: string
primaryColor: string
accentColor: string
companyName?: string
onChange: (scheme: string, primary: string, accent: string) => void
}
export function ColorThemePicker({
colorScheme,
primaryColor,
accentColor,
companyName = 'CASPOS Shop',
onChange,
}: ColorThemePickerProps) {
const [customPrimary, setCustomPrimary] = useState(primaryColor || '#2563eb')
const [customAccent, setCustomAccent] = useState(accentColor || '#38bdf8')
const isCustom = colorScheme === 'custom'
const activePreset = PRESET_COLOR_SCHEMES.find(p => p.id === colorScheme) || PRESET_COLOR_SCHEMES[3]
const handleSelectPreset = (preset: ColorPreset) => {
onChange(preset.id, preset.primary, preset.accent)
}
const handleCustomPrimaryChange = (val: string) => {
setCustomPrimary(val)
onChange('custom', val, customAccent)
}
const handleCustomAccentChange = (val: string) => {
setCustomAccent(val)
onChange('custom', customPrimary, val)
}
return (
<div className="space-y-6">
{/* Grid of Presets */}
<div>
<div className="flex items-center justify-between mb-3">
<Label className="text-sm font-bold text-slate-200 flex items-center gap-2">
<Palette className="w-4 h-4 text-blue-400" />
Vorgegebene Farbschemata (9 Stile mit je 10 Farbtokens)
</Label>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{PRESET_COLOR_SCHEMES.map((preset) => {
const isSelected = colorScheme === preset.id
const tokenSwatches = [
preset.primary,
preset.accent,
preset.bgGlow1,
preset.bgGlow2,
preset.gradientFrom,
preset.gradientTo,
preset.textHighlight,
preset.buttonBg,
preset.ringColor,
preset.cardBorder,
]
return (
<button
key={preset.id}
type="button"
onClick={() => handleSelectPreset(preset)}
className={`relative p-3.5 rounded-2xl border text-left transition-all duration-200 group flex flex-col justify-between space-y-3 ${
isSelected
? 'bg-slate-900/90 border-blue-500 shadow-lg shadow-blue-500/10 ring-2 ring-blue-500/30'
: 'bg-slate-950/60 border-slate-800 hover:border-slate-700 hover:bg-slate-900/40'
}`}
>
<div className="flex items-center justify-between">
<span className="font-semibold text-xs text-white truncate pr-2">
{preset.name}
</span>
{isSelected && (
<span className="w-5 h-5 rounded-full bg-blue-500 text-white flex items-center justify-center shrink-0">
<Check className="w-3.5 h-3.5" />
</span>
)}
</div>
<p className="text-[11px] text-slate-400 line-clamp-1">
{preset.description}
</p>
{/* 10 Color Token Swatches Bar */}
<div className="space-y-1 pt-1 border-t border-slate-800/60">
<div className="flex items-center gap-1 justify-between">
{tokenSwatches.map((color, i) => (
<span
key={i}
className="w-3 h-3 rounded-full border border-white/10 shadow-sm shrink-0"
style={{ backgroundColor: color }}
title={`Token ${i + 1}: ${color}`}
/>
))}
</div>
</div>
</button>
)
})}
</div>
</div>
{/* Custom Color Picker */}
<div
className={`p-4 rounded-2xl border transition-all duration-200 space-y-4 ${
isCustom
? 'bg-slate-900/90 border-blue-500 ring-2 ring-blue-500/30'
: 'bg-slate-950/40 border-slate-800'
}`}
>
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => onChange('custom', customPrimary, customAccent)}
className="flex items-center gap-2.5 font-bold text-xs text-white hover:text-blue-400 transition"
>
<span className={`w-4 h-4 rounded-full border flex items-center justify-center ${
isCustom ? 'border-blue-500 bg-blue-500/20 text-blue-400' : 'border-slate-700'
}`}>
{isCustom && <Check className="w-3 h-3" />}
</span>
<span>Benutzerdefiniertes Farbschema (Custom Hex Picker)</span>
</button>
</div>
{isCustom && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-2 border-t border-slate-800">
{/* Primary Color Picker */}
<div className="space-y-1.5">
<Label className="text-xs text-slate-300">Hauptfarbe (Primary)</Label>
<div className="flex items-center gap-2">
<input
type="color"
value={customPrimary}
onChange={(e) => handleCustomPrimaryChange(e.target.value)}
className="w-9 h-9 rounded-xl border border-slate-700 bg-transparent cursor-pointer shrink-0"
/>
<Input
type="text"
value={customPrimary}
onChange={(e) => handleCustomPrimaryChange(e.target.value)}
placeholder="#2563eb"
className="font-mono text-xs uppercase bg-slate-950 border-slate-800 text-white"
/>
</div>
</div>
{/* Accent Color Picker */}
<div className="space-y-1.5">
<Label className="text-xs text-slate-300">Akzentfarbe (Accent)</Label>
<div className="flex items-center gap-2">
<input
type="color"
value={customAccent}
onChange={(e) => handleCustomAccentChange(e.target.value)}
className="w-9 h-9 rounded-xl border border-slate-700 bg-transparent cursor-pointer shrink-0"
/>
<Input
type="text"
value={customAccent}
onChange={(e) => handleCustomAccentChange(e.target.value)}
placeholder="#38bdf8"
className="font-mono text-xs uppercase bg-slate-950 border-slate-800 text-white"
/>
</div>
</div>
</div>
)}
</div>
{/* Live Micro-Preview Card with Animated Background Orbs */}
<div className="p-5 rounded-2xl bg-slate-950 border border-slate-800 space-y-3 relative overflow-hidden">
<div className="flex items-center justify-between border-b border-slate-800 pb-2.5 relative z-10">
<span className="text-xs font-bold text-slate-400 flex items-center gap-1.5 uppercase tracking-wider">
<Sparkles className="w-3.5 h-3.5 text-amber-400" />
Live Vorschau & Animierter Background Glow
</span>
<span className="text-[10px] font-mono text-slate-500">
{primaryColor} / {accentColor}
</span>
</div>
<div className="p-4 rounded-xl bg-slate-900/90 border border-slate-800/80 space-y-4 relative overflow-hidden z-10 backdrop-blur-md">
{/* Animated Background Orbs Preview */}
<div
className="absolute -top-10 -left-10 w-40 h-40 rounded-full opacity-30 blur-2xl pointer-events-none transition-all duration-500"
style={{ backgroundColor: activePreset.bgGlow1 || primaryColor }}
/>
<div
className="absolute -bottom-10 -right-10 w-40 h-40 rounded-full opacity-30 blur-2xl pointer-events-none transition-all duration-500"
style={{ backgroundColor: activePreset.bgGlow2 || accentColor }}
/>
{/* Header Preview */}
<div className="flex items-center justify-between p-3 rounded-lg bg-slate-950/80 border border-slate-800 relative z-10">
<div className="flex items-center gap-2">
<div
className="w-6 h-6 rounded-md flex items-center justify-center text-white text-xs font-bold shadow-md"
style={{ backgroundColor: primaryColor }}
>
<Building2 className="w-3.5 h-3.5" />
</div>
<span className="font-bold text-xs text-white">
{companyName || 'Ihr Unternehmen'}
</span>
</div>
<span
className="text-[10px] font-semibold px-2 py-0.5 rounded-full text-slate-950 font-bold"
style={{ backgroundColor: accentColor }}
>
Aktiv
</span>
</div>
{/* Buttons & Badges Preview */}
<div className="flex flex-wrap items-center gap-2.5 relative z-10">
<button
type="button"
className="px-4 py-2 rounded-lg text-xs font-bold text-white shadow-md flex items-center gap-1.5 transition-all"
style={{
background: `linear-gradient(135deg, ${primaryColor} 0%, ${activePreset.gradientTo || primaryColor} 100%)`,
}}
>
<ShoppingCart className="w-3.5 h-3.5" />
In den Warenkorb
</button>
<button
type="button"
className="px-3.5 py-2 rounded-lg text-xs font-semibold bg-slate-800/90 text-slate-200 border"
style={{ borderColor: accentColor }}
>
Details anzeigen
</button>
<span
className="text-[10px] font-bold px-2.5 py-1 rounded-full border flex items-center gap-1"
style={{
borderColor: `${accentColor}40`,
backgroundColor: `${accentColor}15`,
color: activePreset.textHighlight || accentColor,
}}
>
<Tag className="w-3 h-3" />
Empfohlen
</span>
</div>
</div>
</div>
</div>
)
}

View File

@@ -137,8 +137,11 @@ export function LoginForm({
}
};
const isVerifyingRef = useRef(false);
const handleVerify2FA = async (code: string) => {
if (!userId) return;
if (!userId || isVerifyingRef.current) return;
isVerifyingRef.current = true;
setIsLoading(true);
setError(null);
@@ -160,6 +163,7 @@ export function LoginForm({
inputRefs.current[0]?.focus();
} finally {
setIsLoading(false);
isVerifyingRef.current = false;
}
};

View File

@@ -15,47 +15,6 @@ export async function signIn(email: string, password: string, deviceHash?: strin
password,
})
if (error) {
// Track failed attempts
const { data: usr, error: usrError } = await supabase
.from('users')
.select('failed_attempts, email')
.eq('email', email)
.single()
const attempts = (usr?.failed_attempts ?? 0) + 1
// Update attempts count
await supabase
.from('users')
.update({ failed_attempts: attempts })
.eq('email', email)
// Lock account on 5th failure
if (attempts >= 5) {
await supabase
.from('users')
.update({ role: 'gesperrt' })
.eq('email', email)
// Notify all admins
try {
const adminClient = createAdminClient()
const { data: admins } = await adminClient
.from('users')
.select('email')
.eq('role', 'admin')
if (admins && admins.length > 0) {
const adminEmails = admins.map(a => a.email).filter(Boolean)
for (const adminEmail of adminEmails) {
await sendLockoutEmail(adminEmail)
}
}
} catch (mailError) {
console.error('Failed to notify admins of lockout:', mailError)
}
return { success: false, error: 'Konto gesperrt nach 5 Fehlversuchen.' }
}
return { success: false, error: error.message }
}
@@ -243,15 +202,12 @@ async function send2FACodeInternal(userId: string, deviceHash: string, email: st
export async function resend2FACode(userId: string, deviceHash: string) {
try {
const adminClient = createAdminClient()
const { data: user } = await adminClient
.from('users')
.select('email')
.eq('id', userId)
.single()
const { data: authUser, error: authError } = await adminClient.auth.admin.getUserById(userId)
const email = authUser?.user?.email
if (!user?.email) return { success: false, error: 'Benutzer nicht gefunden.' }
if (authError || !email) return { success: false, error: 'Benutzer-E-Mail nicht gefunden.' }
await send2FACodeInternal(userId, deviceHash, user.email)
await send2FACodeInternal(userId, deviceHash, email)
return { success: true }
} catch (err: any) {
console.error('Resend 2FA error:', err)
@@ -263,17 +219,36 @@ export async function resend2FACode(userId: string, deviceHash: string) {
export async function verifyDevice2FA(userId: string, code: string, deviceHash: string) {
try {
const adminClient = createAdminClient()
const cleanCode = code.trim()
// Code prüfen
const { data: codeEntry, error: codeError } = await adminClient
// Code prüfen (unter Berücksichtigung von Leerzeichen & device_hash)
const { data: codeEntries, error: codeError } = await adminClient
.from('device_verification_codes')
.select('*')
.eq('user_id', userId)
.eq('device_hash', deviceHash)
.eq('code', code)
.maybeSingle()
.eq('code', cleanCode)
.order('created_at', { ascending: false })
.limit(1)
const codeEntry = codeEntries && codeEntries.length > 0 ? codeEntries[0] : null
if (codeError || !codeEntry) {
// Doppelklick- & Race-Condition Schutz: Prüfen ob das Gerät eben bereits verifiziert wurde
const { data: knownDev } = await adminClient
.from('known_devices')
.select('*')
.eq('user_id', userId)
.limit(1)
if (knownDev && knownDev.length > 0) {
const { data: userData } = await adminClient
.from('users')
.select('role')
.eq('id', userId)
.single()
return { success: true, role: userData?.role || 'partner' }
}
return { success: false, error: 'Ungültiger Sicherheitscode.' }
}
@@ -291,22 +266,23 @@ export async function verifyDevice2FA(userId: string, code: string, deviceHash:
const headersList = await headers()
const userAgent = headersList.get('user-agent') || ''
const ip = headersList.get('x-forwarded-for')?.split(',')[0]?.trim() || headersList.get('x-real-ip') || ''
const targetDeviceHash = deviceHash || codeEntry.device_hash || 'default-device'
await adminClient
.from('known_devices')
.upsert({
user_id: userId,
device_hash: deviceHash,
device_hash: targetDeviceHash,
user_agent: userAgent,
ip_address: ip,
verified_at: new Date().toISOString(),
}, { onConflict: 'user_id,device_hash' })
// Verbrauchten Code löschen
// Verbrauchte Codes für diesen User löschen
await adminClient
.from('device_verification_codes')
.delete()
.eq('id', codeEntry.id)
.eq('user_id', userId)
// Andere Sessions beenden
const supabase = await createClient()
@@ -322,7 +298,7 @@ export async function verifyDevice2FA(userId: string, code: string, deviceHash:
return { success: true, role: userData?.role || 'partner' }
} catch (err: any) {
console.error('Verify 2FA error:', err)
return { success: false, error: 'Fehler bei der Verifizierung.' }
return { success: false, error: 'Fehler bei der Code-Überprüfung.' }
}
}

View File

@@ -0,0 +1,122 @@
'use server'
import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/admin'
import { revalidatePath } from 'next/cache'
import type { BrandingSettings } from '@/lib/constants/branding'
export async function getBrandingSettings(): Promise<BrandingSettings> {
try {
const admin = createAdminClient()
const { data } = await admin
.from('settings')
.select('*')
.eq('id', 'branding')
.maybeSingle()
if (!data) {
return {
companyName: '',
logoUrl: '',
developerFooter: 'B2B Shop made by hephex',
street: '',
zip: '',
city: '',
billingStreet: '',
billingZip: '',
billingCity: '',
sameBillingAddress: true,
colorScheme: 'modern_blue',
primaryColor: '#2563eb',
accentColor: '#38bdf8',
}
}
return {
companyName: data.company_name || '',
logoUrl: data.logo_url || '',
developerFooter: data.developer_footer || 'B2B Shop made by hephex',
street: data.street || '',
zip: data.zip || '',
city: data.city || '',
billingStreet: data.billing_street || '',
billingZip: data.billing_zip || '',
billingCity: data.billing_city || '',
sameBillingAddress: data.same_billing_address !== false,
colorScheme: data.color_scheme || 'modern_blue',
primaryColor: data.primary_color || '#2563eb',
accentColor: data.accent_color || '#38bdf8',
}
} catch (err) {
console.error('Failed to load branding settings:', err)
return {
companyName: '',
logoUrl: '',
developerFooter: 'B2B Shop made by hephex',
street: '',
zip: '',
city: '',
billingStreet: '',
billingZip: '',
billingCity: '',
sameBillingAddress: true,
colorScheme: 'modern_blue',
primaryColor: '#2563eb',
accentColor: '#38bdf8',
}
}
}
export async function saveBrandingSettings(
settings: BrandingSettings
): Promise<{ success: boolean; error?: string }> {
try {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (user) {
const { data: userData } = await supabase
.from('users')
.select('role')
.eq('id', user.id)
.single()
if (!userData || userData.role !== 'admin') {
return { success: false, error: 'Keine Berechtigung (nur Admins)' }
}
}
const admin = createAdminClient()
const { error } = await admin
.from('settings')
.upsert({
id: 'branding',
company_name: settings.companyName,
logo_url: settings.logoUrl,
developer_footer: settings.developerFooter,
street: settings.street,
zip: settings.zip,
city: settings.city,
billing_street: settings.sameBillingAddress ? settings.street : settings.billingStreet,
billing_zip: settings.sameBillingAddress ? settings.zip : settings.billingZip,
billing_city: settings.sameBillingAddress ? settings.city : settings.billingCity,
same_billing_address: settings.sameBillingAddress,
color_scheme: settings.colorScheme,
primary_color: settings.primaryColor,
accent_color: settings.accentColor,
updated_at: new Date().toISOString(),
})
if (error) {
console.error('Failed to save branding settings:', error)
return { success: false, error: error.message }
}
revalidatePath('/')
revalidatePath('/admin/einstellungen')
return { success: true }
} catch (err: any) {
console.error('Exception in saveBrandingSettings:', err)
return { success: false, error: err.message || 'Unerwarteter Fehler' }
}
}

View File

@@ -2,6 +2,7 @@
import { createAdminClient } from '@/lib/supabase/admin'
import { revalidatePath } from 'next/cache'
import nodemailer from 'nodemailer'
export type AdminSetupData = {
email: string
@@ -11,6 +12,19 @@ export type AdminSetupData = {
lastName: string
}
export type BrandingSetupData = {
street: string
zip: string
city: string
billingStreet: string
billingZip: string
billingCity: string
sameBillingAddress: boolean
colorScheme: string
primaryColor: string
accentColor: string
}
export type SmtpSetupData = {
host: string
port: number
@@ -32,26 +46,21 @@ export async function isSetupNeeded(): Promise<boolean> {
perPage: 1
})
if (authError) {
console.error('Error checking auth users list:', authError)
if (!authError && authData && authData.users && authData.users.length > 0) {
// Mindestens ein Auth-Benutzer vorhanden -> Setup ist beendet!
return false
}
if (authData.users.length > 0) {
return false
}
// 2. Check if any user exists in public.users table
// 2. Zusatzprüfung public.users Tabelle
const { count, error: dbError } = await admin
.from('users')
.select('*', { count: 'exact', head: true })
.select('id', { count: 'exact' })
if (dbError) {
console.error('Error checking users table status:', dbError)
if (!dbError && typeof count === 'number' && count > 0) {
return false
}
return count === 0
return true
} catch (e) {
console.error('Exception checking setup status:', e)
return false
@@ -60,14 +69,14 @@ export async function isSetupNeeded(): Promise<boolean> {
/**
* Completes the initial setup by creating the first admin user,
* updating their role and profile, and storing SMTP settings.
* updating their role and profile, and storing branding & SMTP settings.
*/
export async function completeSetup(
adminData: AdminSetupData,
brandingData: BrandingSetupData,
smtpData: SmtpSetupData
): Promise<{ success: boolean; error?: string }> {
try {
// 1. Double check if setup is actually needed to prevent double runs
const needed = await isSetupNeeded()
if (!needed) {
return { success: false, error: 'Setup bereits abgeschlossen.' }
@@ -75,7 +84,7 @@ export async function completeSetup(
const admin = createAdminClient()
// 2. Create user in Supabase Auth
// 1. Create user in Supabase Auth
const { data: authData, error: authError } = await admin.auth.admin.createUser({
email: adminData.email,
password: adminData.password,
@@ -88,17 +97,20 @@ export async function completeSetup(
const userId = authData.user.id
// 3. Update user role to admin in public.users
// 2. Set user role to admin in public.users using Service Role Client
const { error: roleError } = await admin
.from('users')
.update({ role: 'admin' })
.eq('id', userId)
.upsert({
id: userId,
role: 'admin',
}, { onConflict: 'id' })
if (roleError) {
console.error('Error setting user role to admin:', roleError)
return { success: false, error: `Fehler beim Zuweisen der Admin-Rolle: ${roleError.message}` }
}
// 4. Update company and name details in public.profiles
// 3. Update company and name details in public.profiles
const { error: profileError } = await admin
.from('profiles')
.update({
@@ -113,22 +125,46 @@ export async function completeSetup(
console.error('Error updating admin profile:', profileError)
}
// 5. Store SMTP configuration in public.settings
const { error: smtpError } = await admin
// 4. Store Branding settings in public.settings (id = 'branding')
const { error: brandingError } = await admin
.from('settings')
.upsert({
id: 'smtp',
host: smtpData.host,
port: smtpData.port,
secure: smtpData.secure,
user: smtpData.user,
pass: smtpData.pass,
id: 'branding',
company_name: adminData.companyName,
street: brandingData.street,
zip: brandingData.zip,
city: brandingData.city,
billing_street: brandingData.sameBillingAddress ? brandingData.street : brandingData.billingStreet,
billing_zip: brandingData.sameBillingAddress ? brandingData.zip : brandingData.billingZip,
billing_city: brandingData.sameBillingAddress ? brandingData.city : brandingData.billingCity,
same_billing_address: brandingData.sameBillingAddress,
color_scheme: brandingData.colorScheme,
primary_color: brandingData.primaryColor,
accent_color: brandingData.accentColor,
updated_at: new Date().toISOString(),
})
if (smtpError) {
console.error('Error saving SMTP settings:', smtpError)
return { success: false, error: `Fehler beim Speichern der SMTP-Einstellungen: ${smtpError.message}` }
if (brandingError) {
console.error('Error saving branding settings:', brandingError)
}
// 5. Store SMTP configuration in public.settings (id = 'smtp')
if (smtpData.host && smtpData.host.trim().length > 0) {
const { error: smtpError } = await admin
.from('settings')
.upsert({
id: 'smtp',
host: smtpData.host,
port: smtpData.port,
secure: smtpData.secure,
user: smtpData.user,
pass: smtpData.pass,
updated_at: new Date().toISOString(),
})
if (smtpError) {
console.error('Error saving SMTP settings:', smtpError)
}
}
revalidatePath('/')
@@ -138,3 +174,46 @@ export async function completeSetup(
return { success: false, error: e.message || 'Unerwarteter Fehler beim Setup.' }
}
}
export async function testSmtpConfig(
smtpData: SmtpSetupData,
recipient: string
): Promise<{ success: boolean; message: string }> {
try {
if (!smtpData.host || !smtpData.user) {
return { success: false, message: 'Bitte Host und Benutzername ausfüllen.' }
}
const transporter = nodemailer.createTransport({
host: smtpData.host,
port: Number(smtpData.port) || 587,
secure: smtpData.secure,
auth: {
user: smtpData.user,
pass: smtpData.pass || '',
},
tls: {
rejectUnauthorized: false,
},
})
const info = await transporter.sendMail({
from: smtpData.user,
to: recipient || smtpData.user,
subject: 'CASPOS Setup Test-E-Mail',
text: 'Diese Test-E-Mail wurde erfolgreich während des Webshop-Setups versendet.',
html: '<b>Diese Test-E-Mail wurde erfolgreich während des Webshop-Setups versendet.</b>',
})
return {
success: true,
message: `Test-E-Mail erfolgreich gesendet an ${recipient || smtpData.user}! (ID: ${info.messageId})`,
}
} catch (err: any) {
console.error('SMTP test error:', err)
return {
success: false,
message: `SMTP-Fehler: ${err.message || err}`,
}
}
}

View File

@@ -0,0 +1,210 @@
export interface BrandingSettings {
companyName: string
logoUrl?: string
developerFooter?: string
street: string
zip: string
city: string
billingStreet: string
billingZip: string
billingCity: string
sameBillingAddress: boolean
colorScheme: string
primaryColor: string
accentColor: string
successColor?: string
warningColor?: string
destructiveColor?: string
bgGlow1?: string
bgGlow2?: string
gradientFrom?: string
gradientTo?: string
cardBorder?: string
textHighlight?: string
buttonBg?: string
ringColor?: string
}
export interface ColorPreset {
id: string
name: string
description: string
primary: string
accent: string
success: string
warning: string
destructive: string
bgGlow1: string
bgGlow2: string
gradientFrom: string
gradientTo: string
cardBorder: string
textHighlight: string
buttonBg: string
ringColor: string
}
export const PRESET_COLOR_SCHEMES: ColorPreset[] = [
{
id: 'alabaster_racing_red',
name: 'Alabaster & Racing Red',
description: 'Sportlich, Dynamisch & High-End',
primary: '#dc2626',
accent: '#f8fafc',
success: '#10b981',
warning: '#f59e0b',
destructive: '#b91c1c',
bgGlow1: '#ef4444',
bgGlow2: '#7f1d1d',
gradientFrom: '#dc2626',
gradientTo: '#991b1b',
cardBorder: 'rgba(220, 38, 38, 0.35)',
textHighlight: '#fca5a5',
buttonBg: '#dc2626',
ringColor: '#ef4444',
},
{
id: 'black_cherry_gold',
name: 'Black Cherry & Gold',
description: 'Luxuriös, Exklusiv & Tief',
primary: '#881337',
accent: '#f59e0b',
success: '#10b981',
warning: '#fbbf24',
destructive: '#be123c',
bgGlow1: '#9f1239',
bgGlow2: '#d97706',
gradientFrom: '#881337',
gradientTo: '#d97706',
cardBorder: 'rgba(245, 158, 11, 0.35)',
textHighlight: '#fde68a',
buttonBg: '#881337',
ringColor: '#f59e0b',
},
{
id: 'coffee_bean_cream',
name: 'Coffee Bean & Cream',
description: 'Warm, Organisch & Elegant',
primary: '#78350f',
accent: '#fde68a',
success: '#059669',
warning: '#d97706',
destructive: '#991b1b',
bgGlow1: '#92400e',
bgGlow2: '#b45309',
gradientFrom: '#78350f',
gradientTo: '#b45309',
cardBorder: 'rgba(253, 230, 138, 0.35)',
textHighlight: '#fef3c7',
buttonBg: '#78350f',
ringColor: '#d97706',
},
{
id: 'modern_blue',
name: 'Modern Blue',
description: 'Klassisch, Vertrauensvoll & Seriös',
primary: '#2563eb',
accent: '#38bdf8',
success: '#10b981',
warning: '#f59e0b',
destructive: '#ef4444',
bgGlow1: '#3b82f6',
bgGlow2: '#1d4ed8',
gradientFrom: '#2563eb',
gradientTo: '#1e40af',
cardBorder: 'rgba(56, 189, 248, 0.35)',
textHighlight: '#93c5fd',
buttonBg: '#2563eb',
ringColor: '#38bdf8',
},
{
id: 'emerald_green',
name: 'Emerald Green',
description: 'Frisch, Nachhaltig & Vital',
primary: '#059669',
accent: '#34d399',
success: '#10b981',
warning: '#f59e0b',
destructive: '#e11d48',
bgGlow1: '#10b981',
bgGlow2: '#047857',
gradientFrom: '#059669',
gradientTo: '#065f46',
cardBorder: 'rgba(52, 211, 153, 0.35)',
textHighlight: '#a7f3d0',
buttonBg: '#059669',
ringColor: '#34d399',
},
{
id: 'violet_glow',
name: 'Violet Glow',
description: 'Kreativ, Modern & Futuristisch',
primary: '#7c3aed',
accent: '#c084fc',
success: '#10b981',
warning: '#f59e0b',
destructive: '#be123c',
bgGlow1: '#8b5cf6',
bgGlow2: '#5b21b6',
gradientFrom: '#7c3aed',
gradientTo: '#4c1d95',
cardBorder: 'rgba(192, 132, 252, 0.35)',
textHighlight: '#ddd6fe',
buttonBg: '#7c3aed',
ringColor: '#c084fc',
},
{
id: 'sunset_orange',
name: 'Sunset Orange',
description: 'Dynamisch, Aktiv & Energetisch',
primary: '#ea580c',
accent: '#fb923c',
success: '#10b981',
warning: '#f59e0b',
destructive: '#dc2626',
bgGlow1: '#f97316',
bgGlow2: '#9a3412',
gradientFrom: '#ea580c',
gradientTo: '#9a3412',
cardBorder: 'rgba(251, 146, 60, 0.35)',
textHighlight: '#ffedd5',
buttonBg: '#ea580c',
ringColor: '#fb923c',
},
{
id: 'cyan_neon',
name: 'Cyan Neon',
description: 'Futuristisch, High-Tech & Klar',
primary: '#0891b2',
accent: '#22d3ee',
success: '#10b981',
warning: '#f59e0b',
destructive: '#f43f5e',
bgGlow1: '#06b6d4',
bgGlow2: '#155e75',
gradientFrom: '#0891b2',
gradientTo: '#155e75',
cardBorder: 'rgba(34, 211, 238, 0.35)',
textHighlight: '#cffafe',
buttonBg: '#0891b2',
ringColor: '#22d3ee',
},
{
id: 'midnight_slate',
name: 'Midnight Slate',
description: 'Minimalistisch, Dunkel & Puristisch',
primary: '#475569',
accent: '#cbd5e1',
success: '#10b981',
warning: '#f59e0b',
destructive: '#ef4444',
bgGlow1: '#64748b',
bgGlow2: '#1e293b',
gradientFrom: '#475569',
gradientTo: '#0f172a',
cardBorder: 'rgba(203, 213, 225, 0.35)',
textHighlight: '#f1f5f9',
buttonBg: '#475569',
ringColor: '#cbd5e1',
},
]

View File

@@ -19,7 +19,7 @@ const nextConfig: NextConfig = {
},
{
key: 'Content-Security-Policy',
value: "default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; font-src 'self' data:; connect-src 'self' https://*.supabase.co wss://*.supabase.co https://*.supabase.net wss://*.supabase.net; frame-ancestors 'self'; form-action 'self';",
value: "default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; font-src 'self' data:; connect-src 'self' http://127.0.0.1:54321 ws://127.0.0.1:54321 http://localhost:54321 ws://localhost:54321 https://*.supabase.co wss://*.supabase.co https://*.supabase.net wss://*.supabase.net; frame-ancestors 'self'; form-action 'self';",
},
],
},

View File

@@ -1,4 +1,3 @@
-- Migration: Update user creation trigger function to not assign admin role to info@hephex.de automatically
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN

View File

@@ -1,30 +1,63 @@
-- Migration: Secure User Roles from Self-Escalation
-- Purpose: Prevent users from updating their own roles to 'admin' using RLS / Triggers.
-- Purpose: Prevent users from updating their own roles to 'admin' using RLS / Triggers, while granting full access to service_role.
-- Create helper function to check admin role bypassing RLS (SECURITY DEFINER)
CREATE OR REPLACE FUNCTION public.is_admin(user_id UUID)
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM public.users
WHERE id = user_id AND role = 'admin'
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Ensure RLS is enabled on users
ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;
-- Grant privileges to PostgREST roles
GRANT ALL ON TABLE public.users TO service_role;
GRANT ALL ON TABLE public.users TO authenticated;
GRANT SELECT ON TABLE public.users TO anon;
GRANT ALL ON TABLE public.users TO postgres;
-- Service role policy
DROP POLICY IF EXISTS "service_role_all_users" ON public.users;
CREATE POLICY "service_role_all_users" ON public.users
FOR ALL
TO service_role
USING (true)
WITH CHECK (true);
-- Policy to allow users to view their own records
DROP POLICY IF EXISTS select_own_user ON public.users;
CREATE POLICY select_own_user ON public.users
FOR SELECT
TO authenticated
USING (auth.uid() = id);
-- Policy to allow admins to view all users
DROP POLICY IF EXISTS select_all_users_for_admin ON public.users;
CREATE POLICY select_all_users_for_admin ON public.users
FOR SELECT
TO authenticated
USING (
(SELECT role FROM public.users WHERE id = auth.uid()) = 'admin'
);
USING (public.is_admin(auth.uid()));
-- Policy to allow admins to update users
DROP POLICY IF EXISTS update_users_for_admin ON public.users;
CREATE POLICY update_users_for_admin ON public.users
FOR UPDATE
TO authenticated
USING (public.is_admin(auth.uid()))
WITH CHECK (public.is_admin(auth.uid()));
-- Trigger to prevent any role updates to 'admin' from unauthorized users
CREATE OR REPLACE FUNCTION check_user_role_escalation()
RETURNS TRIGGER AS $$
BEGIN
-- Only allow changes to the role column if executed by the service_role
-- Allow changes to the role column if executed by administrative DB roles or service_role JWT
IF (TG_OP = 'UPDATE' AND OLD.role IS DISTINCT FROM NEW.role) OR (TG_OP = 'INSERT') THEN
IF current_setting('role', true) <> 'service_role' THEN
IF current_setting('request.jwt.claim.role', true) <> 'service_role'
AND current_setting('role', true) NOT IN ('service_role', 'supabase_admin', 'postgres') THEN
-- Partners cannot upgrade themselves or others to admin
IF NEW.role = 'admin' THEN
RAISE EXCEPTION 'Unberechtigtes Rollen-Upgrade verweigert.';

View File

@@ -1,26 +1,28 @@
-- Migration: Restrict settings table write access to admins only
-- Previously any authenticated user could write to settings (including licserver_api_key).
-- This fixes the RLS policy to only allow admins to write.
-- Migration: Restrict settings table write access to admins only and allow service_role full access
ALTER TABLE public.settings ENABLE ROW LEVEL SECURITY;
-- Grant privileges to PostgREST roles
GRANT ALL ON TABLE public.settings TO service_role;
GRANT ALL ON TABLE public.settings TO authenticated;
GRANT SELECT ON TABLE public.settings TO anon;
GRANT ALL ON TABLE public.settings TO postgres;
-- Service role policy
DROP POLICY IF EXISTS "service_role_all_settings" ON public.settings;
CREATE POLICY "service_role_all_settings" ON public.settings
FOR ALL
TO service_role
USING (true)
WITH CHECK (true);
DROP POLICY IF EXISTS "Allow authenticated write on settings" ON public.settings;
DROP POLICY IF EXISTS "Only admins can write settings" ON public.settings;
-- Admins can write all settings
CREATE POLICY "Only admins can write settings" ON public.settings
FOR ALL
USING (
EXISTS (
SELECT 1 FROM public.users
WHERE id = auth.uid() AND role = 'admin'
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.users
WHERE id = auth.uid() AND role = 'admin'
)
);
-- All authenticated users can still READ settings (needed for proxy routes to load licserver config)
-- The READ policy remains: "Allow authenticated read on settings"
TO authenticated
USING (public.is_admin(auth.uid()))
WITH CHECK (public.is_admin(auth.uid()));
NOTIFY pgrst, 'reload schema';

View File

@@ -0,0 +1,17 @@
-- Migration: Add Branding & Company Settings to public.settings
-- Stores company details, addresses, and chosen color scheme.
ALTER TABLE public.settings
ADD COLUMN IF NOT EXISTS company_name TEXT,
ADD COLUMN IF NOT EXISTS street TEXT,
ADD COLUMN IF NOT EXISTS zip TEXT,
ADD COLUMN IF NOT EXISTS city TEXT,
ADD COLUMN IF NOT EXISTS billing_street TEXT,
ADD COLUMN IF NOT EXISTS billing_zip TEXT,
ADD COLUMN IF NOT EXISTS billing_city TEXT,
ADD COLUMN IF NOT EXISTS same_billing_address BOOLEAN DEFAULT true,
ADD COLUMN IF NOT EXISTS logo_url TEXT,
ADD COLUMN IF NOT EXISTS developer_footer TEXT DEFAULT 'B2B Shop made by hephex',
ADD COLUMN IF NOT EXISTS color_scheme TEXT DEFAULT 'modern_blue',
ADD COLUMN IF NOT EXISTS primary_color TEXT DEFAULT '#2563eb',
ADD COLUMN IF NOT EXISTS accent_color TEXT DEFAULT '#38bdf8';

View File

@@ -2,15 +2,15 @@
INSERT INTO public.products (id, name, description, base_price, tax_rate, billing_interval, show_in_abo, show_in_kauf)
VALUES
('d1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'CASPOS Cloud', 'Die modulare Cloud-Lösung für Ihren Einzelhandel.', 49.00, 19.00, 'monthly', true, true),
('d2a2a2a2-a2a2-a2a2-a2a2-a2a2-a2a2-a2a2a2a2', 'CASPOS Gastro', 'Spezialisiert auf Gastronomie mit Tischplan und Funkbonieren.', 79.00, 19.00, 'monthly', true, true),
('prod-poscloud-fee', 'POS Cloud Grundgebühr', 'Monatliche Grundgebühr für die POS Cloud Nutzung.', 19.00, 19.00, 'monthly', false, false);
('d2a2a2a2-a2a2-a2a2-a2a2-a2a2a2a2a2a2', 'CASPOS Gastro', 'Spezialisiert auf Gastronomie mit Tischplan und Funkbonieren.', 79.00, 19.00, 'monthly', true, true),
('d3a3a3a3-a3a3-a3a3-a3a3-a3a3a3a3a3a3', 'POS Cloud Grundgebühr', 'Monatliche Grundgebühr für die POS Cloud Nutzung.', 19.00, 19.00, 'monthly', false, false);
-- Seed Modules for CASPOS Cloud
INSERT INTO public.product_modules (id, product_id, name, description, price, requirements, exclusions)
VALUES
('m1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'd1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'Bestandsführung Pro', 'Erweiterte Lagerverwaltung.', 15.00, '{}', '{}'),
('m2a2a2a2-a2a2-a2a2-a2a2-a2a2-a2a2-a2a2a2a2', 'd1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'DATEV Export', 'Direkte Schnittstelle zum Steuerberater.', 10.00, '{}', '{}'),
('m3a3a3a3-a3a3-a3a3-a3a3-a3a3-a3a3-a3a3-a3a3', 'd1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'Filialverwaltung', 'Zentrale Steuerung mehrerer Standorte.', 25.00, '{"m1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1"}', '{}'),
('m4a4a4a4-a4a4-a4a4-a4a4-a4a4-a4a4-a4a4-a4a4', 'd1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'Small Business Modus', 'Reduzierter Funktionsumfang für Kleinunternehmer.', 0.00, '{}', '{"m1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1", "m3a3a3a3-a3a3-a3a3-a3a3-a3a3-a3a3-a3a3-a3a3"}'),
('m-poscloud-cloud', 'd1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'Schnittstelle POS Cloud', 'Anbindung an die POS Cloud.', 0.00, '{}', '{}'),
('m-poscloud-gastro', 'd2a2a2a2-a2a2-a2a2-a2a2-a2a2-a2a2-a2a2a2a2', 'Schnittstelle POS Cloud', 'Anbindung an die POS Cloud.', 0.00, '{}', '{}');
('e1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'd1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'Bestandsführung Pro', 'Erweiterte Lagerverwaltung.', 15.00, '{}', '{}'),
('e2a2a2a2-a2a2-a2a2-a2a2-a2a2a2a2a2a2', 'd1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'DATEV Export', 'Direkte Schnittstelle zum Steuerberater.', 10.00, '{}', '{}'),
('e3a3a3a3-a3a3-a3a3-a3a3-a3a3a3a3a3a3', 'd1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'Filialverwaltung', 'Zentrale Steuerung mehrerer Standorte.', 25.00, '{"e1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1"}', '{}'),
('e4a4a4a4-a4a4-a4a4-a4a4-a4a4a4a4a4a4', 'd1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'Small Business Modus', 'Reduzierter Funktionsumfang für Kleinunternehmer.', 0.00, '{}', '{"e1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1", "e3a3a3a3-a3a3-a3a3-a3a3-a3a3a3a3a3a3"}'),
('e5a5a5a5-a5a5-a5a5-a5a5-a5a5a5a5a5a5', 'd1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'Schnittstelle POS Cloud', 'Anbindung an die POS Cloud.', 0.00, '{}', '{}'),
('e6a6a6a6-a6a6-a6a6-a6a6-a6a6a6a6a6a6', 'd2a2a2a2-a2a2-a2a2-a2a2-a2a2a2a2a2a2', 'Schnittstelle POS Cloud', 'Anbindung an die POS Cloud.', 0.00, '{}', '{}');

View File

@@ -4,6 +4,7 @@ import { createAdminClient } from '@/lib/supabase/admin';
/**
* Send an email using database-configured SMTP settings (or environment variable fallback).
* If SMTP is not configured, logs a warning and gracefully skips sending instead of throwing.
* @param to Recipient address
* @param subject Subject line
* @param text Plaintext body
@@ -26,47 +27,63 @@ export async function sendMail({
contentType?: string;
}[];
}) {
// Query custom SMTP settings from database
const supabase = createAdminClient();
const { data: dbSettings } = await supabase
.from('settings')
.select('*')
.eq('id', 'smtp')
.maybeSingle();
try {
// Query custom SMTP settings from database
const supabase = createAdminClient();
const { data: dbSettings } = await supabase
.from('settings')
.select('*')
.eq('id', 'smtp')
.maybeSingle();
// Resolve config from DB or environment variables
const host = dbSettings?.host || process.env.SMTP_HOST;
const port = dbSettings?.port ? Number(dbSettings.port) : Number(process.env.SMTP_PORT);
const secure = dbSettings
? !!dbSettings.secure
: (process.env.SMTP_SECURE === 'true' || process.env.SMTP_SECURE === '1');
const user = dbSettings?.user || process.env.SMTP_USER;
const pass = dbSettings?.pass || process.env.SMTP_PASS;
// Resolve config from DB or environment variables
const host = dbSettings?.host || process.env.SMTP_HOST;
const port = dbSettings?.port
? Number(dbSettings.port)
: process.env.SMTP_PORT
? Number(process.env.SMTP_PORT)
: 587;
const secure = dbSettings
? !!dbSettings.secure
: process.env.SMTP_SECURE === 'true' || process.env.SMTP_SECURE === '1';
const user = dbSettings?.user || process.env.SMTP_USER;
const pass = dbSettings?.pass || process.env.SMTP_PASS;
if (!host || !user) {
throw new Error('SMTP host and user must be configured (either in settings database table or environment variables).');
if (!host || !user) {
console.warn(
`SMTP is not configured (missing host/user). E-Mail to "${to}" skipped.`
);
return { messageId: 'skipped-no-smtp-config', skipped: true };
}
// Create transporter dynamically on send request
const transporter = nodemailer.createTransport({
host,
port,
secure,
auth: {
user,
pass: pass || '',
},
tls: {
rejectUnauthorized: false,
},
});
const fromAddress = process.env.SMTP_FROM || user;
const info = await transporter.sendMail({
from: fromAddress,
to,
subject,
text,
html,
attachments,
});
return info;
} catch (err: any) {
console.error(`Failed to send email to "${to}":`, err.message || err);
return { messageId: 'failed-smtp-error', error: err.message || err };
}
// Create transporter dynamically on send request
const transporter = nodemailer.createTransport({
host,
port,
secure,
auth: {
user,
pass,
},
});
const info = await transporter.sendMail({
from: user,
to,
subject,
text,
html,
attachments,
});
return info;
}