feat: implement user authentication and admin management

- Add user management API endpoints for listing and creating users.
- Implement login and logout functionality with rate limiting.
- Create UI components for login form, logout button, and animated elements.
- Add session management with JWT and secure cookie handling.
- Seed initial admin user if no users exist.
- Introduce utility functions for password hashing and user authentication.
- Set up proxy middleware for route protection and security headers.
- Create a JSON file for user data storage.
- Add links configuration for external resources.
This commit is contained in:
Anders Böttcher
2026-05-12 14:58:11 +02:00
parent 50566f2a22
commit 0d58fafe4e
34 changed files with 4263 additions and 5012 deletions
+91
View File
@@ -0,0 +1,91 @@
import { NextResponse } from 'next/server'
import { authenticateUser, seedAdminIfEmpty } from '@/lib/users'
import { createSession } from '@/lib/session'
// ── In-memory rate limiter (single-instance, resets on restart) ──────────────
interface RateLimitEntry {
attempts: number
lockedUntil: number
}
const rateLimitMap = new Map<string, RateLimitEntry>()
const MAX_ATTEMPTS = 5
const LOCKOUT_MS = 15 * 60 * 1000 // 15 minutes
function getClientIp(req: Request): string {
return (
req.headers.get('x-forwarded-for')?.split(',')[0].trim() ??
req.headers.get('x-real-ip') ??
'unknown'
)
}
function checkRateLimit(ip: string): { allowed: boolean; retryAfterSecs?: number } {
const now = Date.now()
const entry = rateLimitMap.get(ip)
if (!entry) return { allowed: true }
if (entry.lockedUntil > now) {
return { allowed: false, retryAfterSecs: Math.ceil((entry.lockedUntil - now) / 1000) }
}
// Lock has expired — reset
rateLimitMap.delete(ip)
return { allowed: true }
}
function recordFailedAttempt(ip: string): void {
const now = Date.now()
const entry = rateLimitMap.get(ip) ?? { attempts: 0, lockedUntil: 0 }
entry.attempts++
if (entry.attempts >= MAX_ATTEMPTS) {
entry.lockedUntil = now + LOCKOUT_MS
entry.attempts = 0
}
rateLimitMap.set(ip, entry)
}
function clearAttempts(ip: string): void {
rateLimitMap.delete(ip)
}
// ── Handler ───────────────────────────────────────────────────────────────────
export async function POST(request: Request) {
const ip = getClientIp(request)
const { allowed, retryAfterSecs } = checkRateLimit(ip)
if (!allowed) {
return NextResponse.json(
{ error: `Too many attempts. Try again in ${retryAfterSecs} seconds.` },
{
status: 429,
headers: { 'Retry-After': String(retryAfterSecs) },
},
)
}
let body: { username?: string; password?: string }
try {
body = (await request.json()) as { username?: string; password?: string }
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
const username = String(body.username ?? '').trim()
const password = String(body.password ?? '')
if (!username || !password) {
return NextResponse.json({ error: 'Username and password are required' }, { status: 400 })
}
// Seed admin on first login attempt if no users exist yet
await seedAdminIfEmpty()
const user = await authenticateUser(username, password)
if (!user) {
recordFailedAttempt(ip)
// Return generic error — don't reveal whether the username exists
return NextResponse.json({ error: 'Invalid username or password' }, { status: 401 })
}
clearAttempts(ip)
await createSession({ userId: user.id, username: user.username, role: user.role })
return NextResponse.json({ ok: true, username: user.username, role: user.role })
}