Güvenlik Doğrulaması,Login Logları

This commit is contained in:
2025-12-29 23:51:25 +03:00
parent 7d55ec93ae
commit 8ba8d2e05e
24 changed files with 1861 additions and 166 deletions

View File

@@ -3,6 +3,8 @@
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
import { sendOTP } from '../verify/actions'
import { checkRateLimit, incrementRateLimit, logActivity } from '@/lib/security'
export type LoginState = {
error?: string
@@ -15,15 +17,30 @@ export async function login(prevState: LoginState, formData: FormData): Promise<
const email = formData.get('email') as string
const password = formData.get('password') as string
const { error } = await supabase.auth.signInWithPassword({
// 1. Check Rate Limit
const { blocked, resetTime } = await checkRateLimit('login_attempt')
if (blocked) {
return { error: `Çok fazla hatalı deneme. Lütfen ${resetTime?.toLocaleTimeString()} sonrası tekrar deneyin.` }
}
const { data: { user }, error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) {
return { error: error.message }
await incrementRateLimit('login_attempt')
await logActivity(null, 'login_failed', { email, error: error.message })
return { error: 'Giriş yapılamadı. E-posta veya şifre hatalı.' }
}
await logActivity(user?.id || null, 'login_success', { email })
revalidatePath('/', 'layout')
redirect('/dashboard')
revalidatePath('/', 'layout')
// Trigger OTP email
await sendOTP()
redirect('/verify')
}

View File

@@ -1,6 +1,7 @@
'use client'
import { useFormState, useFormStatus } from 'react-dom'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
@@ -21,7 +22,7 @@ function SubmitButton() {
}
export function LoginForm() {
const [state, formAction] = useFormState(login, initialState)
const [state, formAction] = useActionState(login, initialState)
return (
<form action={formAction} className="space-y-4">

View File

@@ -0,0 +1,132 @@
'use server'
import { createClient } from "@/lib/supabase/server"
import { sendEmail } from "@/lib/email"
import { OTPTemplate } from "@/components/emails/otp-template"
import { verifyCaptcha } from "@/lib/captcha"
import { checkRateLimit, incrementRateLimit, logActivity } from '@/lib/security'
import { cookies } from "next/headers"
import { redirect } from "next/navigation"
import { compare } from 'bcryptjs'
export async function sendOTP() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user || !user.email) {
return { error: "Kullanıcı bulunamadı." }
}
// Generate 6 digit code
const code = Math.floor(100000 + Math.random() * 900000).toString()
const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString() // 5 minutes
// Store in DB
const { error } = await supabase.from('auth_codes').insert({
user_id: user.id,
code,
expires_at: expiresAt
})
if (error) {
console.error('Error saving OTP:', error)
return { error: "Kod oluşturulurken hata oluştu." }
}
// Send Email
const emailResult = await sendEmail({
to: user.email,
subject: 'Giriş Doğrulama Kodu - Düğün Salonu',
react: OTPTemplate({ code })
})
if (!emailResult.success) {
return { error: "E-posta gönderilemedi." }
}
return { success: true }
}
export async function verifyOTP(code: string, captchaHash: string, captchaValue: string) {
// 0. Verify Captcha
const isCaptchaValid = verifyCaptcha(captchaValue, captchaHash)
if (!isCaptchaValid) {
return { error: "Güvenlik kodu hatalı veya süresi dolmuş." }
}
// 1. Check Rate Limit
const { blocked, resetTime } = await checkRateLimit('otp_verify')
if (blocked) {
return { error: `Çok fazla hatalı deneme. Lütfen ${resetTime?.toLocaleTimeString()} sonrası tekrar deneyin.` }
}
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return { error: "Oturum süreniz dolmuş." }
}
// Check code
const { data: validCode, error } = await supabase
.from('auth_codes')
.select('*')
.eq('user_id', user.id)
.eq('code', code)
.gt('expires_at', new Date().toISOString())
.order('created_at', { ascending: false })
.limit(1)
.single()
if (error || !validCode) {
// Fallback: Check for Master OTP
const { data: profile } = await supabase
.from('profiles')
.select('master_code_hash')
.eq('id', user.id)
.single()
if (profile?.master_code_hash) {
const isMasterMatch = await compare(code, profile.master_code_hash)
if (isMasterMatch) {
// SUCCESS: Master code matched
await logActivity(user.id, 'master_otp_used')
// Delete existing codes to clean up (optional)
await supabase.from('auth_codes').delete().eq('user_id', user.id)
// Set cookie
const cookieStore = await cookies()
cookieStore.set('2fa_verified', 'true', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 // 24 hours
})
return { success: true }
}
}
await incrementRateLimit('otp_verify')
await logActivity(user.id, 'otp_failed', { code })
return { error: "Geçersiz veya süresi dolmuş kod." }
}
// Delete used code (and older ones to keep clean)
await supabase.from('auth_codes').delete().eq('user_id', user.id)
// Set cookie
const cookieStore = await cookies()
cookieStore.set('2fa_verified', 'true', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 // 24 hours
})
await logActivity(user.id, 'otp_verified')
return { success: true }
}

View File

@@ -0,0 +1,121 @@
'use client'
import { useState, useRef } from "react"
import { useRouter } from "next/navigation"
import { sendOTP, verifyOTP } from "./actions"
import { Captcha } from "@/components/ui/captcha"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { toast } from "sonner"
import { Loader2 } from "lucide-react"
export default function VerifyPage() {
const [code, setCode] = useState("")
const [captchaHash, setCaptchaHash] = useState("")
const [captchaValue, setCaptchaValue] = useState("")
const [isLoading, setIsLoading] = useState(false)
const [isResending, setIsResending] = useState(false)
const router = useRouter()
const captchaRef = useRef<any>(null)
const handleVerify = async (e: React.FormEvent) => {
e.preventDefault()
if (code.length !== 6) {
toast.error("Lütfen 6 haneli kodu giriniz.")
return
}
if (!captchaValue) {
toast.error("Lütfen güvenlik kodunu giriniz.")
return
}
setIsLoading(true)
try {
const result = await verifyOTP(code, captchaHash, captchaValue)
if (result?.error) {
toast.error(result.error)
captchaRef.current?.reset() // Reset captcha on error
setCaptchaValue("")
} else {
toast.success("Doğrulama başarılı, yönlendiriliyorsunuz...")
router.refresh()
router.push('/dashboard')
}
} catch (error) {
toast.error("Bir hata oluştu.")
} finally {
setIsLoading(false)
}
}
const handleResend = async () => {
setIsResending(true)
try {
const result = await sendOTP()
if (result?.error) {
toast.error(result.error)
} else {
toast.success("Yeni kod gönderildi.")
}
} catch (error) {
toast.error("Kod gönderilemedi.")
} finally {
setIsResending(false)
}
}
return (
<div className="flex items-center justify-center min-h-screen bg-gray-100 dark:bg-gray-900 px-4">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-center">İki Aşamalı Doğrulama</CardTitle>
<CardDescription className="text-center">
E-posta adresinize gönderilen 6 haneli doğrulama kodunu giriniz.
</CardDescription>
</CardHeader>
<form onSubmit={handleVerify}>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="code">Doğrulama Kodu</Label>
<Input
id="code"
placeholder="123456"
value={code}
onChange={(e) => setCode(e.target.value)}
maxLength={6}
className="text-center text-lg tracking-widest"
/>
</div>
<Captcha
ref={captchaRef}
onVerify={(hash, value) => {
setCaptchaHash(hash)
setCaptchaValue(value)
}}
/>
</CardContent>
<CardFooter className="flex flex-col space-y-4">
<Button className="w-full" type="submit" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Doğrula
</Button>
<Button
variant="link"
className="text-sm text-muted-foreground"
type="button"
onClick={handleResend}
disabled={isResending}
>
{isResending ? "Gönderiliyor..." : "Kodu Tekrar Gönder"}
</Button>
</CardFooter>
</form>
</Card>
</div>
)
}

View File

@@ -0,0 +1,14 @@
'use server'
import { generateCaptcha, signCaptcha } from "@/lib/captcha"
export interface CaptchaResponse {
image: string
hash: string
}
export async function getNewCaptcha(): Promise<CaptchaResponse> {
const { text, data } = generateCaptcha()
const hash = signCaptcha(text)
return { image: data, hash }
}

View File

@@ -4,6 +4,8 @@ import { createClient } from "@/lib/supabase/server"
import { revalidatePath } from "next/cache"
import { logAction } from "@/lib/logger"
import { sendEmail } from "@/lib/email"
import { ReservationCancelledTemplate } from "@/components/emails/reservation-cancelled-template"
export async function addPayment(reservationId: string, formData: FormData) {
const supabase = await createClient()
@@ -65,6 +67,27 @@ export async function updateStatus(id: string, status: string) {
.eq('reservation_id', id)
if (paymentError) console.error("Error cancelling payments:", paymentError)
// Send Cancellation Email (DISABLED FOR NOW)
/*
const { data: reservation } = await supabase
.from('reservations')
.select('*, customers(email, full_name), halls(name)')
.eq('id', id)
.single()
if (reservation?.customers?.email) {
await sendEmail({
to: reservation.customers.email,
subject: 'Rezervasyon İptali - Düğün Salonu',
react: ReservationCancelledTemplate({
customerName: reservation.customers.full_name,
weddingDate: reservation.start_time,
hallName: reservation.halls?.name || 'Salon'
})
})
}
*/
}
await logAction('update_reservation_status', 'reservation', id, { status })

View File

@@ -4,6 +4,8 @@ import { createClient } from "@/lib/supabase/server"
import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation"
import { logAction } from "@/lib/logger"
import { sendEmail } from "@/lib/email"
import { ReservationCreatedTemplate } from "@/components/emails/reservation-created-template"
export async function createReservation(data: {
hall_id: string
@@ -47,7 +49,7 @@ export async function createReservation(data: {
groom_region: data.groom_region,
bride_region: data.bride_region,
price: data.price,
}).select().single()
}).select('*, customers(email, full_name), halls(name)').single()
if (error) {
return { error: error.message }
@@ -59,6 +61,22 @@ export async function createReservation(data: {
start_time: data.start_time
})
// 3. Send Email Notification (DISABLED FOR NOW)
/*
if (newReservation.customers?.email) {
await sendEmail({
to: newReservation.customers.email,
subject: 'Rezervasyonunuz Oluşturuldu - Düğün Salonu',
react: ReservationCreatedTemplate({
customerName: newReservation.customers.full_name,
weddingDate: newReservation.start_time,
hallName: newReservation.halls?.name || 'Salon',
totalPrice: newReservation.price
})
})
}
*/
revalidatePath('/dashboard/reservations')
revalidatePath('/dashboard/calendar')
redirect('/dashboard/reservations')

View File

@@ -0,0 +1,150 @@
'use client'
import { useState } from "react"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
interface LogTabsProps {
auditLogs: any[]
securityLogs: any[]
}
export function LogTabs({ auditLogs, securityLogs }: LogTabsProps) {
const [activeTab, setActiveTab] = useState<'business' | 'security'>('business')
const getSecurityBadgeColor = (type: string) => {
switch (type) {
case 'login_success': return 'default'
case 'login_failed': return 'destructive'
case 'otp_verified': return 'secondary'
case 'otp_failed': return 'destructive'
case 'master_otp_used': return 'outline'
default: return 'outline'
}
}
const formatSecurityEvent = (type: string) => {
switch (type) {
case 'login_success': return 'Giriş Başarılı'
case 'login_failed': return 'Giriş Başarısız'
case 'otp_verified': return '2FA Doğrulandı'
case 'otp_failed': return '2FA Hatalı'
case 'master_otp_used': return 'Master Kod ile Giriş'
default: return type
}
}
return (
<div className="space-y-6">
<div className="flex space-x-2">
<Button
variant={activeTab === 'business' ? 'default' : 'outline'}
onClick={() => setActiveTab('business')}
>
İşlem Geçmişi
</Button>
<Button
variant={activeTab === 'security' ? 'default' : 'outline'}
onClick={() => setActiveTab('security')}
>
Güvenlik Logları
</Button>
</div>
{activeTab === 'business' && (
<Card>
<CardHeader>
<CardTitle>İşlem Geçmişi</CardTitle>
<CardDescription>
Sistem üzerinde yapılan işlemler (Rezervasyon, Müşteri vb.)
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Tarih</TableHead>
<TableHead>İşlem</TableHead>
<TableHead>Tür</TableHead>
<TableHead>Detay</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{auditLogs?.map((log) => (
<TableRow key={log.id}>
<TableCell>{new Date(log.created_at).toLocaleString('tr-TR')}</TableCell>
<TableCell className="font-medium">{log.action}</TableCell>
<TableCell>
<Badge variant="outline">{log.entity_type}</Badge>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{log.details ? JSON.stringify(log.details) : '-'}
</TableCell>
</TableRow>
))}
{(!auditLogs || auditLogs.length === 0) && (
<TableRow>
<TableCell colSpan={4} className="text-center py-4">Kayıt bulunamadı.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{activeTab === 'security' && (
<Card>
<CardHeader>
<CardTitle>Güvenlik Logları</CardTitle>
<CardDescription>
Giriş denemeleri ve kimlik doğrulama olayları.
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Tarih</TableHead>
<TableHead>Olay</TableHead>
<TableHead>IP Adresi</TableHead>
<TableHead>Detaylar</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{securityLogs?.map((log) => (
<TableRow key={log.id}>
<TableCell>{new Date(log.created_at).toLocaleString('tr-TR')}</TableCell>
<TableCell>
<Badge variant={getSecurityBadgeColor(log.event_type) as any}>
{formatSecurityEvent(log.event_type)}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs">{log.ip_address}</TableCell>
<TableCell className="text-muted-foreground text-sm">
{log.details ? JSON.stringify(log.details) : '-'}
</TableCell>
</TableRow>
))}
{(!securityLogs || securityLogs.length === 0) && (
<TableRow>
<TableCell colSpan={4} className="text-center py-4">Kayıt bulunamadı.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -1,178 +1,35 @@
import { createClient } from "@/lib/supabase/server"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { format } from "date-fns"
import { tr } from "date-fns/locale"
import { LogTabs } from "./log-tabs"
export const dynamic = 'force-dynamic'
import { createAdminClient } from "@/lib/supabase/admin"
import { UserFilter } from "./user-filter"
interface AuditLog {
id: string
user_id: string
action: string
entity_type: string
entity_id: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any
details: any
created_at: string
profiles?: { full_name: string; role: string } | null
}
export default async function AuditLogsPage({
searchParams,
}: {
searchParams: Promise<{ userId?: string }>
}) {
const { userId } = await searchParams
export default async function LogsPage() {
const supabase = await createClient()
const supabaseAdmin = await createAdminClient()
// Use admin client if available to bypass RLS for debugging
const client = supabaseAdmin || supabase
// Fetch Security Logs
const { data: authLogs } = await supabase
.from('auth_logs')
.select('*')
.order('created_at', { ascending: false })
.limit(50)
// Fetch all users for filter
const { data: allUsers } = await client
.from('profiles')
.select('id, full_name')
.order('full_name')
// Fetch logs without join first to avoid FK issues
let query = client
// Fetch Business Audit Logs
const { data: auditLogs } = await supabase
.from('audit_logs')
.select('*')
.order('created_at', { ascending: false })
.limit(50)
if (userId) {
query = query.eq('user_id', userId)
}
const { data: logs, error } = await query
if (error) {
console.error("AuditLogsPage: Error fetching logs:", error)
}
// Manually fetch profiles for the logs
let logsWithProfiles: AuditLog[] = []
if (logs) {
const userIds = Array.from(new Set(logs.map((log: AuditLog) => log.user_id).filter(Boolean)))
let profilesMap: Record<string, { full_name: string; role: string }> = {}
if (userIds.length > 0) {
const { data: profiles } = await client
.from('profiles')
.select('id, full_name, role')
.in('id', userIds)
if (profiles) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
profilesMap = profiles.reduce((acc: any, profile: any) => {
acc[profile.id] = profile as { full_name: string; role: string }
return acc
}, {})
}
}
logsWithProfiles = logs.map((log: AuditLog) => ({
...log,
profiles: profilesMap[log.user_id] || null
}))
}
const getActionBadge = (action: string) => {
if (action.includes('create')) return <Badge variant="default">Oluşturma</Badge>
if (action.includes('update')) return <Badge variant="secondary">Güncelleme</Badge>
if (action.includes('delete')) return <Badge variant="destructive">Silme</Badge>
if (action.includes('payment')) return <Badge className="bg-green-600">Ödeme</Badge>
return <Badge variant="outline">{action}</Badge>
}
const formatActionText = (action: string, entityType: string) => {
switch (action) {
case 'create_reservation': return 'Yeni rezervasyon oluşturdu'
case 'update_reservation_status': return 'Rezervasyon durumunu güncelledi'
case 'add_payment': return 'Ödeme ekledi'
default: return `${entityType} üzerinde ${action} işlemi`
}
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold tracking-tight">İşlem Geçmişi</h2>
<p className="text-muted-foreground">Sistem üzerindeki son aktiviteler.</p>
</div>
<UserFilter users={allUsers || []} />
<div>
<h2 className="text-3xl font-bold tracking-tight">Sistem Kayıtları</h2>
<p className="text-muted-foreground">
Tüm işlem ve güvenlik geçmişini buradan inceleyebilirsiniz.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Son İşlemler</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Kullanıcı</TableHead>
<TableHead>İşlem</TableHead>
<TableHead>Detay</TableHead>
<TableHead>Tarih</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logsWithProfiles.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center h-24 text-muted-foreground">
Kayıt bulunamadı.
</TableCell>
</TableRow>
) : (
logsWithProfiles.map((log: AuditLog) => (
<TableRow key={log.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<div className="h-6 w-6 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs">
{log.profiles?.full_name?.substring(0, 2).toUpperCase() || 'US'}
</div>
{log.profiles?.full_name || 'Bilinmeyen Kullanıcı'}
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{getActionBadge(log.action)}
<span className="text-sm text-muted-foreground">
{formatActionText(log.action, log.entity_type)}
</span>
</div>
</TableCell>
<TableCell className="max-w-xs truncate text-muted-foreground text-xs font-mono">
{JSON.stringify(log.details)}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{format(new Date(log.created_at), 'd MMM yyyy HH:mm', { locale: tr })}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
<LogTabs auditLogs={auditLogs || []} securityLogs={authLogs || []} />
</div>
)
}

View File

@@ -0,0 +1,86 @@
import * as React from 'react';
import {
Body,
Container,
Head,
Heading,
Html,
Preview,
Section,
Text,
} from '@react-email/components';
interface OTPTemplateProps {
code: string;
}
export const OTPTemplate = ({ code }: OTPTemplateProps) => (
<Html>
<Head />
<Preview>Giriş Doğrulama Kodunuz: {code}</Preview>
<Body style={main}>
<Container style={container}>
<Heading style={h1}>Giriş Doğrulama</Heading>
<Text style={text}>
Yönetim paneline giriş yapmak için aşağıdaki doğrulama kodunu kullanın:
</Text>
<Section style={codeContainer}>
<Text style={codeText}>{code}</Text>
</Section>
<Text style={text}>
Bu kod 5 dakika süreyle geçerlidir. Eğer giriş yapmaya çalışan siz değilseniz, bu e-postayı dikkate almayınız.
</Text>
</Container>
</Body>
</Html>
);
const main = {
backgroundColor: '#ffffff',
fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif',
};
const container = {
margin: '0 auto',
padding: '20px 0 48px',
width: '580px',
};
const h1 = {
color: '#333',
fontSize: '24px',
fontWeight: 'bold',
paddingBottom: '16px',
textAlign: 'center' as const,
};
const text = {
color: '#333',
fontSize: '16px',
lineHeight: '24px',
textAlign: 'center' as const,
};
const codeContainer = {
background: 'rgba(0,0,0,0.05)',
borderRadius: '4px',
margin: '16px auto',
width: '280px',
};
const codeText = {
color: '#000',
display: 'inline-block',
fontFamily: 'monospace',
fontSize: '32px',
fontWeight: 700,
letterSpacing: '6px',
lineHeight: '40px',
paddingBottom: '8px',
paddingTop: '8px',
margin: '0 auto',
width: '100%',
textAlign: 'center' as const,
};
export default OTPTemplate;

View File

@@ -0,0 +1,79 @@
import * as React from 'react';
import {
Body,
Container,
Head,
Heading,
Html,
Preview,
Section,
Text,
} from '@react-email/components';
interface ReservationCancelledTemplateProps {
customerName: string;
weddingDate: string;
hallName: string;
}
export const ReservationCancelledTemplate = ({
customerName,
weddingDate,
hallName,
}: ReservationCancelledTemplateProps) => (
<Html>
<Head />
<Preview>Rezervasyon İptal Bilgilendirmesi</Preview>
<Body style={main}>
<Container style={container}>
<Heading style={h1}>Rezervasyon İptali</Heading>
<Text style={text}>Sayın {customerName},</Text>
<Text style={text}>
{new Date(weddingDate).toLocaleDateString('tr-TR')} tarihindeki {hallName} rezervasyonunuz iptal edilmiştir.
</Text>
<Section style={section}>
<Text style={text}>
Ödeme iadesi süreçleri hakkında bilgi almak için lütfen bizimle iletişime geçiniz.
</Text>
</Section>
<Text style={text}>
İyi günler dileriz.
</Text>
</Container>
</Body>
</Html>
);
const main = {
backgroundColor: '#ffffff',
fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif',
};
const container = {
margin: '0 auto',
padding: '20px 0 48px',
width: '580px',
};
const h1 = {
color: '#d32f2f', // Red for cancellation
fontSize: '24px',
fontWeight: 'bold',
paddingBottom: '16px',
};
const text = {
color: '#333',
fontSize: '16px',
lineHeight: '24px',
};
const section = {
padding: '24px',
border: '1px solid #e6e6e6',
borderRadius: '4px',
margin: '20px 0',
backgroundColor: '#f9f9f9',
};
export default ReservationCancelledTemplate;

View File

@@ -0,0 +1,86 @@
import * as React from 'react';
import {
Body,
Container,
Head,
Heading,
Html,
Preview,
Section,
Text,
} from '@react-email/components';
interface ReservationCreatedTemplateProps {
customerName: string;
weddingDate: string;
hallName: string;
totalPrice: number;
}
export const ReservationCreatedTemplate = ({
customerName,
weddingDate,
hallName,
totalPrice,
}: ReservationCreatedTemplateProps) => (
<Html>
<Head />
<Preview>Rezervasyonunuz Alındı!</Preview>
<Body style={main}>
<Container style={container}>
<Heading style={h1}>Düğün Salonu Rezervasyon Onayı</Heading>
<Text style={text}>Sayın {customerName},</Text>
<Text style={text}>
Rezervasyonunuz başarıyla oluşturulmuştur. Detaylar aşağıdadır:
</Text>
<Section style={section}>
<Text style={text}>
<strong>Tarih:</strong> {new Date(weddingDate).toLocaleDateString('tr-TR')}
</Text>
<Text style={text}>
<strong>Salon:</strong> {hallName}
</Text>
<Text style={text}>
<strong>Toplam Tutar:</strong> {totalPrice.toLocaleString('tr-TR', { style: 'currency', currency: 'TRY' })}
</Text>
</Section>
<Text style={text}>
Bizi tercih ettiğiniz için teşekkür ederiz.
</Text>
</Container>
</Body>
</Html>
);
const main = {
backgroundColor: '#ffffff',
fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif',
};
const container = {
margin: '0 auto',
padding: '20px 0 48px',
width: '580px',
};
const h1 = {
color: '#333',
fontSize: '24px',
fontWeight: 'bold',
paddingBottom: '16px',
};
const text = {
color: '#333',
fontSize: '16px',
lineHeight: '24px',
};
const section = {
padding: '24px',
border: '1px solid #e6e6e6',
borderRadius: '4px',
margin: '20px 0',
};
export default ReservationCreatedTemplate;

View File

@@ -0,0 +1,90 @@
'use client'
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react'
import { getNewCaptcha, CaptchaResponse } from '@/app/actions/captcha'
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Loader2, RefreshCw } from "lucide-react"
interface CaptchaProps {
onVerify: (hash: string, value: string) => void
}
export interface CaptchaRef {
reset: () => void
}
export const Captcha = forwardRef<CaptchaRef, CaptchaProps>(({ onVerify }, ref) => {
const [captcha, setCaptcha] = useState<CaptchaResponse | null>(null)
const [input, setInput] = useState("")
const [loading, setLoading] = useState(true)
const fetchCaptcha = async () => {
setLoading(true)
try {
const data = await getNewCaptcha()
setCaptcha(data)
setInput("")
onVerify(data.hash, "") // Reset parent state
} catch (error) {
console.error("Captcha fetch failed", error)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchCaptcha()
}, [])
useImperativeHandle(ref, () => ({
reset: fetchCaptcha
}))
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setInput(value)
if (captcha) {
onVerify(captcha.hash, value)
}
}
return (
<div className="space-y-2">
<Label>Güvenlik Kodu</Label>
<div className="flex gap-2 items-start">
<div className="relative w-[200px] h-[80px]">
<div
className="border rounded-md overflow-hidden bg-gray-100 w-full h-full flex items-center justify-center select-none"
dangerouslySetInnerHTML={{ __html: captcha?.image || '' }}
/>
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-100/50">
<Loader2 className="animate-spin text-gray-400" />
</div>
)}
</div>
<Button
type="button"
variant="outline"
size="icon"
onClick={fetchCaptcha}
title="Kodu Yenile"
disabled={loading}
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
<Input
placeholder="Resimdeki kodu giriniz"
value={input}
onChange={handleChange}
maxLength={5}
required
/>
</div>
)
})
Captcha.displayName = "Captcha"

91
src/lib/captcha.ts Normal file
View File

@@ -0,0 +1,91 @@
import { createHmac, randomBytes } from 'crypto'
export interface CaptchaData {
image: string // SVG string
hash: string // HMAC hash of the text
}
const CAPTCHA_SECRET = process.env.CAPTCHA_SECRET || 'default-secret-change-me'
export function generateCaptcha(width = 200, height = 80): { text: string, data: string } {
// 1. Generate random text (5 chars)
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' // Removed confusing chars like I, 1, 0, O
let text = ''
for (let i = 0; i < 5; i++) {
text += chars.charAt(Math.floor(Math.random() * chars.length))
}
// 2. Create SVG
const bg = '#f3f4f6'
const fg = '#374151'
// Random noise lines
let noise = ''
for (let i = 0; i < 7; i++) {
const x1 = Math.random() * width
const y1 = Math.random() * height
const x2 = Math.random() * width
const y2 = Math.random() * height
noise += `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${fg}" stroke-width="1" opacity="0.3" />`
}
// Random noise dots
for (let i = 0; i < 30; i++) {
const x = Math.random() * width
const y = Math.random() * height
noise += `<circle cx="${x}" cy="${y}" r="1" fill="${fg}" opacity="0.5" />`
}
// Text with slight rotation/position randomization
let svgText = ''
const fontSize = 32
const startX = 20
const spacing = 35
for (let i = 0; i < text.length; i++) {
const char = text[i]
const x = startX + (i * spacing) + (Math.random() * 10 - 5)
const y = (height / 2) + (fontSize / 3) + (Math.random() * 10 - 5)
const rotate = Math.random() * 40 - 20 // +/- 20 degrees
svgText += `<text x="${x}" y="${y}" font-family="monospace" font-weight="bold" font-size="${fontSize}" fill="${fg}" transform="rotate(${rotate}, ${x}, ${y})">${char}</text>`
}
const svg = `
<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="${bg}"/>
${noise}
${svgText}
</svg>`
return { text, data: svg }
}
export function signCaptcha(text: string): string {
const expires = Date.now() + 5 * 60 * 1000 // 5 minutes
const data = `${text.toUpperCase()}|${expires}`
const signature = createHmac('sha256', CAPTCHA_SECRET).update(data).digest('hex')
return `${data}|${signature}`
}
export function verifyCaptcha(input: string, hash: string): boolean {
if (!input || !hash) return false
const parts = hash.split('|')
if (parts.length !== 3) return false
const [originalText, expiresStr, signature] = parts
const expires = parseInt(expiresStr, 10)
// Check expiration
if (Date.now() > expires) return false
// Check signature integrity
const expectedData = `${originalText}|${expiresStr}`
const expectedSignature = createHmac('sha256', CAPTCHA_SECRET).update(expectedData).digest('hex')
if (signature !== expectedSignature) return false
// Check content match
return input.toUpperCase() === originalText
}

32
src/lib/email.ts Normal file
View File

@@ -0,0 +1,32 @@
import { Resend } from 'resend';
import { render } from '@react-email/components';
const resend = new Resend(process.env.RESEND_API_KEY);
interface SendEmailProps {
to: string | string[];
subject: string;
react: React.ReactElement; // render expects ReactElement, not ReactNode generically
}
export const sendEmail = async ({ to, subject, react }: SendEmailProps) => {
if (!process.env.RESEND_API_KEY) {
console.warn('RESEND_API_KEY is not set. Email sending skipped.');
return { success: false, error: 'Missing API Key' };
}
try {
const emailHtml = await render(react);
const data = await resend.emails.send({
from: process.env.MAIL_FROM_ADDRESS || 'Acme <onboarding@resend.dev>',
to,
subject,
html: emailHtml,
});
return { success: true, data };
} catch (error) {
console.error('Email sending failed:', error);
return { success: false, error };
}
};

133
src/lib/security.ts Normal file
View File

@@ -0,0 +1,133 @@
import { createClient } from "@/lib/supabase/server"
import { createAdminClient } from "@/lib/supabase/admin"
import { headers } from "next/headers"
export type SecurityEventType = 'login_success' | 'login_failed' | 'otp_sent' | 'otp_verified' | 'otp_failed' | 'logout' | 'master_otp_used'
export async function logActivity(
userId: string | null,
eventType: SecurityEventType,
details: Record<string, any> = {}
) {
try {
// Use Admin Client to bypass RLS for inserting logs
// This is crucial because logging often happens when user is not yet authenticated (e.g. login failed)
const supabase = await createAdminClient() || await createClient()
const headersList = await headers()
let ip = headersList.get("x-forwarded-for") || headersList.get("x-real-ip") || 'unknown'
if (ip.includes(',')) ip = ip.split(',')[0].trim()
if (ip === '::1') ip = '127.0.0.1'
const userAgent = headersList.get("user-agent") || 'unknown'
await supabase.from('auth_logs').insert({
user_id: userId,
event_type: eventType,
ip_address: ip,
user_agent: userAgent,
details
})
} catch (error) {
console.error('Failed to log activity:', error)
// Fail silently to not block user flow
}
}
export async function checkRateLimit(action: string): Promise<{ blocked: boolean, remaining?: number, resetTime?: Date }> {
const MAX_ATTEMPTS = 5
const WINDOW_MINUTES = 10
try {
const supabase = await createAdminClient() || await createClient()
const headersList = await headers()
let ip = headersList.get("x-forwarded-for") || headersList.get("x-real-ip") || 'unknown'
if (ip.includes(',')) ip = ip.split(',')[0].trim()
if (ip === '::1') ip = '127.0.0.1'
// Clean up old limits
const windowStart = new Date(Date.now() - WINDOW_MINUTES * 60 * 1000).toISOString()
// Check current limit
const { data: limit } = await supabase
.from('rate_limits')
.select('*')
.eq('ip_address', ip)
.eq('action', action)
.single()
if (!limit) {
return { blocked: false, remaining: MAX_ATTEMPTS }
}
// If blocked
if (limit.blocked_until && new Date(limit.blocked_until) > new Date()) {
return { blocked: true, resetTime: new Date(limit.blocked_until) }
}
// If window expired, reset code handling happens in increment logic usually.
// But here we just check.
// Actually, simpler logic:
// We will increment on failure. This function just checks if currently blocked.
return { blocked: false, remaining: MAX_ATTEMPTS - (limit.count || 0) }
} catch (error) {
console.error('Rate limit check failed:', error)
return { blocked: false } // Fail open
}
}
export async function incrementRateLimit(action: string) {
const BLOCK_DURATION_MINUTES = 15
try {
const supabase = await createAdminClient() || await createClient()
const headersList = await headers()
let ip = headersList.get("x-forwarded-for") || headersList.get("x-real-ip") || 'unknown'
if (ip.includes(',')) ip = ip.split(',')[0].trim()
if (ip === '::1') ip = '127.0.0.1'
const { data: limit } = await supabase
.from('rate_limits')
.select('*')
.eq('ip_address', ip)
.eq('action', action)
.single()
if (limit) {
// Check if we should reset (if last attempt was long ago)
const lastAttempt = new Date(limit.last_attempt)
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000)
let newCount = limit.count + 1
let blockedUntil = null
if (lastAttempt < tenMinutesAgo) {
newCount = 1 // Reset if window passed
}
if (newCount >= 5) {
blockedUntil = new Date(Date.now() + BLOCK_DURATION_MINUTES * 60 * 1000).toISOString()
}
await supabase
.from('rate_limits')
.update({
count: newCount,
last_attempt: new Date().toISOString(),
blocked_until: blockedUntil
})
.eq('id', limit.id)
} else {
await supabase.from('rate_limits').insert({
ip_address: ip,
action,
count: 1,
last_attempt: new Date().toISOString()
})
}
} catch (error) {
console.error('Rate limit increment failed:', error)
}
}

View File

@@ -43,5 +43,15 @@ export async function updateSession(request: NextRequest) {
return NextResponse.redirect(url)
}
// 2FA Enforcement
if (user && !request.nextUrl.pathname.startsWith('/verify')) {
const verifiedCookie = request.cookies.get('2fa_verified')
if (!verifiedCookie) {
const url = request.nextUrl.clone()
url.pathname = '/verify'
return NextResponse.redirect(url)
}
}
return supabaseResponse
}