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
+140
View File
@@ -0,0 +1,140 @@
import path from 'path'
import fs from 'fs/promises'
import crypto from 'crypto'
import { hashPassword, verifyPassword } from './auth'
export type Role = 'admin' | 'user'
export interface User {
id: string
username: string
passwordHash: string
salt: string
role: Role
createdAt: string
}
/** User fields safe to expose to the client (no credentials). */
export type PublicUser = Omit<User, 'passwordHash' | 'salt'>
const DATA_DIR = path.join(process.cwd(), 'data')
const USERS_FILE = path.join(DATA_DIR, 'users.json')
async function readUsers(): Promise<User[]> {
try {
const text = await fs.readFile(USERS_FILE, 'utf-8')
return JSON.parse(text) as User[]
} catch {
return []
}
}
async function writeUsers(users: User[]): Promise<void> {
await fs.mkdir(DATA_DIR, { recursive: true })
await fs.writeFile(USERS_FILE, JSON.stringify(users, null, 2), 'utf-8')
}
function toPublic(user: User): PublicUser {
const { passwordHash: _, salt: __, ...pub } = user
return pub
}
export async function listUsers(): Promise<PublicUser[]> {
const users = await readUsers()
return users.map(toPublic)
}
export async function findUserByUsername(username: string): Promise<User | undefined> {
const users = await readUsers()
return users.find((u) => u.username.toLowerCase() === username.toLowerCase())
}
export async function findUserById(id: string): Promise<User | undefined> {
const users = await readUsers()
return users.find((u) => u.id === id)
}
export async function createUser(
username: string,
password: string,
role: Role,
): Promise<PublicUser> {
if (!username.trim()) throw new Error('Username cannot be empty')
if (password.length < 8) throw new Error('Password must be at least 8 characters')
const users = await readUsers()
if (users.some((u) => u.username.toLowerCase() === username.toLowerCase())) {
throw new Error(`Username "${username}" is already taken`)
}
const { hash, salt } = await hashPassword(password)
const user: User = {
id: crypto.randomUUID(),
username: username.trim(),
passwordHash: hash,
salt,
role,
createdAt: new Date().toISOString(),
}
users.push(user)
await writeUsers(users)
return toPublic(user)
}
export async function deleteUser(id: string): Promise<void> {
const users = await readUsers()
const target = users.find((u) => u.id === id)
if (!target) throw new Error('User not found')
const remaining = users.filter((u) => u.id !== id)
const remainingAdmins = remaining.filter((u) => u.role === 'admin')
if (target.role === 'admin' && remainingAdmins.length === 0) {
throw new Error('Cannot delete the last admin account')
}
await writeUsers(remaining)
}
export async function updatePassword(id: string, newPassword: string): Promise<void> {
if (newPassword.length < 8) throw new Error('Password must be at least 8 characters')
const users = await readUsers()
const idx = users.findIndex((u) => u.id === id)
if (idx === -1) throw new Error('User not found')
const { hash, salt } = await hashPassword(newPassword)
users[idx].passwordHash = hash
users[idx].salt = salt
await writeUsers(users)
}
export async function authenticateUser(username: string, password: string): Promise<User | null> {
const user = await findUserByUsername(username)
if (!user) {
// Run a dummy derivation to keep constant time even when user not found
await hashPassword(password)
return null
}
const valid = await verifyPassword(password, user.passwordHash, user.salt)
return valid ? user : null
}
export async function countUsers(): Promise<number> {
const users = await readUsers()
return users.length
}
/**
* Seeds an initial admin user if no users exist yet.
* Reads ADMIN_USERNAME (default "admin") and ADMIN_PASSWORD from environment.
*/
export async function seedAdminIfEmpty(): Promise<void> {
const count = await countUsers()
if (count > 0) return
const password = process.env.ADMIN_PASSWORD
if (!password) return
const username = process.env.ADMIN_USERNAME ?? 'admin'
try {
await createUser(username, password, 'admin')
console.log(`[auth] Seeded initial admin user: "${username}"`)
} catch (e) {
console.error('[auth] Failed to seed admin user:', e)
}
}