Scrum4Me/actions/auth.ts
Scrum4Me Agent a19ae89e37 feat(ST-l9kkxh2m): /reset-password pagina + resetPasswordAction + hashPassword
- 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
2026-05-05 14:30:59 +02:00

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')
}