mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-30 18:46:00 +00:00
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:
+6
-4
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user