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 const DATA_DIR = path.join(process.cwd(), 'data') const USERS_FILE = path.join(DATA_DIR, 'users.json') async function readUsers(): Promise { try { const text = await fs.readFile(USERS_FILE, 'utf-8') return JSON.parse(text) as User[] } catch { return [] } } async function writeUsers(users: User[]): Promise { 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 { const users = await readUsers() return users.map(toPublic) } export async function findUserByUsername(username: string): Promise { const users = await readUsers() return users.find((u) => u.username.toLowerCase() === username.toLowerCase()) } export async function findUserById(id: string): Promise { const users = await readUsers() return users.find((u) => u.id === id) } export async function createUser( username: string, password: string, role: Role, ): Promise { 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 { 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 { 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 { 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 { 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 { 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) } }