Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43a3efc94f | ||
|
|
e18bc8fbda | ||
|
|
5654964d09 | ||
|
|
8cf038443f | ||
|
|
d4b3398de5 | ||
|
|
f4eedaffe2 | ||
|
|
45a50dc906 | ||
|
|
fcfa0707a1 | ||
|
|
0395a78008 | ||
|
|
2a6c3a1222 |
+3
-2
@@ -1,8 +1,8 @@
|
||||
APP_URL=http://localhost
|
||||
DATABASE_URL=postgresql://ecommerce:ecommerce_password@db:5432/ecommerce
|
||||
AUTH_SECRET=dev-secret-change-in-production-32chars
|
||||
AUTH_SECRET=<generate-with-openssl-rand-hex-32>
|
||||
INITIAL_ADMIN_EMAIL=admin@example.com
|
||||
INITIAL_ADMIN_PASSWORD=Admin1234!test
|
||||
INITIAL_ADMIN_PASSWORD=<change-this-use-openssl-rand-base64-32>
|
||||
STRIPE_SECRET_KEY=sk_test_placeholder
|
||||
STRIPE_WEBHOOK_SECRET=whsec_placeholder
|
||||
SMTP_HOST=mailpit
|
||||
@@ -10,3 +10,4 @@ SMTP_PORT=1025
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=noreply@localhost
|
||||
POSTGRES_PASSWORD=ecommerce_password
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
localhost {
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||
X-Frame-Options "DENY"
|
||||
X-Content-Type-Options "nosniff"
|
||||
}
|
||||
handle /uploads/* {
|
||||
root * /srv
|
||||
file_server
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE "LoginAttempt" (
|
||||
"id" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "LoginAttempt_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
CREATE INDEX "LoginAttempt_key_createdAt_idx" ON "LoginAttempt"("key", "createdAt");
|
||||
@@ -255,3 +255,11 @@ model AuditLog {
|
||||
metadata Json?
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model LoginAttempt {
|
||||
id String @id @default(cuid())
|
||||
key String // IP address or identifier
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([key, createdAt])
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import Link from 'next/link'
|
||||
import { Navbar } from '@/components/storefront/Navbar'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { useUser } from '@/context/UserContext'
|
||||
|
||||
interface Order {
|
||||
id: string
|
||||
@@ -35,13 +36,14 @@ export default function OrdersPage() {
|
||||
function OrdersContent() {
|
||||
const [orders, setOrders] = useState<Order[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const { user, isLoading: userLoading } = useUser()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const success = searchParams.get('success')
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('user')
|
||||
if (!stored) {
|
||||
if (userLoading) return
|
||||
if (!user) {
|
||||
router.push('/login?redirect=/account/orders')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,35 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Navbar } from '@/components/storefront/Navbar'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
email: string
|
||||
name?: string
|
||||
role: string
|
||||
}
|
||||
import { useUser } from '@/context/UserContext'
|
||||
|
||||
export default function AccountPage() {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const { user, isLoading } = useUser()
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('user')
|
||||
if (!stored) {
|
||||
if (!isLoading && !user) {
|
||||
router.push('/login?redirect=/account')
|
||||
return
|
||||
}
|
||||
try {
|
||||
setUser(JSON.parse(stored))
|
||||
} catch {
|
||||
router.push('/login')
|
||||
}
|
||||
}, [router])
|
||||
}, [isLoading, user, router])
|
||||
|
||||
if (!user) return null
|
||||
if (isLoading || !user) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { useUser } from '@/context/UserContext'
|
||||
|
||||
export default function ChangePasswordPage() {
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
@@ -14,6 +15,7 @@ export default function ChangePasswordPage() {
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const router = useRouter()
|
||||
const { user, setUser } = useUser()
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
@@ -41,12 +43,9 @@ export default function ChangePasswordPage() {
|
||||
}
|
||||
|
||||
setSuccess(true)
|
||||
// Update localStorage to remove mustChangePassword flag
|
||||
const userStr = localStorage.getItem('user')
|
||||
if (userStr) {
|
||||
const user = JSON.parse(userStr)
|
||||
user.mustChangePassword = false
|
||||
localStorage.setItem('user', JSON.stringify(user))
|
||||
// Update context to remove mustChangePassword flag
|
||||
if (user) {
|
||||
setUser({ ...user, mustChangePassword: false })
|
||||
}
|
||||
|
||||
setTimeout(() => router.push('/admin'), 1500)
|
||||
|
||||
@@ -72,6 +72,14 @@ export async function DELETE(request: NextRequest) {
|
||||
const id = searchParams.get('id')
|
||||
if (!id) return NextResponse.json({ error: 'ID required' }, { status: 400 })
|
||||
|
||||
const productCount = await prisma.productCategory.count({ where: { categoryId: id } })
|
||||
if (productCount > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `Cannot delete: ${productCount} product(s) use this category` },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
await prisma.category.delete({ where: { id } })
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
|
||||
@@ -2,6 +2,14 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
|
||||
const VALID_TRANSITIONS: Record<string, string[]> = {
|
||||
PENDING: ['PAID', 'CANCELLED'],
|
||||
PAID: ['FULFILLED', 'REFUNDED', 'CANCELLED'],
|
||||
FULFILLED: ['REFUNDED'],
|
||||
CANCELLED: [],
|
||||
REFUNDED: [],
|
||||
}
|
||||
|
||||
async function requireAdmin() {
|
||||
const user = await getCurrentUser()
|
||||
if (!user || (user.role !== 'ADMIN' && user.role !== 'OWNER')) return null
|
||||
@@ -50,6 +58,17 @@ export async function PUT(
|
||||
return NextResponse.json({ error: 'Invalid status' }, { status: 400 })
|
||||
}
|
||||
|
||||
const currentOrder = await prisma.order.findUnique({ where: { id: params.id }, select: { status: true } })
|
||||
if (!currentOrder) return NextResponse.json({ error: 'Order not found' }, { status: 404 })
|
||||
|
||||
const allowed = VALID_TRANSITIONS[currentOrder.status] ?? []
|
||||
if (!allowed.includes(status)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Cannot transition order from ${currentOrder.status} to ${status}` },
|
||||
{ status: 422 }
|
||||
)
|
||||
}
|
||||
|
||||
const order = await prisma.order.update({
|
||||
where: { id: params.id },
|
||||
data: { status: status as 'PENDING' | 'PAID' | 'CANCELLED' | 'REFUNDED' | 'FULFILLED' },
|
||||
|
||||
@@ -13,8 +13,8 @@ export async function GET(request: NextRequest) {
|
||||
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const page = parseInt(searchParams.get('page') || '1')
|
||||
const limit = parseInt(searchParams.get('limit') || '20')
|
||||
const page = Math.max(1, parseInt(searchParams.get('page') || '1') || 1)
|
||||
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get('limit') || '20') || 20))
|
||||
const status = searchParams.get('status')
|
||||
|
||||
const skip = (page - 1) * limit
|
||||
|
||||
@@ -73,6 +73,14 @@ export async function DELETE(request: NextRequest) {
|
||||
const id = searchParams.get('id')
|
||||
if (!id) return NextResponse.json({ error: 'ID required' }, { status: 400 })
|
||||
|
||||
const productCount = await prisma.product.count({ where: { typeId: id } })
|
||||
if (productCount > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `Cannot delete: ${productCount} product(s) use this product type` },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
await prisma.productType.delete({ where: { id } })
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { saveImage, deleteImageFile } from '@/lib/storage'
|
||||
import { saveImage, deleteImageFile, validateImageMagicBytes } from '@/lib/storage'
|
||||
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp']
|
||||
const MAX_SIZE = 5 * 1024 * 1024 // 5MB
|
||||
@@ -26,6 +26,10 @@ export async function POST(req: NextRequest, { params }: { params: { id: string
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
return NextResponse.json({ error: 'Only JPEG, PNG and WebP images are allowed' }, { status: 400 })
|
||||
}
|
||||
const isValidImage = await validateImageMagicBytes(file, file.type)
|
||||
if (!isValidImage) {
|
||||
return NextResponse.json({ error: 'File content does not match declared image type' }, { status: 400 })
|
||||
}
|
||||
if (file.size > MAX_SIZE) {
|
||||
return NextResponse.json({ error: 'File too large (max 5MB)' }, { status: 400 })
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ export async function GET(request: NextRequest) {
|
||||
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const page = parseInt(searchParams.get('page') || '1')
|
||||
const limit = parseInt(searchParams.get('limit') || '20')
|
||||
const page = Math.max(1, parseInt(searchParams.get('page') || '1') || 1)
|
||||
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get('limit') || '20') || 20))
|
||||
const search = searchParams.get('search')
|
||||
const status = searchParams.get('status')
|
||||
|
||||
@@ -65,7 +65,7 @@ export async function POST(request: NextRequest) {
|
||||
const parsed = productSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.errors[0]?.message || 'Invalid input', details: parsed.error.errors },
|
||||
{ error: parsed.error.errors[0]?.message ?? 'Invalid input' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ export async function GET(request: NextRequest) {
|
||||
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const page = parseInt(searchParams.get('page') || '1')
|
||||
const limit = parseInt(searchParams.get('limit') || '20')
|
||||
const page = Math.max(1, parseInt(searchParams.get('page') || '1') || 1)
|
||||
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get('limit') || '20') || 20))
|
||||
const status = searchParams.get('status')
|
||||
|
||||
const skip = (page - 1) * limit
|
||||
|
||||
@@ -2,6 +2,17 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
|
||||
const ALLOWED_SETTING_KEYS = [
|
||||
'site_name',
|
||||
'site_description',
|
||||
'support_email',
|
||||
'currency',
|
||||
'tax_rate',
|
||||
'footer_copyright',
|
||||
'footer_links',
|
||||
'favicon_url',
|
||||
] as const
|
||||
|
||||
async function requireAdmin() {
|
||||
const user = await getCurrentUser()
|
||||
if (!user || (user.role !== 'ADMIN' && user.role !== 'OWNER')) return null
|
||||
@@ -40,6 +51,10 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
if (!key) return NextResponse.json({ error: 'Key is required' }, { status: 400 })
|
||||
|
||||
if (!ALLOWED_SETTING_KEYS.includes(key as (typeof ALLOWED_SETTING_KEYS)[number])) {
|
||||
return NextResponse.json({ error: 'Invalid setting key' }, { status: 400 })
|
||||
}
|
||||
|
||||
const setting = await prisma.siteSettings.upsert({
|
||||
where: { key },
|
||||
update: { value: value as object },
|
||||
|
||||
@@ -3,8 +3,9 @@ import { mkdir, writeFile } from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { validateImageMagicBytes } from '@/lib/storage'
|
||||
|
||||
const ALLOWED_TYPES = ['image/x-icon', 'image/png', 'image/svg+xml', 'image/jpeg', 'image/webp']
|
||||
const ALLOWED_TYPES = ['image/x-icon', 'image/png', 'image/jpeg', 'image/webp']
|
||||
const MAX_SIZE = 1 * 1024 * 1024 // 1MB
|
||||
const FAVICON_URL = '/uploads/branding/favicon.png'
|
||||
|
||||
@@ -23,7 +24,11 @@ export async function POST(req: NextRequest) {
|
||||
if (!file) return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
||||
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
return NextResponse.json({ error: 'Formato non supportato (usa PNG, ICO, SVG o WebP)' }, { status: 400 })
|
||||
return NextResponse.json({ error: 'Formato non supportato (usa PNG, ICO, JPEG o WebP)' }, { status: 400 })
|
||||
}
|
||||
const isValidImage = await validateImageMagicBytes(file, file.type)
|
||||
if (!isValidImage) {
|
||||
return NextResponse.json({ error: 'File content does not match declared image type' }, { status: 400 })
|
||||
}
|
||||
if (file.size > MAX_SIZE) {
|
||||
return NextResponse.json({ error: 'File troppo grande (max 1MB)' }, { status: 400 })
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getCurrentUser, verifyPassword, hashPassword } from '@/lib/auth'
|
||||
import { changePasswordSchema } from '@/lib/validate'
|
||||
import { checkRateLimit, recordAttempt } from '@/lib/rate-limit'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const user = await getCurrentUser()
|
||||
@@ -9,6 +10,19 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const ip =
|
||||
request.headers.get('x-forwarded-for') ??
|
||||
request.headers.get('x-real-ip') ??
|
||||
'unknown'
|
||||
|
||||
const { limited } = await checkRateLimit(ip)
|
||||
if (limited) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many attempts. Please try again later.' },
|
||||
{ status: 429 }
|
||||
)
|
||||
}
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
@@ -28,6 +42,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const valid = await verifyPassword(currentPassword, user.passwordHash)
|
||||
if (!valid) {
|
||||
await recordAttempt(ip)
|
||||
return NextResponse.json({ error: 'Current password is incorrect' }, { status: 400 })
|
||||
}
|
||||
|
||||
|
||||
@@ -6,33 +6,16 @@ import {
|
||||
setSessionCookie,
|
||||
} from '@/lib/auth'
|
||||
import { loginSchema } from '@/lib/validate'
|
||||
|
||||
// Simple in-memory rate limiter
|
||||
const loginAttempts = new Map<string, { count: number; resetAt: number }>()
|
||||
|
||||
function checkRateLimit(ip: string): boolean {
|
||||
const now = Date.now()
|
||||
const windowMs = 15 * 60 * 1000 // 15 minutes
|
||||
const maxAttempts = 10
|
||||
|
||||
const record = loginAttempts.get(ip)
|
||||
if (!record || record.resetAt < now) {
|
||||
loginAttempts.set(ip, { count: 1, resetAt: now + windowMs })
|
||||
return true
|
||||
}
|
||||
|
||||
if (record.count >= maxAttempts) {
|
||||
return false
|
||||
}
|
||||
|
||||
record.count++
|
||||
return true
|
||||
}
|
||||
import { checkRateLimit, recordAttempt } from '@/lib/rate-limit'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const ip = request.headers.get('x-forwarded-for') || 'unknown'
|
||||
const ip =
|
||||
request.headers.get('x-forwarded-for') ??
|
||||
request.headers.get('x-real-ip') ??
|
||||
'unknown'
|
||||
|
||||
if (!checkRateLimit(ip)) {
|
||||
const { limited } = await checkRateLimit(ip)
|
||||
if (limited) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many login attempts. Please try again later.' },
|
||||
{ status: 429 }
|
||||
@@ -58,11 +41,13 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } })
|
||||
if (!user) {
|
||||
await recordAttempt(ip)
|
||||
return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 })
|
||||
}
|
||||
|
||||
const valid = await verifyPassword(password, user.passwordHash)
|
||||
if (!valid) {
|
||||
await recordAttempt(ip)
|
||||
return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 })
|
||||
}
|
||||
|
||||
@@ -75,7 +60,6 @@ export async function POST(request: NextRequest) {
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
mustChangePassword: user.mustChangePassword,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
|
||||
export async function GET() {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return NextResponse.json({ user: null }, { status: 401 })
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
mustChangePassword: user.mustChangePassword,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const page = parseInt(searchParams.get('page') || '1')
|
||||
const limit = parseInt(searchParams.get('limit') || '20')
|
||||
const page = Math.max(1, parseInt(searchParams.get('page') || '1') || 1)
|
||||
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get('limit') || '20') || 20))
|
||||
const category = searchParams.get('category')
|
||||
const search = searchParams.get('search')
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ export async function POST(request: NextRequest) {
|
||||
try {
|
||||
event = constructWebhookEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!)
|
||||
} catch (err) {
|
||||
console.error('Webhook signature verification failed:', err)
|
||||
console.error('Webhook signature verification failed:', err instanceof Error ? err.message : String(err))
|
||||
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ export async function POST(request: NextRequest) {
|
||||
currency: order.currency,
|
||||
})
|
||||
} catch (emailErr) {
|
||||
console.error('Failed to send confirmation email:', emailErr)
|
||||
console.error('Failed to send confirmation email:', emailErr instanceof Error ? emailErr.message : String(emailErr))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ export async function POST(request: NextRequest) {
|
||||
console.log(`Unhandled event type: ${event.type}`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error processing webhook:', err)
|
||||
console.error('Error processing webhook:', err instanceof Error ? err.message : String(err))
|
||||
return NextResponse.json({ error: 'Webhook processing failed' }, { status: 500 })
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'
|
||||
import { Navbar } from '@/components/storefront/Navbar'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { useUser } from '@/context/UserContext'
|
||||
|
||||
interface CartItem {
|
||||
productId: string
|
||||
@@ -19,20 +20,23 @@ export default function CheckoutPage() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const router = useRouter()
|
||||
const { user, isLoading: userLoading } = useUser()
|
||||
|
||||
useEffect(() => {
|
||||
if (userLoading) return
|
||||
|
||||
if (!user) {
|
||||
router.push('/login?redirect=/checkout')
|
||||
return
|
||||
}
|
||||
|
||||
const stored = JSON.parse(localStorage.getItem('cart') || '[]')
|
||||
if (stored.length === 0) {
|
||||
router.push('/cart')
|
||||
return
|
||||
}
|
||||
setCart(stored)
|
||||
|
||||
const user = localStorage.getItem('user')
|
||||
if (!user) {
|
||||
router.push('/login?redirect=/checkout')
|
||||
}
|
||||
}, [router])
|
||||
}, [router, user, userLoading])
|
||||
|
||||
const subtotal = cart.reduce((sum, item) => sum + item.price * item.quantity, 0)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from 'next'
|
||||
import './globals.css'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { Footer } from '@/components/storefront/Footer'
|
||||
import { UserProvider } from '@/context/UserContext'
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
try {
|
||||
@@ -23,7 +24,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
return (
|
||||
<html lang="it">
|
||||
<body className="bg-gray-50 text-gray-900 min-h-screen flex flex-col">
|
||||
{children}
|
||||
<UserProvider>
|
||||
{children}
|
||||
</UserProvider>
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { useUser } from '@/context/UserContext'
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
@@ -23,6 +24,7 @@ function LoginForm() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const redirect = searchParams.get('redirect') || '/'
|
||||
const { refreshUser } = useUser()
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
@@ -43,11 +45,11 @@ function LoginForm() {
|
||||
return
|
||||
}
|
||||
|
||||
localStorage.setItem('user', JSON.stringify(data.user))
|
||||
const refreshedUser = await refreshUser()
|
||||
|
||||
if (data.user.mustChangePassword) {
|
||||
if (refreshedUser?.mustChangePassword) {
|
||||
router.push('/admin/change-password')
|
||||
} else if (data.user.role === 'ADMIN' || data.user.role === 'OWNER') {
|
||||
} else if (refreshedUser?.role === 'ADMIN' || refreshedUser?.role === 'OWNER') {
|
||||
router.push('/admin')
|
||||
} else {
|
||||
router.push(redirect)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useRouter } from 'next/navigation'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { useUser } from '@/context/UserContext'
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [name, setName] = useState('')
|
||||
@@ -14,6 +15,7 @@ export default function RegisterPage() {
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const router = useRouter()
|
||||
const { refreshUser } = useUser()
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
@@ -34,7 +36,7 @@ export default function RegisterPage() {
|
||||
return
|
||||
}
|
||||
|
||||
localStorage.setItem('user', JSON.stringify(data.user))
|
||||
await refreshUser()
|
||||
router.push('/')
|
||||
} catch {
|
||||
setError('Something went wrong. Please try again.')
|
||||
|
||||
@@ -3,31 +3,22 @@
|
||||
import Link from 'next/link'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useUser } from '@/context/UserContext'
|
||||
|
||||
export function Navbar() {
|
||||
const [cartCount, setCartCount] = useState(0)
|
||||
const [user, setUser] = useState<{ name?: string; email: string; role: string } | null>(null)
|
||||
const { user, refreshUser } = useUser()
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
const cart = JSON.parse(localStorage.getItem('cart') || '[]')
|
||||
const count = cart.reduce((sum: number, item: { quantity: number }) => sum + item.quantity, 0)
|
||||
setCartCount(count)
|
||||
|
||||
const userData = localStorage.getItem('user')
|
||||
if (userData) {
|
||||
try {
|
||||
setUser(JSON.parse(userData))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch('/api/auth/logout', { method: 'POST' })
|
||||
localStorage.removeItem('user')
|
||||
setUser(null)
|
||||
await refreshUser()
|
||||
router.push('/')
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
role: string
|
||||
mustChangePassword: boolean
|
||||
}
|
||||
|
||||
interface UserContextValue {
|
||||
user: User | null
|
||||
isLoading: boolean
|
||||
setUser: (user: User | null) => void
|
||||
refreshUser: () => Promise<User | null>
|
||||
}
|
||||
|
||||
const UserContext = createContext<UserContextValue | null>(null)
|
||||
|
||||
export function UserProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const refreshUser = useCallback(async (): Promise<User | null> => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/me')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setUser(data.user)
|
||||
return data.user
|
||||
} else {
|
||||
setUser(null)
|
||||
return null
|
||||
}
|
||||
} catch {
|
||||
setUser(null)
|
||||
return null
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
refreshUser()
|
||||
}, [refreshUser])
|
||||
|
||||
return (
|
||||
<UserContext.Provider value={{ user, isLoading, setUser, refreshUser }}>
|
||||
{children}
|
||||
</UserContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useUser(): UserContextValue {
|
||||
const ctx = useContext(UserContext)
|
||||
if (!ctx) throw new Error('useUser must be used within a UserProvider')
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
const MAX_ATTEMPTS = 10
|
||||
const WINDOW_MS = 15 * 60 * 1000 // 15 minutes
|
||||
|
||||
export async function checkRateLimit(key: string): Promise<{ limited: boolean; remaining: number }> {
|
||||
const windowStart = new Date(Date.now() - WINDOW_MS)
|
||||
|
||||
const count = await prisma.loginAttempt.count({
|
||||
where: { key, createdAt: { gte: windowStart } },
|
||||
})
|
||||
|
||||
if (count >= MAX_ATTEMPTS) {
|
||||
return { limited: true, remaining: 0 }
|
||||
}
|
||||
|
||||
return { limited: false, remaining: MAX_ATTEMPTS - count }
|
||||
}
|
||||
|
||||
export async function recordAttempt(key: string): Promise<void> {
|
||||
await prisma.loginAttempt.create({ data: { key } })
|
||||
// Clean up old records (keep last 24h only)
|
||||
const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000)
|
||||
await prisma.loginAttempt.deleteMany({ where: { createdAt: { lt: cutoff } } })
|
||||
}
|
||||
@@ -16,6 +16,23 @@ export async function saveImage(
|
||||
return `/uploads/${productId}/${filename}`
|
||||
}
|
||||
|
||||
const IMAGE_MAGIC_BYTES: Record<string, (buf: Buffer) => boolean> = {
|
||||
'image/jpeg': (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff,
|
||||
'image/png': (b) => b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47,
|
||||
'image/webp': (b) =>
|
||||
b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 &&
|
||||
b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50,
|
||||
'image/x-icon': (b) => b[0] === 0x00 && b[1] === 0x00 && b[2] === 0x01 && b[3] === 0x00,
|
||||
}
|
||||
|
||||
export async function validateImageMagicBytes(file: File, declaredType: string): Promise<boolean> {
|
||||
const checker = IMAGE_MAGIC_BYTES[declaredType]
|
||||
if (!checker) return false
|
||||
const arrayBuffer = await file.slice(0, 12).arrayBuffer()
|
||||
const buf = Buffer.from(arrayBuffer)
|
||||
return checker(buf)
|
||||
}
|
||||
|
||||
export async function deleteImageFile(url: string): Promise<void> {
|
||||
const filePath = path.join(process.cwd(), 'public', url)
|
||||
await unlink(filePath)
|
||||
|
||||
@@ -4,6 +4,14 @@ import type { NextRequest } from 'next/server'
|
||||
export function middleware(request: NextRequest) {
|
||||
const response = NextResponse.next()
|
||||
response.headers.set('x-pathname', request.nextUrl.pathname)
|
||||
response.headers.set('X-Frame-Options', 'DENY')
|
||||
response.headers.set('X-Content-Type-Options', 'nosniff')
|
||||
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
|
||||
response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
|
||||
response.headers.set(
|
||||
'Content-Security-Policy',
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https://api.stripe.com; frame-src https://js.stripe.com; frame-ancestors 'none'"
|
||||
)
|
||||
return response
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -3,9 +3,9 @@ services:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ecommerce
|
||||
POSTGRES_PASSWORD: ecommerce_password
|
||||
POSTGRES_DB: ecommerce
|
||||
POSTGRES_USER: ${POSTGRES_USER:-ecommerce}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ecommerce_password}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-ecommerce}
|
||||
volumes:
|
||||
- ./data/db:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
@@ -24,7 +24,7 @@ services:
|
||||
condition: service_healthy
|
||||
env_file: .env
|
||||
environment:
|
||||
DATABASE_URL: postgresql://ecommerce:ecommerce_password@db:5432/ecommerce
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-ecommerce}:${POSTGRES_PASSWORD:-ecommerce_password}@db:5432/${POSTGRES_DB:-ecommerce}
|
||||
expose:
|
||||
- "3000"
|
||||
volumes:
|
||||
|
||||
Reference in New Issue
Block a user