diff --git a/__tests__/actions/auth.test.ts b/__tests__/actions/auth.test.ts index 424e31b..a5a9be7 100644 --- a/__tests__/actions/auth.test.ts +++ b/__tests__/actions/auth.test.ts @@ -1,10 +1,19 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -const { redirectMock, verifyUserMock, headerGetMock, sessionSaveMock } = vi.hoisted(() => ({ +const { + redirectMock, + verifyUserMock, + headerGetMock, + sessionSaveMock, + requireSessionMock, + prismaUserUpdateMock, +} = vi.hoisted(() => ({ redirectMock: vi.fn((path: string) => { throw new Error(`REDIRECT:${path}`) }), verifyUserMock: vi.fn(), headerGetMock: vi.fn(), sessionSaveMock: vi.fn(), + requireSessionMock: vi.fn(), + prismaUserUpdateMock: vi.fn(), })) vi.mock('next/navigation', () => ({ redirect: redirectMock })) @@ -23,10 +32,15 @@ vi.mock('@/lib/session', () => ({ sessionOptions: { cookieName: 't', password: ' vi.mock('@/lib/auth', () => ({ verifyUser: verifyUserMock, registerUser: vi.fn(), + hashPassword: vi.fn().mockResolvedValue('hashed'), +})) +vi.mock('@/lib/auth-guard', () => ({ requireSession: requireSessionMock })) +vi.mock('@/lib/prisma', () => ({ + prisma: { user: { update: prismaUserUpdateMock } }, })) vi.mock('@/lib/rate-limit', () => ({ checkRateLimit: vi.fn().mockReturnValue(true) })) -import { loginAction } from '@/actions/auth' +import { loginAction, resetPasswordAction } from '@/actions/auth' const IPHONE_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) Mobile/15E148 Safari/604.1' const IPAD_UA = 'Mozilla/5.0 (iPad; CPU OS 17_4 like Mac OS X) Safari/604.1' @@ -44,6 +58,8 @@ beforeEach(() => { verifyUserMock.mockReset() headerGetMock.mockReset() sessionSaveMock.mockReset() + requireSessionMock.mockReset() + prismaUserUpdateMock.mockReset() }) describe('loginAction UA-redirect', () => { @@ -83,3 +99,37 @@ describe('loginAction UA-redirect', () => { await expect(loginAction(undefined, fd('demo', 'demo123pw'))).rejects.toThrow('REDIRECT:/m/products/p1/solo') }) }) + +describe('resetPasswordAction', () => { + function fdReset(password: string, confirm: string) { + const f = new FormData() + f.set('password', password) + f.set('confirm', confirm) + return f + } + + it('redirect /dashboard na succesvolle reset', async () => { + requireSessionMock.mockResolvedValue({ userId: 'u1' }) + prismaUserUpdateMock.mockResolvedValue({}) + await expect(resetPasswordAction(undefined, fdReset('nieuwpass1', 'nieuwpass1'))).rejects.toThrow('REDIRECT:/dashboard') + expect(prismaUserUpdateMock).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'u1' }, + data: expect.objectContaining({ password_hash: 'hashed', must_reset_password: false }), + }) + ) + }) + + it('fout als wachtwoorden niet overeenkomen', async () => { + requireSessionMock.mockResolvedValue({ userId: 'u1' }) + const result = await resetPasswordAction(undefined, fdReset('nieuwpass1', 'anderpass1')) + expect(result).toMatchObject({ error: expect.objectContaining({ confirm: expect.any(Array) }) }) + expect(prismaUserUpdateMock).not.toHaveBeenCalled() + }) + + it('fout als wachtwoord te kort is', async () => { + requireSessionMock.mockResolvedValue({ userId: 'u1' }) + const result = await resetPasswordAction(undefined, fdReset('kort', 'kort')) + expect(result).toMatchObject({ error: expect.objectContaining({ password: expect.any(Array) }) }) + }) +}) diff --git a/actions/auth.ts b/actions/auth.ts index 575178e..d40d188 100644 --- a/actions/auth.ts +++ b/actions/auth.ts @@ -4,10 +4,12 @@ import { redirect } from 'next/navigation' import { cookies, headers } from 'next/headers' import { getIronSession } from 'iron-session' import { z } from 'zod' -import { registerUser, verifyUser } from '@/lib/auth' +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 { const h = await headers() @@ -90,3 +92,39 @@ export async function logoutAction() { 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') +} diff --git a/app/(auth)/reset-password/page.tsx b/app/(auth)/reset-password/page.tsx new file mode 100644 index 0000000..c38ec5f --- /dev/null +++ b/app/(auth)/reset-password/page.tsx @@ -0,0 +1,37 @@ +import { redirect } from 'next/navigation' +import { getSession } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import { ResetPasswordForm } from './reset-form' + +export default async function ResetPasswordPage() { + const session = await getSession() + if (!session.userId) { + redirect('/login') + } + + const user = await prisma.user.findUnique({ + where: { id: session.userId }, + select: { must_reset_password: true }, + }) + + if (!user?.must_reset_password) { + redirect('/dashboard') + } + + return ( +
+
+
+

Wachtwoord wijzigen

+

+ Kies een nieuw wachtwoord om verder te gaan. +

+
+ +
+ +
+
+
+ ) +} diff --git a/app/(auth)/reset-password/reset-form.tsx b/app/(auth)/reset-password/reset-form.tsx new file mode 100644 index 0000000..85f44f2 --- /dev/null +++ b/app/(auth)/reset-password/reset-form.tsx @@ -0,0 +1,74 @@ +'use client' + +import { useActionState } from 'react' +import { useFormStatus } from 'react-dom' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { resetPasswordAction } from '@/actions/auth' + +type ActionResult = { error: string | Record } | undefined + +function SubmitButton() { + const { pending } = useFormStatus() + return ( + + ) +} + +function getErrorMessage(error: ActionResult): string | null { + if (!error) return null + if (typeof error.error === 'string') return error.error + const first = Object.values(error.error).flat()[0] + return first ?? null +} + +export function ResetPasswordForm() { + const [state, formAction] = useActionState(resetPasswordAction, undefined) + const errorMessage = getErrorMessage(state) + + return ( +
+
+ + +
+ +
+ + +
+ + {errorMessage && ( +
+ {errorMessage} +
+ )} + + + + ) +} diff --git a/lib/auth.ts b/lib/auth.ts index 52cede1..027e9aa 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -66,3 +66,7 @@ export async function verifyUser(username: string, password: string) { return user } + +export async function hashPassword(password: string): Promise { + return bcrypt.hash(password, 12) +}