- lib/auth.ts: hashPassword(password) geëxporteerd (bcrypt, rounds=12) - actions/auth.ts: resetPasswordAction met Zod-validatie (min 8, superRefine gelijkheid), prisma.user.update (password_hash + must_reset_password=false), redirect /dashboard - app/(auth)/reset-password/page.tsx: server guard (userId check + must_reset_password check) - app/(auth)/reset-password/reset-form.tsx: client form (nieuw wachtwoord + bevestiging) - __tests__/actions/auth.test.ts: 3 tests voor resetPasswordAction
130 lines
3.9 KiB
TypeScript
130 lines
3.9 KiB
TypeScript
'use server'
|
|
|
|
import { redirect } from 'next/navigation'
|
|
import { cookies, headers } from 'next/headers'
|
|
import { getIronSession } from 'iron-session'
|
|
import { z } from 'zod'
|
|
import { prisma } from '@/lib/prisma'
|
|
import { registerUser, verifyUser, hashPassword } from '@/lib/auth'
|
|
import { SessionData, sessionOptions } from '@/lib/session'
|
|
import { checkRateLimit } from '@/lib/rate-limit'
|
|
import { isPhoneUA } from '@/lib/user-agent'
|
|
import { requireSession } from '@/lib/auth-guard'
|
|
|
|
async function getClientIp(): Promise<string> {
|
|
const h = await headers()
|
|
return h.get('x-forwarded-for')?.split(',')[0].trim() ?? h.get('x-real-ip') ?? 'unknown'
|
|
}
|
|
|
|
const registerSchema = z.object({
|
|
username: z.string().min(3, 'Gebruikersnaam moet minimaal 3 tekens bevatten').max(50),
|
|
password: z.string().min(8, 'Wachtwoord moet minimaal 8 tekens bevatten'),
|
|
})
|
|
|
|
const loginSchema = z.object({
|
|
username: z.string().min(1),
|
|
password: z.string().min(1),
|
|
})
|
|
|
|
export async function registerAction(_prevState: unknown, formData: FormData) {
|
|
const ip = await getClientIp()
|
|
if (!checkRateLimit(`register:${ip}`)) {
|
|
return { error: 'Te veel pogingen. Probeer het over een minuut opnieuw.' }
|
|
}
|
|
|
|
const parsed = registerSchema.safeParse({
|
|
username: formData.get('username'),
|
|
password: formData.get('password'),
|
|
})
|
|
|
|
if (!parsed.success) {
|
|
return { error: parsed.error.flatten().fieldErrors }
|
|
}
|
|
|
|
const result = await registerUser(parsed.data.username, parsed.data.password)
|
|
if (result.error) return { error: result.error }
|
|
|
|
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
|
|
session.userId = result.user!.id
|
|
session.isDemo = false
|
|
await session.save()
|
|
|
|
redirect('/dashboard')
|
|
}
|
|
|
|
export async function loginAction(_prevState: unknown, formData: FormData) {
|
|
const ip = await getClientIp()
|
|
if (!checkRateLimit(`login:${ip}`)) {
|
|
return { error: 'Te veel inlogpogingen. Probeer het over een minuut opnieuw.' }
|
|
}
|
|
|
|
const parsed = loginSchema.safeParse({
|
|
username: formData.get('username'),
|
|
password: formData.get('password'),
|
|
})
|
|
|
|
if (!parsed.success) {
|
|
return { error: 'Ongeldige inloggegevens' }
|
|
}
|
|
|
|
const user = await verifyUser(parsed.data.username, parsed.data.password)
|
|
if (!user) {
|
|
return { error: 'Onjuiste gebruikersnaam of wachtwoord' }
|
|
}
|
|
|
|
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
|
|
session.userId = user.id
|
|
session.isDemo = user.is_demo
|
|
await session.save()
|
|
|
|
// PBI-11 / ST-1135: telefoon-UA's krijgen de mobile-shell.
|
|
// Tablets en desktop volgen het bestaande /dashboard-pad.
|
|
const ua = (await headers()).get('user-agent')
|
|
if (isPhoneUA(ua)) {
|
|
redirect(user.active_product_id ? `/m/products/${user.active_product_id}/solo` : '/m/settings')
|
|
}
|
|
|
|
redirect('/dashboard')
|
|
}
|
|
|
|
export async function logoutAction() {
|
|
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
|
|
session.destroy()
|
|
redirect('/login')
|
|
}
|
|
|
|
const resetPasswordSchema = z
|
|
.object({
|
|
password: z.string().min(8, 'Wachtwoord moet minimaal 8 tekens bevatten'),
|
|
confirm: z.string(),
|
|
})
|
|
.superRefine((data, ctx) => {
|
|
if (data.password !== data.confirm) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: 'Wachtwoorden komen niet overeen',
|
|
path: ['confirm'],
|
|
})
|
|
}
|
|
})
|
|
|
|
export async function resetPasswordAction(_prevState: unknown, formData: FormData) {
|
|
const session = await requireSession()
|
|
|
|
const parsed = resetPasswordSchema.safeParse({
|
|
password: formData.get('password'),
|
|
confirm: formData.get('confirm'),
|
|
})
|
|
|
|
if (!parsed.success) {
|
|
return { error: parsed.error.flatten().fieldErrors }
|
|
}
|
|
|
|
const hash = await hashPassword(parsed.data.password)
|
|
await prisma.user.update({
|
|
where: { id: session.userId },
|
|
data: { password_hash: hash, must_reset_password: false },
|
|
})
|
|
|
|
redirect('/dashboard')
|
|
}
|