import db from './db.js'; import config from './config.js'; import { hashPassword, randomPassword } from './auth.js'; import { nowIso } from './util.js'; const LOCKOUT_FAILS = 6; const LOCKOUT_MINUTES = 15; export function domainAllowed(email) { const allowed = config.admin.allowedDomains; if (!allowed.length) return true; const domain = String(email).split('@')[1]?.toLowerCase() || ''; return allowed.some((d) => domain === d || domain.endsWith(`.${d}`)); } export function domainRuleText() { const allowed = config.admin.allowedDomains; if (!allowed.length) return null; return allowed.map((d) => `@${d}`).join(' or '); } export function findByEmail(email) { return db .prepare('SELECT * FROM admin_users WHERE email = ?') .get(String(email).trim().toLowerCase()); } export function findById(id) { return db.prepare('SELECT * FROM admin_users WHERE id = ?').get(id); } export function countActive() { return db.prepare('SELECT COUNT(*) AS n FROM admin_users WHERE active = 1').get().n; } export function shape(user) { return { id: user.id, email: user.email, name: user.name, role: user.role, siteId: user.site_id, twoFactorOn: Boolean(user.totp_enabled), mustChangePassword: Boolean(user.must_change_password), active: Boolean(user.active), lastLoginAt: user.last_login_at, createdAt: user.created_at, }; } export function createUser({ email, name, password, role = 'admin', siteId = null, mustChange = true }) { const clean = String(email || '').trim().toLowerCase(); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(clean)) throw new Error('Enter a valid email address.'); if (!domainAllowed(clean)) { throw new Error(`Admin accounts must use an ${domainRuleText()} address.`); } if (findByEmail(clean)) throw new Error('An account already uses that email address.'); const temp = password || randomPassword(); const info = db .prepare( `INSERT INTO admin_users (email, name, password_hash, role, site_id, must_change_password) VALUES (?, ?, ?, ?, ?, ?)` ) .run(clean, String(name || '').trim() || null, hashPassword(temp), role, siteId, mustChange ? 1 : 0); return { user: findById(info.lastInsertRowid), temporaryPassword: password ? null : temp }; } /* ------------------------------------------------------------- lockout */ export function lockState(email) { const row = db.prepare('SELECT * FROM login_attempts WHERE email = ?').get(email); if (row?.locked_until && row.locked_until > nowIso()) return row; return null; } export function noteFailure(email) { const row = db.prepare('SELECT * FROM login_attempts WHERE email = ?').get(email); const fails = (row?.fails || 0) + 1; const lockedUntil = fails >= LOCKOUT_FAILS ? new Date(Date.now() + LOCKOUT_MINUTES * 60000).toISOString() : null; db.prepare( `INSERT INTO login_attempts (email, fails, locked_until) VALUES (?, ?, ?) ON CONFLICT(email) DO UPDATE SET fails = excluded.fails, locked_until = excluded.locked_until` ).run(email, fails, lockedUntil); return { fails, lockedUntil }; } export function clearFailures(email) { db.prepare('DELETE FROM login_attempts WHERE email = ?').run(email); } /* ----------------------------------------------------------- bootstrap */ /** Creates the very first admin account from the environment, once. */ export function bootstrap() { if (countActive() > 0) return; const { bootstrapEmail, bootstrapPassword } = config.admin; if (!bootstrapEmail || !bootstrapPassword) { console.warn( '[users] No admin accounts exist yet. Set ADMIN_BOOTSTRAP_EMAIL and ADMIN_BOOTSTRAP_PASSWORD\n' + ' in .env and restart to create the first one.' ); return; } try { createUser({ email: bootstrapEmail, name: 'First admin', password: bootstrapPassword, role: 'owner', mustChange: true, }); console.log(`[users] created the first admin account: ${bootstrapEmail}`); console.log('[users] you will be asked to set a new password at first sign in'); } catch (err) { console.error('[users] could not create the first admin account:', err.message); } }