fix(security): replace localStorage user state with server-side session

- Add GET /api/auth/me endpoint returning current user from httpOnly cookie
- Add UserContext + useUser() hook that fetches from /api/auth/me on mount
- Wrap root layout with UserProvider
- Remove all localStorage.setItem/getItem('user') calls from login, register,
  navbar, account pages, change-password, and checkout
- mustChangePassword redirect now reads from refreshed server session
This commit is contained in:
2026-05-19 10:10:17 +02:00
parent 0395a78008
commit fcfa0707a1
10 changed files with 116 additions and 50 deletions
+4 -2
View File
@@ -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
}
+6 -19
View File
@@ -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
View File
@@ -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)
+16
View File
@@ -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,
},
})
}
+10 -6
View File
@@ -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)
+4 -1
View File
@@ -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>
+5 -3
View File
@@ -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)
+3 -1
View File
@@ -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 -12
View File
@@ -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()
}
+60
View File
@@ -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
}