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
This commit is contained in:
parent
71281038ff
commit
a19ae89e37
5 changed files with 206 additions and 3 deletions
|
|
@ -1,10 +1,19 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
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}`) }),
|
redirectMock: vi.fn((path: string) => { throw new Error(`REDIRECT:${path}`) }),
|
||||||
verifyUserMock: vi.fn(),
|
verifyUserMock: vi.fn(),
|
||||||
headerGetMock: vi.fn(),
|
headerGetMock: vi.fn(),
|
||||||
sessionSaveMock: vi.fn(),
|
sessionSaveMock: vi.fn(),
|
||||||
|
requireSessionMock: vi.fn(),
|
||||||
|
prismaUserUpdateMock: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('next/navigation', () => ({ redirect: redirectMock }))
|
vi.mock('next/navigation', () => ({ redirect: redirectMock }))
|
||||||
|
|
@ -23,10 +32,15 @@ vi.mock('@/lib/session', () => ({ sessionOptions: { cookieName: 't', password: '
|
||||||
vi.mock('@/lib/auth', () => ({
|
vi.mock('@/lib/auth', () => ({
|
||||||
verifyUser: verifyUserMock,
|
verifyUser: verifyUserMock,
|
||||||
registerUser: vi.fn(),
|
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) }))
|
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 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'
|
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()
|
verifyUserMock.mockReset()
|
||||||
headerGetMock.mockReset()
|
headerGetMock.mockReset()
|
||||||
sessionSaveMock.mockReset()
|
sessionSaveMock.mockReset()
|
||||||
|
requireSessionMock.mockReset()
|
||||||
|
prismaUserUpdateMock.mockReset()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('loginAction UA-redirect', () => {
|
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')
|
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) }) })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,12 @@ import { redirect } from 'next/navigation'
|
||||||
import { cookies, headers } from 'next/headers'
|
import { cookies, headers } from 'next/headers'
|
||||||
import { getIronSession } from 'iron-session'
|
import { getIronSession } from 'iron-session'
|
||||||
import { z } from 'zod'
|
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 { SessionData, sessionOptions } from '@/lib/session'
|
||||||
import { checkRateLimit } from '@/lib/rate-limit'
|
import { checkRateLimit } from '@/lib/rate-limit'
|
||||||
import { isPhoneUA } from '@/lib/user-agent'
|
import { isPhoneUA } from '@/lib/user-agent'
|
||||||
|
import { requireSession } from '@/lib/auth-guard'
|
||||||
|
|
||||||
async function getClientIp(): Promise<string> {
|
async function getClientIp(): Promise<string> {
|
||||||
const h = await headers()
|
const h = await headers()
|
||||||
|
|
@ -90,3 +92,39 @@ export async function logoutAction() {
|
||||||
session.destroy()
|
session.destroy()
|
||||||
redirect('/login')
|
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')
|
||||||
|
}
|
||||||
|
|
|
||||||
37
app/(auth)/reset-password/page.tsx
Normal file
37
app/(auth)/reset-password/page.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<main className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-sm space-y-6">
|
||||||
|
<div className="text-center space-y-1">
|
||||||
|
<h1 className="text-2xl font-medium text-foreground">Wachtwoord wijzigen</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Kies een nieuw wachtwoord om verder te gaan.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-surface-container-low rounded-xl p-6 space-y-4 border border-border">
|
||||||
|
<ResetPasswordForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
74
app/(auth)/reset-password/reset-form.tsx
Normal file
74
app/(auth)/reset-password/reset-form.tsx
Normal file
|
|
@ -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<string, string[]> } | undefined
|
||||||
|
|
||||||
|
function SubmitButton() {
|
||||||
|
const { pending } = useFormStatus()
|
||||||
|
return (
|
||||||
|
<Button type="submit" className="w-full" disabled={pending}>
|
||||||
|
{pending ? 'Even wachten…' : 'Wachtwoord opslaan'}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<form action={formAction} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="password" className="text-sm font-medium text-foreground">
|
||||||
|
Nieuw wachtwoord
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
placeholder="••••••••"
|
||||||
|
className="bg-input-background border-border focus-visible:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="confirm" className="text-sm font-medium text-foreground">
|
||||||
|
Bevestig wachtwoord
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="confirm"
|
||||||
|
name="confirm"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
placeholder="••••••••"
|
||||||
|
className="bg-input-background border-border focus-visible:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errorMessage && (
|
||||||
|
<div className="bg-error-container text-error-container-foreground rounded-lg px-3 py-2 text-sm border-l-4 border-error">
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SubmitButton />
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -66,3 +66,7 @@ export async function verifyUser(username: string, password: string) {
|
||||||
|
|
||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
|
return bcrypt.hash(password, 12)
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue