Files
visitor-signin/src/users.js
T

122 lines
4.1 KiB
JavaScript

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);
}
}