From 9ecdc645e84f962fe8bc9ba70f8232748a40e126 Mon Sep 17 00:00:00 2001 From: DanielS Date: Fri, 14 Aug 2026 15:57:29 +0200 Subject: [PATCH] feat(branding): add global dynamic theme system and fix 2fa mail --- shop/app/admin/einstellungen/page.tsx | 284 +++++++++++- shop/app/globals.css | 85 +++- shop/app/layout.tsx | 2 +- shop/components/AsciiShaderBackground.tsx | 79 ++-- shop/components/HomeClient.tsx | 6 +- shop/components/NavbarClient.tsx | 12 +- shop/components/ScrollIndicator.tsx | 8 +- shop/components/SetupWizard.tsx | 438 ++++++++++++++---- shop/components/ThemeProvider.tsx | 111 +++++ shop/components/admin/ColorThemePicker.tsx | 267 +++++++++++ shop/components/login-form.tsx | 6 +- shop/lib/actions/auth.ts | 90 ++-- shop/lib/actions/branding.ts | 122 +++++ shop/lib/actions/setup.ts | 137 ++++-- shop/lib/constants/branding.ts | 210 +++++++++ shop/next.config.ts | 2 +- ...703095900_remove_default_admin_trigger.sql | 1 - .../20260709231000_secure_user_roles.sql | 47 +- ...0100_fix_settings_rls_admin_only_write.sql | 40 +- .../20260814000000_add_branding_settings.sql | 17 + shop/supabase/seed.sql | 16 +- shop/utils/mail.ts | 97 ++-- 22 files changed, 1764 insertions(+), 313 deletions(-) create mode 100644 shop/components/ThemeProvider.tsx create mode 100644 shop/components/admin/ColorThemePicker.tsx create mode 100644 shop/lib/actions/branding.ts create mode 100644 shop/lib/constants/branding.ts create mode 100644 shop/supabase/migrations/20260814000000_add_branding_settings.sql diff --git a/shop/app/admin/einstellungen/page.tsx b/shop/app/admin/einstellungen/page.tsx index 4dd8c8e..ab987a9 100644 --- a/shop/app/admin/einstellungen/page.tsx +++ b/shop/app/admin/einstellungen/page.tsx @@ -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({ + 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
; } @@ -176,7 +227,7 @@ export default function AdminSettings() {

Admin Einstellungen

- Zentrales Bento-Dashboard für System-, Backup- und Lizenz-Konfigurationen. + Zentrales Dashboard für Firmendaten, Branding, Backup & Lizenz-Konfigurationen.

@@ -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 */} - {/* Bento Item 2: Daten Export (1 Spalte) */} + {/* Bento Item 2: Daten Export */}

DB Backup

- Lädt alle Produkte, Firmen, Lizenzen & Einstellungen als ZIP-Archiv herunter. + Lädt alle Produkte, Firmen, Lizenzen & Einstellungen als ZIP-Archiv herunter.

@@ -256,7 +307,7 @@ export default function AdminSettings() {
- {/* Bento Item 3: Daten Import (2 Spalten / Span 2 auf Desktop) */} + {/* Bento Item 3: Daten Import */} - {/* Bento Item 4: LicServer Konfiguration (Breites Bento: 4 Spalten auf Large) */} + {/* Bento Item 4: Firmendaten & Anschriften (4 Spalten) */} + +
+
+
+ +
+
+

Firmendaten & Rechnungsadresse

+

Verwaltung der eigenen Unternehmensanschrift für Rechnungen und Dokumente

+
+
+ + +
+ + {brandingMsg && ( +
+ {brandingMsg} +
+ )} + +
+
+
+ + setBranding({ ...branding, companyName: e.target.value })} + placeholder="z. B. Meine Firma GmbH" + className="bg-slate-950/80 border-slate-800 text-white text-sm" + /> +
+ +
+ + 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" + /> +
+
+ +
+ + setBranding({ ...branding, developerFooter: e.target.value })} + placeholder="B2B Shop made by hephex" + className="bg-slate-950/80 border-slate-800 text-white text-sm" + /> +
+ + {/* Anschrift */} +
+
+ + Hauptanschrift + +
+ + setBranding({ ...branding, street: e.target.value })} + placeholder="Musterstraße 12" + className="bg-slate-950/80 border-slate-800 text-white text-xs" + /> +
+
+
+ + setBranding({ ...branding, zip: e.target.value })} + placeholder="12345" + className="bg-slate-950/80 border-slate-800 text-white text-xs" + /> +
+
+ + setBranding({ ...branding, city: e.target.value })} + placeholder="Musterstadt" + className="bg-slate-950/80 border-slate-800 text-white text-xs" + /> +
+
+
+ + {/* Rechnungsadresse */} +
+
+ + Rechnungsadresse + + +
+ + {!branding.sameBillingAddress ? ( +
+
+ + setBranding({ ...branding, billingStreet: e.target.value })} + placeholder="Rechnungsstraße 45" + className="bg-slate-950/80 border-slate-800 text-white text-xs" + /> +
+
+
+ + setBranding({ ...branding, billingZip: e.target.value })} + placeholder="54321" + className="bg-slate-950/80 border-slate-800 text-white text-xs" + /> +
+
+ + setBranding({ ...branding, billingCity: e.target.value })} + placeholder="Rechnungsstadt" + className="bg-slate-950/80 border-slate-800 text-white text-xs" + /> +
+
+
+ ) : ( +
+ Verwendet automatisch die Hauptanschrift oben. +
+ )} +
+
+
+
+ + {/* Bento Item 5: Farbschema & Design-System (4 Spalten) */} + +
+
+
+ +
+
+

Farbschema & Webshop Styling

+

Wählen Sie das primäre Farbschema für Buttons, Akzente und Highlights

+
+
+ + +
+ + + setBranding({ + ...branding, + colorScheme: scheme, + primaryColor: primary, + accentColor: accent, + }) + } + /> +
+ + {/* Bento Item 6: LicServer Konfiguration */} - {/* LicServer Status Pill */} {licStatus && (
- {/* Base URL */}
- {/* API Key */}
- {/* Action Buttons */}
@@ -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 + + +
+
+ )} + + {/* STEP 3: Firmendaten & Rechnungsadresse */} + {step === 3 && ( + +
+

+ + Firmendaten & Adressen +

+

+ Tragen Sie Ihren Firmennamen, die Anschrift und die Rechnungsadresse ein. +

+
+ +
+
+ +
+ + 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" + /> +
+
+ + {/* Anschrift */} +
+ + Firmenanschrift + + +
+ + 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" + /> +
+ +
+
+ + setBrandingForm({ ...brandingForm, zip: e.target.value })} + placeholder="12345" + className="bg-white/5 border-white/10 text-white text-xs" + /> +
+
+ + setBrandingForm({ ...brandingForm, city: e.target.value })} + placeholder="Musterstadt" + className="bg-white/5 border-white/10 text-white text-xs" + /> +
+
+
+ + {/* Rechnungsadresse */} +
+
+ + Rechnungsadresse + + +
+ + {!brandingForm.sameBillingAddress && ( +
+
+ + + setBrandingForm({ ...brandingForm, billingStreet: e.target.value }) + } + placeholder="Rechnungsstraße 45" + className="bg-white/5 border-white/10 text-white text-xs" + /> +
+
+
+ + + setBrandingForm({ ...brandingForm, billingZip: e.target.value }) + } + placeholder="54321" + className="bg-white/5 border-white/10 text-white text-xs" + /> +
+
+ + + setBrandingForm({ ...brandingForm, billingCity: e.target.value }) + } + placeholder="Rechnungsstadt" + className="bg-white/5 border-white/10 text-white text-xs" + /> +
+
+
+ )} +
+
+ +
+ + +
+
+ )} + + {/* STEP 4: Farbschema & Live-Vorschau */} + {step === 4 && ( + +
+

+ + Farbschema & Webshop Styling +

+

+ Wählen Sie aus 9 vorgegebenen Farbpaletten oder erstellen Sie eine eigene Farbkombination mit Live-Vorschau. +

+
+ + + setBrandingForm({ + ...brandingForm, + colorScheme: scheme, + primaryColor: primary, + accentColor: accent, + }) + } + /> + +
+ +
+ + {testSmtpResult && ( +
+ {testSmtpResult.message} +
+ )} -
+
+ + ) + })} +
+
+ + {/* Custom Color Picker */} +
+
+ +
+ + {isCustom && ( +
+ {/* Primary Color Picker */} +
+ +
+ handleCustomPrimaryChange(e.target.value)} + className="w-9 h-9 rounded-xl border border-slate-700 bg-transparent cursor-pointer shrink-0" + /> + handleCustomPrimaryChange(e.target.value)} + placeholder="#2563eb" + className="font-mono text-xs uppercase bg-slate-950 border-slate-800 text-white" + /> +
+
+ + {/* Accent Color Picker */} +
+ +
+ handleCustomAccentChange(e.target.value)} + className="w-9 h-9 rounded-xl border border-slate-700 bg-transparent cursor-pointer shrink-0" + /> + handleCustomAccentChange(e.target.value)} + placeholder="#38bdf8" + className="font-mono text-xs uppercase bg-slate-950 border-slate-800 text-white" + /> +
+
+
+ )} +
+ + {/* Live Micro-Preview Card with Animated Background Orbs */} +
+
+ + + Live Vorschau & Animierter Background Glow + + + {primaryColor} / {accentColor} + +
+ +
+ {/* Animated Background Orbs Preview */} +
+
+ + {/* Header Preview */} +
+
+
+ +
+ + {companyName || 'Ihr Unternehmen'} + +
+ + Aktiv + +
+ + {/* Buttons & Badges Preview */} +
+ + + + + + + Empfohlen + +
+
+
+
+ ) +} diff --git a/shop/components/login-form.tsx b/shop/components/login-form.tsx index 6d018fc..599c428 100644 --- a/shop/components/login-form.tsx +++ b/shop/components/login-form.tsx @@ -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; } }; diff --git a/shop/lib/actions/auth.ts b/shop/lib/actions/auth.ts index aaee1ce..49df9e4 100644 --- a/shop/lib/actions/auth.ts +++ b/shop/lib/actions/auth.ts @@ -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.' } } } diff --git a/shop/lib/actions/branding.ts b/shop/lib/actions/branding.ts new file mode 100644 index 0000000..a814d21 --- /dev/null +++ b/shop/lib/actions/branding.ts @@ -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 { + 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' } + } +} diff --git a/shop/lib/actions/setup.ts b/shop/lib/actions/setup.ts index 045e23f..233957c 100644 --- a/shop/lib/actions/setup.ts +++ b/shop/lib/actions/setup.ts @@ -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 { 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 { /** * 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: 'Diese Test-E-Mail wurde erfolgreich während des Webshop-Setups versendet.', + }) + + 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}`, + } + } +} diff --git a/shop/lib/constants/branding.ts b/shop/lib/constants/branding.ts new file mode 100644 index 0000000..e606fb9 --- /dev/null +++ b/shop/lib/constants/branding.ts @@ -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', + }, +] diff --git a/shop/next.config.ts b/shop/next.config.ts index b86b59c..73fae90 100644 --- a/shop/next.config.ts +++ b/shop/next.config.ts @@ -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';", }, ], }, diff --git a/shop/supabase/migrations/20260703095900_remove_default_admin_trigger.sql b/shop/supabase/migrations/20260703095900_remove_default_admin_trigger.sql index cb41a97..7b974a8 100644 --- a/shop/supabase/migrations/20260703095900_remove_default_admin_trigger.sql +++ b/shop/supabase/migrations/20260703095900_remove_default_admin_trigger.sql @@ -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 diff --git a/shop/supabase/migrations/20260709231000_secure_user_roles.sql b/shop/supabase/migrations/20260709231000_secure_user_roles.sql index a4b04c6..2cd4250 100644 --- a/shop/supabase/migrations/20260709231000_secure_user_roles.sql +++ b/shop/supabase/migrations/20260709231000_secure_user_roles.sql @@ -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.'; diff --git a/shop/supabase/migrations/20260714000100_fix_settings_rls_admin_only_write.sql b/shop/supabase/migrations/20260714000100_fix_settings_rls_admin_only_write.sql index c56ca00..e235fde 100644 --- a/shop/supabase/migrations/20260714000100_fix_settings_rls_admin_only_write.sql +++ b/shop/supabase/migrations/20260714000100_fix_settings_rls_admin_only_write.sql @@ -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'; diff --git a/shop/supabase/migrations/20260814000000_add_branding_settings.sql b/shop/supabase/migrations/20260814000000_add_branding_settings.sql new file mode 100644 index 0000000..fa683b3 --- /dev/null +++ b/shop/supabase/migrations/20260814000000_add_branding_settings.sql @@ -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'; diff --git a/shop/supabase/seed.sql b/shop/supabase/seed.sql index 42519f9..23175d0 100644 --- a/shop/supabase/seed.sql +++ b/shop/supabase/seed.sql @@ -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, '{}', '{}'); diff --git a/shop/utils/mail.ts b/shop/utils/mail.ts index 811ff06..e2417c7 100644 --- a/shop/utils/mail.ts +++ b/shop/utils/mail.ts @@ -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 Plain‑text 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; } -