mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +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:
@@ -89,6 +89,8 @@ COPY server/tsconfig.json ./server/
|
||||
# raw .ts source — it never enters dist, so it must be copied in explicitly or
|
||||
# `node --import tsx scripts/migrate-encryption.ts` fails with module-not-found.
|
||||
COPY server/scripts/migrate-encryption.ts ./server/scripts/migrate-encryption.ts
|
||||
# Admin recovery script (node server/reset-admin.js) for locked-out installs.
|
||||
COPY server/reset-admin.js ./server/reset-admin.js
|
||||
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||
COPY --from=client-builder /app/client/dist ./server/public
|
||||
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
|
||||
|
||||
+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');
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,46 @@ environment:
|
||||
|
||||
---
|
||||
|
||||
## Can't log in after setup / ADMIN_EMAIL and ADMIN_PASSWORD seem ignored
|
||||
|
||||
**Cause:** The initial admin account is seeded **only on the first boot, when the database has no users yet.** Three things follow from that, and each trips people up:
|
||||
|
||||
- `ADMIN_EMAIL` / `ADMIN_PASSWORD` apply **only on that first run**. If you first start *without* them, an admin is created with a **random** password (it is **not** `changeme`) — and adding the variables afterwards has no effect, because a user already exists. The server now logs a reminder when it ignores them.
|
||||
- The random first-run password is printed to the log **once**, in a box titled `TREK — First Run: Admin Account Created`. It is easy to miss if you read the logs later.
|
||||
- Pulling a "fresh image" does **not** reset anything — your `./data` volume still holds the old database, so first-run setup does not run again.
|
||||
|
||||
**Fix — pick whichever applies:**
|
||||
|
||||
**Read the first-run credentials** (only present on the very first start of an empty database):
|
||||
|
||||
```bash
|
||||
docker compose logs | grep -A6 "First Run"
|
||||
```
|
||||
|
||||
Log in with what it shows; you will be asked to set a new password.
|
||||
|
||||
**Reset the admin without losing data** (locked-out, existing install):
|
||||
|
||||
```bash
|
||||
docker exec -it trek node server/reset-admin.js
|
||||
```
|
||||
|
||||
This resets (or creates) `admin@trek.local` and prints a generated password. Override with `-e RESET_ADMIN_EMAIL=you@example.com -e RESET_ADMIN_PASSWORD=yourpass`. You will be asked to change it on first login.
|
||||
|
||||
**Start over with chosen credentials** (fresh install, no data to keep):
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
rm -rf ./data # deletes ALL TREK data — only on a throwaway/fresh install
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
With `ADMIN_EMAIL` and `ADMIN_PASSWORD` set, the admin is created with exactly those credentials.
|
||||
|
||||
> **Note (Docker Desktop on Windows/macOS):** SQLite's WAL mode is unreliable on bind mounts backed by the Windows/macOS filesystem and can cause silent write failures. Prefer a Docker **named volume** for `/app/data` over a host bind mount. See [Install: Docker Compose](Install-Docker-Compose#named-volumes).
|
||||
|
||||
---
|
||||
|
||||
## WebSocket not connecting / real-time sync broken
|
||||
|
||||
**Cause:** Your reverse proxy is not forwarding WebSocket upgrade headers on the `/ws` path.
|
||||
|
||||
Reference in New Issue
Block a user