Merge pull request #98 from madhura68/feat/story-l9kkxh2m

ST-1204: Password-reset-flow & CLI-bootstrap
This commit is contained in:
Janpeter Visser 2026-05-05 14:44:27 +02:00 committed by GitHub
commit 64b8c7f5d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 265 additions and 3 deletions

View file

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

View file

@ -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<string> {
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')
}

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

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

View file

@ -66,3 +66,7 @@ export async function verifyUser(username: string, password: string) {
return user
}
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 12)
}

View file

@ -15,6 +15,7 @@
"db:erd": "prisma generate",
"db:erd:watch": "chokidar \"prisma/schema.prisma\" -c \"npm run db:erd\"",
"db:insert-milestone": "tsx scripts/insert-milestone.ts",
"create-admin": "tsx scripts/create-admin.ts",
"seed": "prisma db seed",
"docs:index": "node scripts/generate-docs-index.mjs",
"docs:check-links": "node scripts/check-doc-links.mjs",

58
scripts/create-admin.ts Normal file
View file

@ -0,0 +1,58 @@
// Maak een admin-user aan of upgrade een bestaande user naar ADMIN-rol.
//
// Gebruik:
// npx tsx scripts/create-admin.ts <username> <password>
import { PrismaClient } from '@prisma/client'
import { Pool } from 'pg'
import { PrismaPg } from '@prisma/adapter-pg'
import * as bcrypt from 'bcryptjs'
import * as dotenv from 'dotenv'
import * as path from 'path'
const root = path.resolve(__dirname, '..')
dotenv.config({ path: path.join(root, '.env.local'), override: true })
dotenv.config({ path: path.join(root, '.env') })
const [username, password] = process.argv.slice(2)
if (!username || !password) {
console.error('Usage: npx tsx scripts/create-admin.ts <username> <password>')
process.exit(1)
}
const url = process.env.DIRECT_URL || process.env.DATABASE_URL
if (!url) {
console.error('Fout: DATABASE_URL is niet ingesteld.')
process.exit(1)
}
const pool = new Pool({ connectionString: url })
const adapter = new PrismaPg(pool)
const prisma = new PrismaClient({ adapter })
async function main() {
let user = await prisma.user.findUnique({ where: { username } })
if (!user) {
const password_hash = await bcrypt.hash(password, 12)
user = await prisma.user.create({ data: { username, password_hash } })
console.log(`Gebruiker '${username}' aangemaakt.`)
} else {
console.log(`Gebruiker '${username}' gevonden — rol wordt geüpgraded.`)
}
await prisma.userRole.upsert({
where: { user_id_role: { user_id: user.id, role: 'ADMIN' } },
create: { user_id: user.id, role: 'ADMIN' },
update: {},
})
console.log(`Admin-rol toegewezen aan '${username}'.`)
}
main()
.catch((err) => {
console.error('Fout:', err.message)
process.exit(1)
})
.finally(() => prisma.$disconnect())