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 <noreply@anthropic.com>
This commit is contained in:
parent
24924c9b79
commit
8017968e60
9 changed files with 375 additions and 2 deletions
|
|
@ -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'),
|
||||
|
|
|
|||
29
app/(app)/dashboard/page.tsx
Normal file
29
app/(app)/dashboard/page.tsx
Normal file
|
|
@ -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<SessionData>(await cookies(), sessionOptions)
|
||||
|
||||
const products = await prisma.product.findMany({
|
||||
where: { user_id: session.userId, archived: false },
|
||||
orderBy: { created_at: 'desc' },
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto w-full">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-xl font-medium text-foreground">Mijn Producten</h1>
|
||||
{!session.isDemo && (
|
||||
<Button render={<Link href="/products/new" />}>+ Nieuw product</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ProductList products={products} isDemo={session.isDemo ?? false} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
22
app/(app)/layout.tsx
Normal file
22
app/(app)/layout.tsx
Normal file
|
|
@ -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<SessionData>(await cookies(), sessionOptions)
|
||||
|
||||
if (!session.userId) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
<NavBar isDemo={session.isDemo} />
|
||||
<main className="flex-1 flex flex-col">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
40
app/(auth)/login/page.tsx
Normal file
40
app/(auth)/login/page.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm space-y-6">
|
||||
|
||||
{/* Logo / titel */}
|
||||
<div className="text-center space-y-1">
|
||||
<h1 className="text-2xl font-medium text-foreground">Scrum4Me</h1>
|
||||
<p className="text-sm text-muted-foreground">Inloggen bij je account</p>
|
||||
</div>
|
||||
|
||||
{/* Formulier */}
|
||||
<div className="bg-surface-container-low rounded-xl p-6 space-y-4 border border-border">
|
||||
<AuthForm action={loginAction} submitLabel="Inloggen" />
|
||||
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
Nog geen account?{' '}
|
||||
<Link href="/register" className="text-primary hover:underline font-medium">
|
||||
Registreer hier
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Demo credentials */}
|
||||
<div className="bg-info-container text-info-container-foreground rounded-xl p-4 text-sm space-y-1 border border-border">
|
||||
<p className="font-medium">Demo-account (alleen lezen)</p>
|
||||
<p>Gebruikersnaam: <span className="font-mono font-medium">demo</span></p>
|
||||
<p>Wachtwoord: <span className="font-mono font-medium">demo1234</span></p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
31
app/(auth)/register/page.tsx
Normal file
31
app/(auth)/register/page.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm space-y-6">
|
||||
|
||||
{/* Logo / titel */}
|
||||
<div className="text-center space-y-1">
|
||||
<h1 className="text-2xl font-medium text-foreground">Scrum4Me</h1>
|
||||
<p className="text-sm text-muted-foreground">Nieuw account aanmaken</p>
|
||||
</div>
|
||||
|
||||
{/* Formulier */}
|
||||
<div className="bg-surface-container-low rounded-xl p-6 space-y-4 border border-border">
|
||||
<AuthForm action={registerAction} submitLabel="Account aanmaken" />
|
||||
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
Al een account?{' '}
|
||||
<Link href="/login" className="text-primary hover:underline font-medium">
|
||||
Inloggen
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
79
components/auth/auth-form.tsx
Normal file
79
components/auth/auth-form.tsx
Normal file
|
|
@ -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<string, string[]> } | undefined
|
||||
|
||||
function SubmitButton({ label }: { label: string }) {
|
||||
const { pending } = useFormStatus()
|
||||
return (
|
||||
<Button type="submit" className="w-full" disabled={pending}>
|
||||
{pending ? 'Even wachten…' : label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
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<ActionResult>
|
||||
submitLabel: string
|
||||
}
|
||||
|
||||
export function AuthForm({ action, submitLabel }: AuthFormProps) {
|
||||
const [state, formAction] = useActionState(action, undefined)
|
||||
const errorMessage = getErrorMessage(state)
|
||||
|
||||
return (
|
||||
<form action={formAction} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="username" className="text-sm font-medium text-foreground">
|
||||
Gebruikersnaam
|
||||
</label>
|
||||
<Input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
required
|
||||
minLength={3}
|
||||
placeholder="jouw-naam"
|
||||
className="bg-input-background border-border focus-visible:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" className="text-sm font-medium text-foreground">
|
||||
Wachtwoord
|
||||
</label>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-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 label={submitLabel} />
|
||||
</form>
|
||||
)
|
||||
}
|
||||
70
components/dashboard/product-list.tsx
Normal file
70
components/dashboard/product-list.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="bg-surface-container-low rounded-xl border border-border p-12 text-center space-y-3">
|
||||
<p className="text-muted-foreground">Je hebt nog geen producten aangemaakt.</p>
|
||||
{!isDemo && (
|
||||
<Button variant="outline" render={<Link href="/products/new" />}>
|
||||
Maak je eerste product aan
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{products.map(product => (
|
||||
<div
|
||||
key={product.id}
|
||||
onClick={() => 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"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-foreground group-hover:text-primary transition-colors truncate">
|
||||
{product.name}
|
||||
</p>
|
||||
{product.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1 line-clamp-1">
|
||||
{product.description.slice(0, 80)}{product.description.length > 80 ? '…' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{product.repo_url && (
|
||||
<a
|
||||
href={product.repo_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="text-xs text-muted-foreground hover:text-primary shrink-0 underline"
|
||||
>
|
||||
Repo
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
73
components/shared/nav-bar.tsx
Normal file
73
components/shared/nav-bar.tsx
Normal file
|
|
@ -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 (
|
||||
<header className="bg-surface-container-low border-b border-border h-14 flex items-center px-4 gap-4 shrink-0">
|
||||
{/* Logo */}
|
||||
<Link href="/dashboard" className="flex items-center gap-2 font-medium text-foreground">
|
||||
<span className="text-primary font-semibold">Scrum4Me</span>
|
||||
{isDemo && (
|
||||
<Badge className="bg-warning text-warning-foreground text-xs px-2 py-0">
|
||||
Demo
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* Nav links */}
|
||||
<nav className="flex items-center gap-1 ml-2">
|
||||
{navLinks.map(link => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded-md text-sm transition-colors',
|
||||
pathname.startsWith(link.href)
|
||||
? 'bg-primary-container text-primary-container-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-surface-container'
|
||||
)}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Rechts: instellingen + uitloggen */}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Link
|
||||
href="/settings"
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded-md text-sm transition-colors',
|
||||
pathname.startsWith('/settings')
|
||||
? 'bg-primary-container text-primary-container-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-surface-container'
|
||||
)}
|
||||
>
|
||||
Instellingen
|
||||
</Link>
|
||||
<form action={logoutAction}>
|
||||
<Button variant="ghost" size="sm" type="submit" className="text-muted-foreground hover:text-foreground">
|
||||
Uitloggen
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
29
middleware.ts
Normal file
29
middleware.ts
Normal file
|
|
@ -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).*)'],
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue