Compare commits

...
Sign in to create a new pull request.

2 commits

Author SHA1 Message Date
Scrum4Me Agent
c0ded1f482 feat(ST-nma6ylbl): requireAdmin() guard + /admin layout-shell + tests
- lib/auth-guard.ts: requireAdmin() toegevoegd — redirect /dashboard bij !userId of !isAdmin
- app/(app)/admin/layout.tsx: admin-sidebar met links naar /admin/users, /admin/jobs, /admin/products
- app/(app)/admin/page.tsx: redirect-stub naar /admin/users
- __tests__/lib/auth-guard.test.ts: 3 tests voor requireAdmin() (geen userId, isAdmin=false, isAdmin=true)
2026-05-05 14:26:03 +02:00
Scrum4Me Agent
8af5354f22 feat(ST-nma6ylbl): SessionData isAdmin + loginAction admin-redirect + must_reset_password-interceptie
- SessionData: isAdmin: boolean toegevoegd (na isDemo)
- loginAction: UserRole-query voor ADMIN, session.isAdmin gezet, redirect-volgorde:
  must_reset_password → /reset-password, adminRole → /admin, phone-UA, dashboard
- registerAction: session.isAdmin = false
- pair/claim route: session.isAdmin = false (QR-pairing is geen admin-flow)
2026-05-05 14:22:04 +02:00
7 changed files with 79 additions and 0 deletions

View file

@ -8,6 +8,41 @@ vi.mock('@/lib/auth', () => ({ getSession: getSessionMock }))
vi.mock('@/lib/auth/pairing', () => ({ isPairedSessionExpired: isPairedSessionExpiredMock })) vi.mock('@/lib/auth/pairing', () => ({ isPairedSessionExpired: isPairedSessionExpiredMock }))
vi.mock('next/navigation', () => ({ redirect: redirectMock })) vi.mock('next/navigation', () => ({ redirect: redirectMock }))
describe('requireAdmin', () => {
beforeEach(() => {
getSessionMock.mockReset()
isPairedSessionExpiredMock.mockReset()
redirectMock.mockClear()
})
afterEach(() => {
vi.resetModules()
})
it('redirect /dashboard als userId ontbreekt', async () => {
getSessionMock.mockResolvedValue({ userId: undefined, isAdmin: false })
const { requireAdmin } = await import('@/lib/auth-guard')
await expect(requireAdmin()).rejects.toThrow('REDIRECT_CALLED')
expect(redirectMock).toHaveBeenCalledWith('/dashboard')
})
it('redirect /dashboard als isAdmin false is', async () => {
getSessionMock.mockResolvedValue({ userId: 'u1', isAdmin: false })
const { requireAdmin } = await import('@/lib/auth-guard')
await expect(requireAdmin()).rejects.toThrow('REDIRECT_CALLED')
expect(redirectMock).toHaveBeenCalledWith('/dashboard')
})
it('geeft sessie terug als isAdmin true is', async () => {
const sess = { userId: 'u1', isAdmin: true }
getSessionMock.mockResolvedValue(sess)
const { requireAdmin } = await import('@/lib/auth-guard')
const result = await requireAdmin()
expect(result).toBe(sess)
expect(redirectMock).not.toHaveBeenCalled()
})
})
describe('requireSession', () => { describe('requireSession', () => {
beforeEach(() => { beforeEach(() => {
getSessionMock.mockReset() getSessionMock.mockReset()

View file

@ -4,6 +4,7 @@ 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 { prisma } from '@/lib/prisma'
import { registerUser, verifyUser } from '@/lib/auth' import { registerUser, verifyUser } 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'
@ -45,6 +46,7 @@ export async function registerAction(_prevState: unknown, formData: FormData) {
const session = await getIronSession<SessionData>(await cookies(), sessionOptions) const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
session.userId = result.user!.id session.userId = result.user!.id
session.isDemo = false session.isDemo = false
session.isAdmin = false
await session.save() await session.save()
redirect('/dashboard') redirect('/dashboard')
@ -70,11 +72,22 @@ export async function loginAction(_prevState: unknown, formData: FormData) {
return { error: 'Onjuiste gebruikersnaam of wachtwoord' } return { error: 'Onjuiste gebruikersnaam of wachtwoord' }
} }
const adminRole = await prisma.userRole.findFirst({
where: { user_id: user.id, role: 'ADMIN' },
})
const session = await getIronSession<SessionData>(await cookies(), sessionOptions) const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
session.userId = user.id session.userId = user.id
session.isDemo = user.is_demo session.isDemo = user.is_demo
session.isAdmin = !!adminRole
await session.save() await session.save()
if (user.must_reset_password) {
redirect('/reset-password')
} else if (adminRole) {
redirect('/admin')
}
// PBI-11 / ST-1135: telefoon-UA's krijgen de mobile-shell. // PBI-11 / ST-1135: telefoon-UA's krijgen de mobile-shell.
// Tablets en desktop volgen het bestaande /dashboard-pad. // Tablets en desktop volgen het bestaande /dashboard-pad.
const ua = (await headers()).get('user-agent') const ua = (await headers()).get('user-agent')

View file

@ -0,0 +1,16 @@
import { requireAdmin } from '@/lib/auth-guard'
import Link from 'next/link'
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
await requireAdmin()
return (
<div className="flex min-h-screen">
<nav className="w-48 border-r p-4 flex flex-col gap-2">
<Link href="/admin/users">Gebruikers</Link>
<Link href="/admin/jobs">Claude Jobs</Link>
<Link href="/admin/products">Producten</Link>
</nav>
<main className="flex-1 p-6">{children}</main>
</div>
)
}

5
app/(app)/admin/page.tsx Normal file
View file

@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function AdminPage() {
redirect('/admin/users')
}

View file

@ -88,6 +88,7 @@ export async function POST(request: Request) {
const session = await getIronSession<SessionData>(await cookies(), sessionOptions) const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
session.userId = pairing.user_id session.userId = pairing.user_id
session.isDemo = pairing.user?.is_demo ?? false session.isDemo = pairing.user?.is_demo ?? false
session.isAdmin = false
session.paired = true session.paired = true
session.pairedExpiresAt = Date.now() + PAIRED_TTL_MS session.pairedExpiresAt = Date.now() + PAIRED_TTL_MS
await session.save() await session.save()

View file

@ -22,3 +22,11 @@ export async function requireSession() {
return session return session
} }
export async function requireAdmin() {
const session = await getSession()
if (!session.userId || !session.isAdmin) {
redirect('/dashboard')
}
return session
}

View file

@ -3,6 +3,7 @@ import { SessionOptions } from 'iron-session'
export interface SessionData { export interface SessionData {
userId: string userId: string
isDemo: boolean isDemo: boolean
isAdmin: boolean
// ST-1002 (M10) — gezet door /api/auth/pair/claim na een succesvolle QR-pairing. // ST-1002 (M10) — gezet door /api/auth/pair/claim na een succesvolle QR-pairing.
// Beide velden zijn optioneel zodat bestaande wachtwoord-sessies onveranderd blijven. // Beide velden zijn optioneel zodat bestaande wachtwoord-sessies onveranderd blijven.
paired?: boolean paired?: boolean