feat(branding): add global dynamic theme system and fix 2fa mail
This commit is contained in:
@@ -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.' }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
122
shop/lib/actions/branding.ts
Normal file
122
shop/lib/actions/branding.ts
Normal 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' }
|
||||
}
|
||||
}
|
||||
@@ -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}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user