fix(setup): warn when ADMIN_EMAIL/ADMIN_PASSWORD are ignored, ship reset-admin

The first-run seeder only applies ADMIN_EMAIL/ADMIN_PASSWORD on an empty
database and then silently ignores them. People add the vars after the first
boot, or pull a fresh image without clearing ./data, restart, and cannot log
in with no hint why (#1339). The default is a generated password (not the
.env.example placeholder), printed once in the first-run box. Now: warn loudly
when the vars are set but a user already exists, and warn on a partial
(one-of-two) config instead of quietly falling back.

Also ship the reset-admin recovery script in the image -- it was never COPYed in
despite the wiki referencing it. node server/reset-admin.js resets/creates
admin@trek.local with a generated password (RESET_ADMIN_EMAIL/RESET_ADMIN_PASSWORD
overridable), picks a free username so it cannot trip UNIQUE(username), and sets
must_change_password.
This commit is contained in:
Maurice
2026-06-28 10:50:36 +02:00
committed by Maurice
parent 37f1fff367
commit 41c541828f
7 changed files with 211 additions and 20 deletions
+6 -4
View File
@@ -43,8 +43,10 @@ DEMO_MODE=false # Demo mode - resets data hourly
# OVERPASS_URL= # Custom Overpass endpoint(s) for the map POI "explore" search, comma-separated. When set, REPLACES the bundled public mirrors — point it at an internal/self-hosted Overpass instance when the public ones are unreachable from your network. Non-http(s) entries are ignored. If you don't self-host Overpass but the public mirrors throttle you, setting APP_URL also gives outbound requests a unique User-Agent the mirrors rate-limit less.
# OVERPASS_TIMEOUT_MS=12000 # Per-endpoint timeout (ms) for Overpass POI requests; slower endpoints are abandoned so a faster mirror wins. Raise it for a slow self-hosted instance. (default: 12000)
# Initial admin account — only used on first boot when no users exist yet.
# If both are set the admin account is created with these credentials.
# If either is omitted a random password is generated and printed to the server log.
# Initial admin account — ONLY applied on the first boot, when the database has no
# users yet. Adding these later has no effect (the server logs a reminder if you do);
# to change an existing password sign in and use Settings, or reset the admin account.
# Both must be set together. If either is omitted, a random password is generated and
# printed to the server log under "First Run: Admin Account Created" — watch for it.
# ADMIN_EMAIL=admin@trek.local
# ADMIN_PASSWORD=changeme
# ADMIN_PASSWORD=change-me-before-first-boot
+1
View File
@@ -7,6 +7,7 @@
"dev": "node scripts/dev.mjs",
"build": "node scripts/build.mjs",
"start:prod": "node --require tsconfig-paths/register dist/index.js",
"reset-admin": "node reset-admin.js",
"typecheck": "tsc --noEmit",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"",
+38 -9
View File
@@ -1,21 +1,50 @@
/**
* Admin recovery — reset (or create) an admin account when you are locked out.
*
* Usage inside the container:
* docker exec -it trek node server/reset-admin.js
* docker exec -it -e RESET_ADMIN_EMAIL=me@example.com -e RESET_ADMIN_PASSWORD=secret trek node server/reset-admin.js
*
* Defaults to admin@trek.local with a generated password (printed below). The
* account is flagged must_change_password, so you are prompted to set a new one
* on first login. Honours TREK_DB_FILE the same way the server does.
*/
const path = require('path');
const crypto = require('crypto');
const Database = require('better-sqlite3');
const bcrypt = require('bcryptjs');
const dbPath = path.join(__dirname, 'data/travel.db');
// Kept in sync with the seeder/authService cost factor.
const BCRYPT_COST = 12;
const email = process.env.RESET_ADMIN_EMAIL || 'admin@trek.local';
const password = process.env.RESET_ADMIN_PASSWORD || crypto.randomBytes(12).toString('base64url');
const generated = !process.env.RESET_ADMIN_PASSWORD;
const dbPath = process.env.TREK_DB_FILE || path.join(__dirname, 'data/travel.db');
const db = new Database(dbPath);
const hash = bcrypt.hashSync('admin123', 10);
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get('admin@admin.com');
const hash = bcrypt.hashSync(password, BCRYPT_COST);
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
if (existing) {
db.prepare('UPDATE users SET password_hash = ?, role = ? WHERE email = ?')
.run(hash, 'admin', 'admin@admin.com');
console.log('✓ Admin-Passwort zurückgesetzt: admin@admin.com / admin123');
db.prepare('UPDATE users SET password_hash = ?, role = ?, must_change_password = 1 WHERE email = ?')
.run(hash, 'admin', email);
console.log(`\n✓ Admin password reset: ${email}`);
} else {
db.prepare('INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)')
.run('admin', 'admin@admin.com', hash, 'admin');
console.log('✓ Admin-User erstellt: admin@admin.com / admin123');
// 'admin' is usually taken by the first-run seed — pick the first free username
// so the insert can't trip the UNIQUE(username) constraint.
let username = 'admin';
let n = 1;
while (db.prepare('SELECT 1 FROM users WHERE username = ?').get(username)) {
username = `admin${n++}`;
}
db.prepare('INSERT INTO users (username, email, password_hash, role, must_change_password) VALUES (?, ?, ?, ?, 1)')
.run(username, email, hash, 'admin');
console.log(`\n✓ Admin account created: ${email} (username: ${username})`);
}
if (generated) console.log(` Password: ${password}`);
console.log(' You will be asked to change the password on first login.\n');
db.close();
+23 -7
View File
@@ -15,8 +15,21 @@ function isOidcOnlyConfigured(): boolean {
function seedAdminAccount(db: Database.Database): void {
try {
const env_admin_email = process.env.ADMIN_EMAIL;
const env_admin_pw = process.env.ADMIN_PASSWORD;
const adminEnvProvided = !!(env_admin_email || env_admin_pw);
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
if (userCount > 0) return;
if (userCount > 0) {
// ADMIN_EMAIL/ADMIN_PASSWORD only take effect on the first run (empty database). Once a
// user exists they are silently ignored — a common trip-up: people add the vars after the
// fact, restart, nothing changes, and there is no hint why. Say so instead of staying silent.
if (adminEnvProvided) {
console.warn('[admin] ADMIN_EMAIL/ADMIN_PASSWORD are set, but users already exist — these only apply on first run (empty database) and are being ignored.');
console.warn('[admin] Change an existing password from Settings after signing in, reset the admin (see the Troubleshooting wiki), or start with an empty data volume to re-run setup.');
}
return;
}
// Demo mode seeds its own admin (admin@trek.app, username 'admin') right after this.
// Creating a first-run admin here would grab username 'admin' first and make the demo
@@ -35,15 +48,18 @@ function seedAdminAccount(db: Database.Database): void {
const bcrypt = require('bcryptjs');
const env_admin_email = process.env.ADMIN_EMAIL;
const env_admin_pw = process.env.ADMIN_PASSWORD;
let password;
let email;
let password: string;
let email: string;
if (env_admin_email && env_admin_pw) {
password = env_admin_pw;
email = env_admin_email;
} else {
// A partial config (only one of the two) is an easy mistake: neither value is used and a
// generated password is created instead. Flag it so the chosen credentials silently not
// working isn't a surprise.
if (adminEnvProvided) {
console.warn('[admin] Only one of ADMIN_EMAIL/ADMIN_PASSWORD is set — both are required for a custom admin. Falling back to admin@trek.local with a generated password (shown below).');
}
password = crypto.randomBytes(12).toString('base64url');
email = 'admin@trek.local';
}
@@ -154,4 +170,4 @@ function runSeeds(db: Database.Database): void {
seedAddons(db);
}
export { runSeeds };
export { runSeeds, seedAdminAccount };
+101
View File
@@ -0,0 +1,101 @@
/**
* First-run admin seeding (seedAdminAccount).
*
* Covers the #1339 fix: ADMIN_EMAIL/ADMIN_PASSWORD only take effect on first run
* (empty database). Setting them once a user exists must no longer be silent — it
* has to warn — and a partial config (only one of the two) must warn too instead
* of quietly falling back to a generated password.
*/
import { seedAdminAccount } from '../../../src/db/seeds';
import { createTestDb } from '../../helpers/test-db';
import type Database from 'better-sqlite3';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
const ENV_KEYS = ['ADMIN_EMAIL', 'ADMIN_PASSWORD', 'DEMO_MODE', 'OIDC_ONLY', 'OIDC_ISSUER', 'OIDC_CLIENT_ID'];
function countUsers(db: Database.Database): number {
return (db.prepare('SELECT COUNT(*) as c FROM users').get() as { c: number }).c;
}
function insertExistingUser(db: Database.Database): void {
db.prepare(
"INSERT INTO users (username, email, password_hash, role) VALUES ('admin', 'admin@trek.local', 'x', 'admin')",
).run();
}
describe('seedAdminAccount — first-run admin', () => {
let db: Database.Database;
let saved: Record<string, string | undefined>;
beforeEach(() => {
db = createTestDb();
saved = {};
for (const k of ENV_KEYS) {
saved[k] = process.env[k];
delete process.env[k];
}
});
afterEach(() => {
db.close();
for (const k of ENV_KEYS) {
if (saved[k] === undefined) delete process.env[k];
else process.env[k] = saved[k];
}
vi.restoreAllMocks();
});
it('creates the admin from ADMIN_EMAIL/ADMIN_PASSWORD on an empty database', () => {
process.env.ADMIN_EMAIL = 'me@example.com';
process.env.ADMIN_PASSWORD = 'S3cret-pw';
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
seedAdminAccount(db);
const user = db
.prepare('SELECT email, role, must_change_password FROM users WHERE email = ?')
.get('me@example.com') as { email: string; role: string; must_change_password: number } | undefined;
expect(user).toBeDefined();
expect(user!.role).toBe('admin');
expect(user!.must_change_password).toBe(1);
expect(warn).not.toHaveBeenCalled();
});
it('warns and creates nothing when ADMIN_* is set but a user already exists', () => {
insertExistingUser(db);
process.env.ADMIN_EMAIL = 'new@example.com';
process.env.ADMIN_PASSWORD = 'whatever';
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
seedAdminAccount(db);
expect(countUsers(db)).toBe(1);
expect(db.prepare('SELECT 1 FROM users WHERE email = ?').get('new@example.com')).toBeUndefined();
const msg = warn.mock.calls.map((c) => c.join(' ')).join('\n');
expect(msg).toContain('only apply on first run');
});
it('stays silent when no admin env is set and a user already exists', () => {
insertExistingUser(db);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
seedAdminAccount(db);
expect(countUsers(db)).toBe(1);
expect(warn).not.toHaveBeenCalled();
});
it('warns about a partial config and falls back to a generated password', () => {
process.env.ADMIN_EMAIL = 'me@example.com'; // ADMIN_PASSWORD intentionally missing
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
seedAdminAccount(db);
// Falls back to the default local admin, NOT the provided email.
expect(db.prepare('SELECT 1 FROM users WHERE email = ?').get('admin@trek.local')).toBeDefined();
expect(db.prepare('SELECT 1 FROM users WHERE email = ?').get('me@example.com')).toBeUndefined();
const msg = warn.mock.calls.map((c) => c.join(' ')).join('\n');
expect(msg).toContain('Only one of ADMIN_EMAIL/ADMIN_PASSWORD');
});
});