From 8017968e60847f30de3e6e183211ae7d3009a6be Mon Sep 17 00:00:00 2001 From: janpeter visser Date: Fri, 24 Apr 2026 11:18:42 +0200 Subject: [PATCH] feat: ST-006-ST-008 auth pages, middleware, nav shell en dashboard - Login/register pages met AuthForm (useActionState + useFormStatus) - Server Actions voor login, register, logout met Zod validatie - Middleware checkt session cookie zonder iron-session op Edge runtime - AppLayout met auth-check en NavBar met demo badge en actieve links - Dashboard toont productenlijst via ProductList Client Component - Fix: a-in-a hydration error opgelost door div plus useRouter te gebruiken Co-Authored-By: Claude Sonnet 4.6 --- actions/auth.ts | 4 +- app/(app)/dashboard/page.tsx | 29 ++++++++++ app/(app)/layout.tsx | 22 ++++++++ app/(auth)/login/page.tsx | 40 ++++++++++++++ app/(auth)/register/page.tsx | 31 +++++++++++ components/auth/auth-form.tsx | 79 +++++++++++++++++++++++++++ components/dashboard/product-list.tsx | 70 ++++++++++++++++++++++++ components/shared/nav-bar.tsx | 73 +++++++++++++++++++++++++ middleware.ts | 29 ++++++++++ 9 files changed, 375 insertions(+), 2 deletions(-) create mode 100644 app/(app)/dashboard/page.tsx create mode 100644 app/(app)/layout.tsx create mode 100644 app/(auth)/login/page.tsx create mode 100644 app/(auth)/register/page.tsx create mode 100644 components/auth/auth-form.tsx create mode 100644 components/dashboard/product-list.tsx create mode 100644 components/shared/nav-bar.tsx create mode 100644 middleware.ts diff --git a/actions/auth.ts b/actions/auth.ts index 5b88c99..35cf233 100644 --- a/actions/auth.ts +++ b/actions/auth.ts @@ -17,7 +17,7 @@ const loginSchema = z.object({ password: z.string().min(1), }) -export async function registerAction(formData: FormData) { +export async function registerAction(_prevState: unknown, formData: FormData) { const parsed = registerSchema.safeParse({ username: formData.get('username'), password: formData.get('password'), @@ -38,7 +38,7 @@ export async function registerAction(formData: FormData) { redirect('/dashboard') } -export async function loginAction(formData: FormData) { +export async function loginAction(_prevState: unknown, formData: FormData) { const parsed = loginSchema.safeParse({ username: formData.get('username'), password: formData.get('password'), diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx new file mode 100644 index 0000000..8893480 --- /dev/null +++ b/app/(app)/dashboard/page.tsx @@ -0,0 +1,29 @@ +import { cookies } from 'next/headers' +import { getIronSession } from 'iron-session' +import { SessionData, sessionOptions } from '@/lib/session' +import { prisma } from '@/lib/prisma' +import Link from 'next/link' +import { Button } from '@/components/ui/button' +import { ProductList } from '@/components/dashboard/product-list' + +export default async function DashboardPage() { + const session = await getIronSession(await cookies(), sessionOptions) + + const products = await prisma.product.findMany({ + where: { user_id: session.userId, archived: false }, + orderBy: { created_at: 'desc' }, + }) + + return ( +
+
+

Mijn Producten

+ {!session.isDemo && ( + + )} +
+ + +
+ ) +} diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx new file mode 100644 index 0000000..25a1733 --- /dev/null +++ b/app/(app)/layout.tsx @@ -0,0 +1,22 @@ +import { redirect } from 'next/navigation' +import { cookies } from 'next/headers' +import { getIronSession } from 'iron-session' +import { SessionData, sessionOptions } from '@/lib/session' +import { NavBar } from '@/components/shared/nav-bar' + +export default async function AppLayout({ children }: { children: React.ReactNode }) { + const session = await getIronSession(await cookies(), sessionOptions) + + if (!session.userId) { + redirect('/login') + } + + return ( +
+ +
+ {children} +
+
+ ) +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx new file mode 100644 index 0000000..c1a9806 --- /dev/null +++ b/app/(auth)/login/page.tsx @@ -0,0 +1,40 @@ +import Link from 'next/link' +import { loginAction } from '@/actions/auth' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { AuthForm } from '@/components/auth/auth-form' + +export default function LoginPage() { + return ( +
+
+ + {/* Logo / titel */} +
+

Scrum4Me

+

Inloggen bij je account

+
+ + {/* Formulier */} +
+ + +
+ Nog geen account?{' '} + + Registreer hier + +
+
+ + {/* Demo credentials */} +
+

Demo-account (alleen lezen)

+

Gebruikersnaam: demo

+

Wachtwoord: demo1234

+
+ +
+
+ ) +} diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx new file mode 100644 index 0000000..ee1c6aa --- /dev/null +++ b/app/(auth)/register/page.tsx @@ -0,0 +1,31 @@ +import Link from 'next/link' +import { registerAction } from '@/actions/auth' +import { AuthForm } from '@/components/auth/auth-form' + +export default function RegisterPage() { + return ( +
+
+ + {/* Logo / titel */} +
+

Scrum4Me

+

Nieuw account aanmaken

+
+ + {/* Formulier */} +
+ + +
+ Al een account?{' '} + + Inloggen + +
+
+ +
+
+ ) +} diff --git a/components/auth/auth-form.tsx b/components/auth/auth-form.tsx new file mode 100644 index 0000000..6ec179b --- /dev/null +++ b/components/auth/auth-form.tsx @@ -0,0 +1,79 @@ +'use client' + +import { useActionState } from 'react' +import { useFormStatus } from 'react-dom' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' + +type ActionResult = { error: string | Record } | undefined + +function SubmitButton({ label }: { label: string }) { + const { pending } = useFormStatus() + return ( + + ) +} + +function getErrorMessage(error: ActionResult): string | null { + if (!error) return null + if (typeof error.error === 'string') return error.error + // Field errors object — flatten to first message + const first = Object.values(error.error).flat()[0] + return first ?? null +} + +interface AuthFormProps { + action: (_prevState: unknown, formData: FormData) => Promise + submitLabel: string +} + +export function AuthForm({ action, submitLabel }: AuthFormProps) { + const [state, formAction] = useActionState(action, undefined) + const errorMessage = getErrorMessage(state) + + return ( +
+
+ + +
+ +
+ + +
+ + {errorMessage && ( +
+ {errorMessage} +
+ )} + + + + ) +} diff --git a/components/dashboard/product-list.tsx b/components/dashboard/product-list.tsx new file mode 100644 index 0000000..3099d5b --- /dev/null +++ b/components/dashboard/product-list.tsx @@ -0,0 +1,70 @@ +'use client' + +import Link from 'next/link' +import { useRouter } from 'next/navigation' +import { Button } from '@/components/ui/button' + +interface Product { + id: string + name: string + description: string | null + repo_url: string | null +} + +interface ProductListProps { + products: Product[] + isDemo: boolean +} + +export function ProductList({ products, isDemo }: ProductListProps) { + const router = useRouter() + + if (products.length === 0) { + return ( +
+

Je hebt nog geen producten aangemaakt.

+ {!isDemo && ( + + )} +
+ ) + } + + return ( +
+ {products.map(product => ( +
router.push(`/products/${product.id}`)} + className="group cursor-pointer bg-surface-container-low border border-border rounded-xl p-4 hover:border-primary transition-colors" + > +
+
+

+ {product.name} +

+ {product.description && ( +

+ {product.description.slice(0, 80)}{product.description.length > 80 ? '…' : ''} +

+ )} +
+ {product.repo_url && ( + e.stopPropagation()} + className="text-xs text-muted-foreground hover:text-primary shrink-0 underline" + > + Repo + + )} +
+
+ ))} +
+ ) +} diff --git a/components/shared/nav-bar.tsx b/components/shared/nav-bar.tsx new file mode 100644 index 0000000..d268e7a --- /dev/null +++ b/components/shared/nav-bar.tsx @@ -0,0 +1,73 @@ +'use client' + +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import { logoutAction } from '@/actions/auth' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { cn } from '@/lib/utils' + +interface NavBarProps { + isDemo: boolean +} + +export function NavBar({ isDemo }: NavBarProps) { + const pathname = usePathname() + + const navLinks = [ + { href: '/dashboard', label: 'Producten' }, + { href: '/todos', label: "Todo's" }, + ] + + return ( +
+ {/* Logo */} + + Scrum4Me + {isDemo && ( + + Demo + + )} + + + {/* Nav links */} + + + {/* Rechts: instellingen + uitloggen */} +
+ + Instellingen + +
+ +
+
+
+ ) +} diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..1b83634 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,29 @@ +import { NextResponse } from 'next/server' +import type { NextRequest } from 'next/server' +import { sessionOptions } from '@/lib/session' + +const protectedRoutes = ['/dashboard', '/products', '/todos', '/settings'] +const authRoutes = ['/login', '/register'] + +export function middleware(request: NextRequest) { + const path = request.nextUrl.pathname + const isProtected = protectedRoutes.some(r => path.startsWith(r)) + const isAuthRoute = authRoutes.some(r => path.startsWith(r)) + + // Check cookie existence only — full session validation happens in layout.tsx + const hasSession = !!request.cookies.get(sessionOptions.cookieName)?.value + + if (isProtected && !hasSession) { + return NextResponse.redirect(new URL('/login', request.url)) + } + + if (isAuthRoute && hasSession) { + return NextResponse.redirect(new URL('/dashboard', request.url)) + } + + return NextResponse.next() +} + +export const config = { + matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'], +}