diff --git a/Dockerfile b/Dockerfile index e6e8f1a2..4a657776 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/server/.env.example b/server/.env.example index 06921fbb..c7e06742 100644 --- a/server/.env.example +++ b/server/.env.example @@ -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 diff --git a/server/package.json b/server/package.json index 091da2bb..6830c785 100644 --- a/server/package.json +++ b/server/package.json @@ -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\"", diff --git a/server/reset-admin.js b/server/reset-admin.js index 41a2ccca..84df51e6 100644 --- a/server/reset-admin.js +++ b/server/reset-admin.js @@ -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(); diff --git a/server/src/db/seeds.ts b/server/src/db/seeds.ts index 76d40a75..7e388655 100644 --- a/server/src/db/seeds.ts +++ b/server/src/db/seeds.ts @@ -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 }; diff --git a/server/tests/unit/db/seeds-admin.test.ts b/server/tests/unit/db/seeds-admin.test.ts new file mode 100644 index 00000000..cd25e827 --- /dev/null +++ b/server/tests/unit/db/seeds-admin.test.ts @@ -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; + + 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'); + }); +}); diff --git a/wiki/Troubleshooting.md b/wiki/Troubleshooting.md index fa70fb39..0ea04faa 100644 --- a/wiki/Troubleshooting.md +++ b/wiki/Troubleshooting.md @@ -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.