373 lines
14 KiB
TypeScript
373 lines
14 KiB
TypeScript
'use server'
|
||
|
||
import { createClient } from '@/lib/supabase/server'
|
||
import { createAdminClient } from '@/lib/supabase/admin'
|
||
import { headers } from 'next/headers'
|
||
import { sendLockoutEmail } from '@/lib/utils/email'
|
||
import { sendMail } from '@/utils/mail'
|
||
import crypto from 'crypto'
|
||
|
||
export async function signIn(email: string, password: string, deviceHash?: string) {
|
||
try {
|
||
const supabase = await createClient()
|
||
const { data, error } = await supabase.auth.signInWithPassword({
|
||
email,
|
||
password,
|
||
})
|
||
if (error) {
|
||
return { success: false, error: error.message }
|
||
}
|
||
|
||
const userId = data.user?.id
|
||
if (userId) {
|
||
const adminClient = createAdminClient()
|
||
let { data: userData, error: userError } = await adminClient
|
||
.from('users')
|
||
.select('role')
|
||
.eq('id', userId)
|
||
.single()
|
||
|
||
if (userError || !userData) {
|
||
// Fallback: create database records if missing for this authenticated user
|
||
try {
|
||
const adminSupabase = createAdminClient()
|
||
|
||
// Ensure profile exists
|
||
await adminSupabase.from('profiles').insert([{ id: userId }]).select()
|
||
|
||
// Ensure user exists
|
||
const { data: insertedUser, error: insertError } = await adminSupabase
|
||
.from('users')
|
||
.insert([{ id: userId, role: 'partner' }])
|
||
.select('role')
|
||
.single()
|
||
|
||
if (!insertError && insertedUser) {
|
||
userData = insertedUser
|
||
userError = null
|
||
}
|
||
} catch (adminErr) {
|
||
console.error("Failed to backfill user records:", adminErr)
|
||
}
|
||
}
|
||
|
||
if (userError || !userData || (userData.role !== 'partner' && userData.role !== 'admin' && userData.role !== 'verwaltung')) {
|
||
await supabase.auth.signOut()
|
||
if (userData?.role === 'gesperrt') {
|
||
return { success: false, error: "Ihr Konto wurde gesperrt. Bitte wenden Sie sich an den Administrator." }
|
||
}
|
||
return { success: false, error: "Zugriff verweigert. Nur registrierte Partner dürfen sich anmelden." }
|
||
}
|
||
|
||
// --- 2FA: Geräteprüfung ---
|
||
if (deviceHash) {
|
||
const { data: knownDevice } = await adminClient
|
||
.from('known_devices')
|
||
.select('id')
|
||
.eq('user_id', userId)
|
||
.eq('device_hash', deviceHash)
|
||
.maybeSingle()
|
||
|
||
if (!knownDevice) {
|
||
// Gerät unbekannt -> 2FA erforderlich
|
||
// Session bleibt aktiv, aber Client muss 2FA bestätigen
|
||
await send2FACodeInternal(userId, deviceHash, email)
|
||
return { success: true, requires2FA: true, userId, role: userData.role }
|
||
}
|
||
}
|
||
|
||
// Andere aktive Sitzungen beenden
|
||
await supabase.auth.signOut({ scope: 'others' })
|
||
|
||
return { success: true, role: userData.role }
|
||
}
|
||
|
||
return { success: true, role: 'partner' }
|
||
} catch (err: any) {
|
||
console.error("SignIn error:", err)
|
||
return { success: false, error: err?.message || "Ein unerwarteter Fehler ist aufgetreten." }
|
||
}
|
||
}
|
||
|
||
export async function signUp(email: string, password: string) {
|
||
throw new Error("Registrierung ist deaktiviert.")
|
||
}
|
||
|
||
export async function signOut() {
|
||
const supabase = await createClient()
|
||
const { error } = await supabase.auth.signOut()
|
||
if (error) {
|
||
throw new Error(error.message)
|
||
}
|
||
return { success: true }
|
||
}
|
||
|
||
function getBeautifulEmailHtml(title: string, messageHtml: string, buttonText?: string, buttonUrl?: string) {
|
||
return `
|
||
<div style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; background-color: #f8fafc; padding: 40px 10px; margin: 0; width: 100%;">
|
||
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 16px; overflow: hidden; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -2px rgba(0, 0, 0, 0.05); border: 1px solid #e2e8f0;">
|
||
<!-- Header banner with gradient -->
|
||
<div style="background: linear-gradient(135deg, #1e3a8a 0%, #312e81 100%); padding: 32px; text-align: center;">
|
||
<h1 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 800; letter-spacing: -0.5px;">${title}</h1>
|
||
</div>
|
||
|
||
<!-- Content -->
|
||
<div style="padding: 40px 32px; background-color: #ffffff;">
|
||
${messageHtml}
|
||
|
||
${buttonText && buttonUrl ? `
|
||
<div style="text-align: center; margin: 32px 0;">
|
||
<a href="${buttonUrl}" style="background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); color: #ffffff; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: bold; font-size: 15px; display: inline-block; box-shadow: 0 4px 10px rgba(37, 99, 235, 0.2); transition: all 0.2s ease;">
|
||
${buttonText}
|
||
</a>
|
||
</div>
|
||
<p style="color: #64748b; font-size: 13px; line-height: 1.6; margin-top: 32px; border-top: 1px solid #f1f5f9; padding-top: 20px;">
|
||
Falls der Button nicht funktioniert, kopieren Sie bitte diesen Link in Ihren Browser:<br>
|
||
<a href="${buttonUrl}" style="color: #2563eb; text-decoration: none; word-break: break-all;">${buttonUrl}</a>
|
||
</p>
|
||
` : ''}
|
||
|
||
<hr style="border: 0; border-top: 1px solid #f1f5f9; margin: 32px 0;">
|
||
<p style="color: #94a3b8; font-size: 11px; text-align: center; margin: 0; line-height: 1.6;">
|
||
Dies ist eine automatisch generierte E-Mail.<br>
|
||
© ${new Date().getFullYear()} CASPOS Shop. Alle Rechte vorbehalten.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function get2FAEmailHtml(code: string) {
|
||
const digits = code.split('')
|
||
const digitBoxes = digits.map(d =>
|
||
`<td style="width: 48px; height: 56px; text-align: center; vertical-align: middle; font-size: 28px; font-weight: 800; color: #1e293b; background-color: #f1f5f9; border: 2px solid #e2e8f0; border-radius: 12px; font-family: 'Courier New', monospace; letter-spacing: 0;">${d}</td>`
|
||
).join('<td style="width: 8px;"></td>')
|
||
|
||
const messageHtml = `
|
||
<p style="color: #475569; font-size: 16px; line-height: 1.6; margin-top: 0;">Hallo,</p>
|
||
<p style="color: #475569; font-size: 16px; line-height: 1.6;">
|
||
Wir haben eine Anmeldung von einem neuen Gerät erkannt. Bitte bestätigen Sie Ihre Identität mit folgendem Sicherheitscode:
|
||
</p>
|
||
|
||
<div style="text-align: center; margin: 32px 0;">
|
||
<table cellpadding="0" cellspacing="0" style="margin: 0 auto;">
|
||
<tr>${digitBoxes}</tr>
|
||
</table>
|
||
</div>
|
||
|
||
<div style="background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); border-radius: 12px; padding: 16px 20px; margin: 24px 0; border-left: 4px solid #f59e0b;">
|
||
<p style="color: #92400e; font-size: 14px; margin: 0; line-height: 1.5;">
|
||
⏱️ <strong>Dieser Code ist 15 Minuten gültig.</strong><br>
|
||
Falls Sie diese Anmeldung nicht durchgeführt haben, ändern Sie bitte umgehend Ihr Passwort.
|
||
</p>
|
||
</div>
|
||
|
||
<p style="color: #64748b; font-size: 13px; line-height: 1.6; margin-top: 24px;">
|
||
🔒 Dieser Code dient der Sicherheit Ihres Kontos. Geben Sie ihn niemals an andere Personen weiter.
|
||
</p>
|
||
`
|
||
|
||
return getBeautifulEmailHtml('🔐 Sicherheitscode', messageHtml)
|
||
}
|
||
|
||
/** Internal: generates code + sends email (no auth check needed) */
|
||
async function send2FACodeInternal(userId: string, deviceHash: string, email: string) {
|
||
const adminClient = createAdminClient()
|
||
const code = Math.floor(100000 + Math.random() * 900000).toString()
|
||
const expiresAt = new Date(Date.now() + 15 * 60 * 1000).toISOString()
|
||
|
||
// Alte Codes für dieses Gerät löschen
|
||
await adminClient
|
||
.from('device_verification_codes')
|
||
.delete()
|
||
.eq('user_id', userId)
|
||
.eq('device_hash', deviceHash)
|
||
|
||
// Neuen Code speichern
|
||
await adminClient
|
||
.from('device_verification_codes')
|
||
.insert({ user_id: userId, code, device_hash: deviceHash, expires_at: expiresAt })
|
||
|
||
// Mail senden
|
||
await sendMail({
|
||
to: email,
|
||
subject: '🔐 Ihr Sicherheitscode – CASPOS Shop',
|
||
text: `Ihr Sicherheitscode lautet: ${code}\n\nDieser Code ist 15 Minuten gültig.\n\nFalls Sie diese Anmeldung nicht durchgeführt haben, ändern Sie bitte umgehend Ihr Passwort.`,
|
||
html: get2FAEmailHtml(code),
|
||
})
|
||
}
|
||
|
||
/** Public server action: resend 2FA code */
|
||
export async function resend2FACode(userId: string, deviceHash: string) {
|
||
try {
|
||
const adminClient = createAdminClient()
|
||
const { data: authUser, error: authError } = await adminClient.auth.admin.getUserById(userId)
|
||
const email = authUser?.user?.email
|
||
|
||
if (authError || !email) return { success: false, error: 'Benutzer-E-Mail nicht gefunden.' }
|
||
|
||
await send2FACodeInternal(userId, deviceHash, email)
|
||
return { success: true }
|
||
} catch (err: any) {
|
||
console.error('Resend 2FA error:', err)
|
||
return { success: false, error: 'Fehler beim erneuten Senden des Codes.' }
|
||
}
|
||
}
|
||
|
||
/** Verify 2FA code and register device */
|
||
export async function verifyDevice2FA(userId: string, code: string, deviceHash: string) {
|
||
try {
|
||
const adminClient = createAdminClient()
|
||
const cleanCode = code.trim()
|
||
|
||
// 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('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.' }
|
||
}
|
||
|
||
// Ablauf prüfen
|
||
if (new Date(codeEntry.expires_at) < new Date()) {
|
||
// Code abgelaufen -> löschen
|
||
await adminClient
|
||
.from('device_verification_codes')
|
||
.delete()
|
||
.eq('id', codeEntry.id)
|
||
return { success: false, error: 'Der Code ist abgelaufen. Bitte fordern Sie einen neuen Code an.' }
|
||
}
|
||
|
||
// Gerät registrieren
|
||
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: targetDeviceHash,
|
||
user_agent: userAgent,
|
||
ip_address: ip,
|
||
verified_at: new Date().toISOString(),
|
||
}, { onConflict: 'user_id,device_hash' })
|
||
|
||
// Verbrauchte Codes für diesen User löschen
|
||
await adminClient
|
||
.from('device_verification_codes')
|
||
.delete()
|
||
.eq('user_id', userId)
|
||
|
||
// Andere Sessions beenden
|
||
const supabase = await createClient()
|
||
await supabase.auth.signOut({ scope: 'others' })
|
||
|
||
// Rolle zurückgeben
|
||
const { data: userData } = await adminClient
|
||
.from('users')
|
||
.select('role')
|
||
.eq('id', userId)
|
||
.single()
|
||
|
||
return { success: true, role: userData?.role || 'partner' }
|
||
} catch (err: any) {
|
||
console.error('Verify 2FA error:', err)
|
||
return { success: false, error: 'Fehler bei der Code-Überprüfung.' }
|
||
}
|
||
}
|
||
|
||
export async function resetPassword(email: string) {
|
||
try {
|
||
const admin = createAdminClient()
|
||
const { data, error } = await admin.auth.admin.generateLink({
|
||
type: 'recovery',
|
||
email,
|
||
})
|
||
if (error) {
|
||
return { success: false, error: error.message }
|
||
}
|
||
const tokenHash = (data as any)?.properties?.hashed_token;
|
||
if (!tokenHash) {
|
||
return { success: false, error: 'Wiederherstellungs-Token konnte nicht generiert werden.' }
|
||
}
|
||
|
||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de'
|
||
const resetLink = `${siteUrl}/auth/verify-link?token_hash=${tokenHash}&type=recovery&next=/auth/update-password`
|
||
|
||
const messageHtml = `
|
||
<p style="color: #475569; font-size: 16px; line-height: 1.6; margin-top: 0;">Hallo,</p>
|
||
<p style="color: #475569; font-size: 16px; line-height: 1.6;">Sie haben das Zurücksetzen Ihres Passworts angefordert.</p>
|
||
<p style="color: #475569; font-size: 16px; line-height: 1.6;">Bitte klicken Sie auf den untenstehenden Button, um ein neues Passwort festzulegen:</p>
|
||
`;
|
||
|
||
await sendMail({
|
||
to: email,
|
||
subject: 'Passwort zurücksetzen – CASPOS Shop',
|
||
text: `Hallo,\n\nSie haben das Zurücksetzen Ihres Passworts angefordert.\n\nBitte klicken Sie auf den folgenden Link, um ein neues Passwort festzulegen:\n${resetLink}\n\nViele Grüße,\nIhr CASPOS Shop-Team`,
|
||
html: getBeautifulEmailHtml('Passwort zurücksetzen', messageHtml, 'Passwort zurücksetzen', resetLink)
|
||
})
|
||
|
||
return { success: true }
|
||
} catch (err: any) {
|
||
console.error('Password reset error:', err)
|
||
return { success: false, error: err.message || 'Fehler beim Senden der Passwort-Zurücksetzen-E-Mail.' }
|
||
}
|
||
}
|
||
|
||
export async function updatePassword(password: string) {
|
||
try {
|
||
const supabase = await createClient()
|
||
const { error } = await supabase.auth.updateUser({ password })
|
||
if (error) {
|
||
return { success: false, error: error.message }
|
||
}
|
||
return { success: true }
|
||
} catch (err: any) {
|
||
console.error('Update password error:', err)
|
||
return { success: false, error: err.message || 'Fehler beim Aktualisieren des Passworts.' }
|
||
}
|
||
}
|
||
|
||
export async function verifyAdmin() {
|
||
const supabase = await createClient()
|
||
const { data: { user } } = await supabase.auth.getUser()
|
||
if (!user) throw new Error("Nicht authentifiziert.")
|
||
|
||
const { data: userData, error } = await supabase
|
||
.from('users')
|
||
.select('role')
|
||
.eq('id', user.id)
|
||
.single()
|
||
|
||
if (error || !userData || userData.role !== 'admin') {
|
||
throw new Error("Zugriff verweigert. Nur Administratoren erlaubt.")
|
||
}
|
||
return user
|
||
}
|