fix: admin-navigatie zichtbaar voor ADMIN-rol gebruikers

- requireAdmin() checkt nu de database i.p.v. session.isAdmin (was altijd undefined)
- loginAction stelt session.isAdmin in op basis van UserRole in de DB
- registerAction stelt session.isAdmin = false expliciet in
- NavBar toont 'Admin'-link conditioneel als roles.includes('ADMIN')
- UserMenu ROLE_LABELS uitgebreid met ADMIN → 'Admin'
- Tests aangepast: prismaUserRole.findFirst mock toegevoegd

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-05 20:26:54 +02:00
parent c3f10cccce
commit fbf58d4e44
6 changed files with 26 additions and 2 deletions

View file

@ -7,6 +7,7 @@ const {
sessionSaveMock, sessionSaveMock,
requireSessionMock, requireSessionMock,
prismaUserUpdateMock, prismaUserUpdateMock,
prismaUserRoleFindFirstMock,
} = vi.hoisted(() => ({ } = 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(),
@ -14,6 +15,7 @@ const {
sessionSaveMock: vi.fn(), sessionSaveMock: vi.fn(),
requireSessionMock: vi.fn(), requireSessionMock: vi.fn(),
prismaUserUpdateMock: vi.fn(), prismaUserUpdateMock: vi.fn(),
prismaUserRoleFindFirstMock: vi.fn().mockResolvedValue(null),
})) }))
vi.mock('next/navigation', () => ({ redirect: redirectMock })) vi.mock('next/navigation', () => ({ redirect: redirectMock }))
@ -36,7 +38,10 @@ vi.mock('@/lib/auth', () => ({
})) }))
vi.mock('@/lib/auth-guard', () => ({ requireSession: requireSessionMock })) vi.mock('@/lib/auth-guard', () => ({ requireSession: requireSessionMock }))
vi.mock('@/lib/prisma', () => ({ vi.mock('@/lib/prisma', () => ({
prisma: { user: { update: prismaUserUpdateMock } }, prisma: {
user: { update: prismaUserUpdateMock },
userRole: { findFirst: prismaUserRoleFindFirstMock },
},
})) }))
vi.mock('@/lib/rate-limit', () => ({ checkRateLimit: vi.fn().mockReturnValue(true) })) vi.mock('@/lib/rate-limit', () => ({ checkRateLimit: vi.fn().mockReturnValue(true) }))
@ -60,6 +65,7 @@ beforeEach(() => {
sessionSaveMock.mockReset() sessionSaveMock.mockReset()
requireSessionMock.mockReset() requireSessionMock.mockReset()
prismaUserUpdateMock.mockReset() prismaUserUpdateMock.mockReset()
prismaUserRoleFindFirstMock.mockResolvedValue(null)
}) })
describe('loginAction UA-redirect', () => { describe('loginAction UA-redirect', () => {

View file

@ -3,10 +3,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
const getSessionMock = vi.fn() const getSessionMock = vi.fn()
const isPairedSessionExpiredMock = vi.fn() const isPairedSessionExpiredMock = vi.fn()
const redirectMock = vi.fn(() => { throw new Error('REDIRECT_CALLED') }) const redirectMock = vi.fn(() => { throw new Error('REDIRECT_CALLED') })
const prismaUserRoleFindFirstMock = vi.fn()
vi.mock('@/lib/auth', () => ({ getSession: getSessionMock })) 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 }))
vi.mock('@/lib/prisma', () => ({
prisma: { userRole: { findFirst: prismaUserRoleFindFirstMock } },
}))
describe('requireSession', () => { describe('requireSession', () => {
beforeEach(() => { beforeEach(() => {

View file

@ -47,6 +47,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')
@ -72,9 +73,13 @@ 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()
// PBI-11 / ST-1135: telefoon-UA's krijgen de mobile-shell. // PBI-11 / ST-1135: telefoon-UA's krijgen de mobile-shell.

View file

@ -142,6 +142,7 @@ export function NavBar({
{navLink('/insights', 'Insights', pathname.startsWith('/insights'))} {navLink('/insights', 'Insights', pathname.startsWith('/insights'))}
{navLink('/ideas', 'Ideeën', pathname.startsWith('/ideas'))} {navLink('/ideas', 'Ideeën', pathname.startsWith('/ideas'))}
{navLink('/todos', "Todo's", pathname.startsWith('/todos'))} {navLink('/todos', "Todo's", pathname.startsWith('/todos'))}
{roles.includes('ADMIN') && navLink('/admin', 'Admin', pathname.startsWith('/admin'))}
</nav> </nav>
</div> </div>

View file

@ -20,6 +20,7 @@ const ROLE_LABELS: Record<string, string> = {
PRODUCT_OWNER: 'Product Owner', PRODUCT_OWNER: 'Product Owner',
SCRUM_MASTER: 'Scrum Master', SCRUM_MASTER: 'Scrum Master',
DEVELOPER: 'Developer', DEVELOPER: 'Developer',
ADMIN: 'Admin',
} }
interface UserMenuProps { interface UserMenuProps {

View file

@ -1,6 +1,7 @@
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { isPairedSessionExpired } from '@/lib/auth/pairing' import { isPairedSessionExpired } from '@/lib/auth/pairing'
import { prisma } from '@/lib/prisma'
/** /**
* Layout-side auth guard. Returns the session when valid; otherwise redirects * Layout-side auth guard. Returns the session when valid; otherwise redirects
@ -25,7 +26,13 @@ export async function requireSession() {
export async function requireAdmin() { export async function requireAdmin() {
const session = await getSession() const session = await getSession()
if (!session.userId || !session.isAdmin) { if (!session.userId) {
redirect('/dashboard')
}
const adminRole = await prisma.userRole.findFirst({
where: { user_id: session.userId, role: 'ADMIN' },
})
if (!adminRole) {
redirect('/dashboard') redirect('/dashboard')
} }
return session return session