Merge pull request #714 from Luca-Timo/feat/first-run-setup-wizard

feat: zero-config first run — in-browser admin bootstrap + auto-generated secrets
This commit is contained in:
Paul Nothaft
2026-07-02 16:19:33 +02:00
committed by GitHub
18 changed files with 1175 additions and 32 deletions
+20 -8
View File
@@ -4,8 +4,11 @@
# Environment # Environment
NODE_ENV=production NODE_ENV=production
# JWT Secret (generate with: openssl rand -base64 64) # JWT Secret — OPTIONAL. Leave unset and it is auto-generated on first run
JWT_SECRET=your_very_long_random_jwt_secret_here # (Docker: the secrets-init service writes it to a private volume and reuses it
# across restarts). Set it explicitly only to pin your own value.
# Generate one with: openssl rand -base64 64
#JWT_SECRET=your_very_long_random_jwt_secret_here
# Auth cookie Secure flag # Auth cookie Secure flag
# unset - default: follows NODE_ENV (production=true, dev=false) # unset - default: follows NODE_ENV (production=true, dev=false)
@@ -38,19 +41,28 @@ JWT_SECRET=your_very_long_random_jwt_secret_here
# Database Configuration (PostgreSQL) # Database Configuration (PostgreSQL)
DATABASE_CLIENT=pg DATABASE_CLIENT=pg
DB_USER=picpeak DB_USER=picpeak
# DB_PASSWORD — OPTIONAL. Leave unset and it is auto-generated on first run
# (Docker). Set it explicitly to pin your own, e.g. for an external database.
# IMPORTANT: Avoid $ character in passwords - Docker Compose interprets it as variable substitution # IMPORTANT: Avoid $ character in passwords - Docker Compose interprets it as variable substitution
# If you must use $, escape it as $$ (e.g., Pass$$word instead of Pass$word) # If you must use $, escape it as $$ (e.g., Pass$$word instead of Pass$word)
DB_PASSWORD=your_secure_postgres_password_here #DB_PASSWORD=your_secure_postgres_password_here
DB_NAME=picpeak_prod DB_NAME=picpeak_prod
# Redis Configuration # Redis Configuration
# REDIS_PASSWORD — OPTIONAL. Leave unset and it is auto-generated on first run (Docker).
# IMPORTANT: Same warning applies - avoid $ or escape as $$ # IMPORTANT: Same warning applies - avoid $ or escape as $$
REDIS_PASSWORD=your_secure_redis_password_here #REDIS_PASSWORD=your_secure_redis_password_here
# Admin Account (initial setup) # Admin Account (initial setup) — OPTIONAL
ADMIN_USERNAME=admin # Leave these unset (default) to create your admin IN THE BROWSER on first run:
ADMIN_EMAIL=[email protected] # open /admin and PicPeak shows a setup screen. The one-time setup token is
ADMIN_PASSWORD=your_secure_admin_password_here # printed to the backend logs (`docker compose logs backend | grep -i "setup token"`)
# and saved to data/SETUP_TOKEN.
# Set ADMIN_PASSWORD to auto-create the admin on first boot instead (legacy;
# credentials written to data/ADMIN_CREDENTIALS.txt).
#ADMIN_USERNAME=admin
#[email protected]
#ADMIN_PASSWORD=your_secure_admin_password_here
# Email Configuration # Email Configuration
# For Gmail: use app-specific password # For Gmail: use app-specific password
+17 -4
View File
@@ -94,18 +94,31 @@ Get PicPeak running in under 5 minutes:
git clone https://github.com/PicPeak/picpeak.git git clone https://github.com/PicPeak/picpeak.git
cd picpeak cd picpeak
# Copy environment template # Copy the environment template — the defaults work out of the box.
# Machine secrets (JWT, DB, Redis) are auto-generated on first run, and the
# admin account is created in the browser (see below). Edit .env only to
# customise (domain, SMTP, storage paths, …) — nothing is required.
cp .env.example .env cp .env.example .env
# Edit configuration (required: JWT_SECRET)
nano .env
# Start with Docker Compose # Start with Docker Compose
docker compose up -d docker compose up -d
# Access at http://localhost:3000 # Access at http://localhost:3000
``` ```
### First run — create your admin account
On first start with no `ADMIN_PASSWORD` set, PicPeak has **no admin account yet** and greets you with an in-browser setup screen — no credentials in `.env`:
1. Open **http://localhost:3000/admin** — you'll be redirected to `/setup`.
2. Grab the **one-time setup token** from the backend logs (it's also saved to `data/SETUP_TOKEN`):
```bash
docker compose logs backend | grep -i "setup token"
```
3. Paste the token, set your admin **email + password**, and you're in. The token is single-use, and the setup screen closes permanently once an admin exists.
> Prefer the old behaviour? Set `ADMIN_PASSWORD` in `.env` and PicPeak auto-creates the admin on first boot instead (credentials written to `data/ADMIN_CREDENTIALS.txt`).
Note on Docker file permissions Note on Docker file permissions
- The backend container starts as root, chowns bind-mounted host directories (`./storage`, `./data`, `./logs`) to UID 1001 (`nodejs`), then drops privileges via `su-exec` before running the app. No host-side setup needed for fresh installs. - The backend container starts as root, chowns bind-mounted host directories (`./storage`, `./data`, `./logs`) to UID 1001 (`nodejs`), then drops privileges via `su-exec` before running the app. No host-side setup needed for fresh installs.
- If you pin `user:` in a compose override (e.g. to map a specific host UID), the self-chown is skipped and you must pre-chown the host directories to that UID — see [docs.picpeak.app/deployment/docker#permissions](https://docs.picpeak.app/deployment/docker#permissions). - If you pin `user:` in a compose override (e.g. to map a specific host UID), the self-chown is skipped and you must pre-chown the host directories to that UID — see [docs.picpeak.app/deployment/docker#permissions](https://docs.picpeak.app/deployment/docker#permissions).
+13
View File
@@ -163,6 +163,19 @@ sudo ./picpeak-setup.sh --native --unattended \
- `picpeak-workers` - Background workers - `picpeak-workers` - Background workers
- `caddy` - Web server (optional) - `caddy` - Web server (optional)
## 🔑 First Login — Create Your Admin
If you installed with `picpeak-setup.sh` and gave an `--admin-password`, your admin account already exists — log in at `/admin` with that email and password.
If you started PicPeak **without** setting `ADMIN_PASSWORD` (e.g. a plain `docker compose up`), there's **no admin yet** and you create it in the browser:
1. Open `http://your-server:3000/admin` — you'll land on a setup screen.
2. Get the **one-time setup token** from the backend logs (also saved to `data/SETUP_TOKEN`):
```bash
docker compose logs backend | grep -i "setup token"
```
3. Paste it, set your admin email + password. The token is single-use and the screen closes once an admin exists.
## 🌐 Access Methods ## 🌐 Access Methods
### Direct Access (Simplest) ### Direct Access (Simplest)
@@ -0,0 +1,196 @@
'use strict';
// First-run bootstrap service. bootCrmDb() must run BEFORE requiring the service
// so setupService shares this test's db instance (see crmDb.js note).
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-characters-long!!';
const fs = require('fs');
const path = require('path');
const request = require('supertest');
const { bootCrmDb, buildRouteApp } = require('./helpers/crmDb');
let db;
let cleanup;
let tmpDir;
let setupService;
let getAppSetting;
let upsertAppSetting;
let app;
const VALID_PW = 'Str0ng-Passw0rd!';
// bootCrmDb MUST run before any require of db.js (directly or transitively via a
// service/util), or db.js binds to the default path instead of the temp one.
beforeAll(async () => {
({ db, cleanup, tmpDir } = await bootCrmDb());
process.env.DATA_DIR = tmpDir; // isolate the SETUP_TOKEN file to the temp dir
setupService = require('../../src/services/setupService');
({ getAppSetting, upsertAppSetting } = require('../../src/utils/appSettings'));
app = buildRouteApp('/api/setup', require('../../src/routes/setup'));
}, 60000);
afterAll(async () => {
await cleanup();
});
beforeEach(async () => {
await db('admin_users').del();
await db('app_settings').where({ setting_key: 'setup_token' }).del();
});
describe('setupService (first-run bootstrap)', () => {
it('reports needsAdmin while no admin exists', async () => {
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: true, complete: false });
});
it('generates and persists a one-time token while no admin exists', async () => {
const token = await setupService.ensureSetupToken();
expect(token).toEqual(expect.any(String));
expect(token.length).toBeGreaterThan(20);
expect(await getAppSetting('setup_token')).toBe(token);
// Idempotent — a second call returns the same token, not a fresh one.
expect(await setupService.ensureSetupToken()).toBe(token);
});
it('stores the token as valid JSON so the Postgres jsonb column accepts it', async () => {
// Regression guard for the SQLite-only miss: a bare token string is rejected
// by Postgres jsonb ("invalid input syntax for type json"). The raw column
// value must be JSON-parseable and round-trip back to the token.
const token = await setupService.ensureSetupToken();
const row = await db('app_settings').where({ setting_key: 'setup_token' }).first();
expect(() => JSON.parse(row.setting_value)).not.toThrow();
expect(JSON.parse(row.setting_value)).toBe(token);
});
it('rejects a wrong token', async () => {
await setupService.ensureSetupToken();
await expect(
setupService.createInitialAdmin({ token: 'nope', email: '[email protected]', password: VALID_PW })
).rejects.toMatchObject({ statusCode: 400 });
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: true, complete: false });
});
it('rejects a weak password', async () => {
const token = await setupService.ensureSetupToken();
await expect(
setupService.createInitialAdmin({ token, email: '[email protected]', password: 'weak' })
).rejects.toMatchObject({ statusCode: 400 });
});
it('creates the first admin as super_admin, issues a token, and burns the setup token', async () => {
const token = await setupService.ensureSetupToken();
const result = await setupService.createInitialAdmin({
token, email: '[email protected]', password: VALID_PW, ip: '203.0.113.7',
});
expect(result.user.email).toBe('[email protected]'); // normalised
expect(result.user.role.name).toBe('super_admin');
expect(result.token).toEqual(expect.any(String));
const row = await db('admin_users').first();
const role = await db('roles').where({ name: 'super_admin' }).first();
expect(row.role_id).toBe(role.id);
expect(row.password_hash).not.toBe(VALID_PW); // hashed
// One-time: token burned, status now complete.
expect(await getAppSetting('setup_token')).toBeFalsy();
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: false, complete: true });
});
it('writes the SETUP_TOKEN file while pending and removes it once setup completes', async () => {
const tokenFile = path.join(tmpDir, 'SETUP_TOKEN');
const token = await setupService.ensureSetupToken();
expect(fs.readFileSync(tokenFile, 'utf8').trim()).toBe(token);
await setupService.createInitialAdmin({ token, email: '[email protected]', password: VALID_PW });
expect(fs.existsSync(tokenFile)).toBe(false); // burned in DB + file removed
});
it('refuses to create a second admin (setup already complete)', async () => {
const token = await setupService.ensureSetupToken();
await setupService.createInitialAdmin({ token, email: '[email protected]', password: VALID_PW });
await expect(
setupService.createInitialAdmin({ token, email: '[email protected]', password: VALID_PW })
).rejects.toMatchObject({ statusCode: 409 });
});
it('serialises a double-submit — two concurrent valid-token calls create only one admin', async () => {
const token = await setupService.ensureSetupToken();
const results = await Promise.allSettled([
setupService.createInitialAdmin({ token, email: '[email protected]', password: VALID_PW }),
setupService.createInitialAdmin({ token, email: '[email protected]', password: VALID_PW }),
]);
const fulfilled = results.filter((r) => r.status === 'fulfilled');
expect(fulfilled).toHaveLength(1); // the atomic token claim lets exactly one win
const count = await db('admin_users').count({ c: '*' }).first();
expect(Number(count.c)).toBe(1);
});
it('ensureSetupToken clears any stale token once an admin exists', async () => {
const token = await setupService.ensureSetupToken();
await setupService.createInitialAdmin({ token, email: '[email protected]', password: VALID_PW });
// Simulate a stale token left in settings, then re-run the boot hook.
await upsertAppSetting('setup_token', JSON.stringify('stale'), 'string');
expect(await setupService.ensureSetupToken()).toBeNull();
expect(await getAppSetting('setup_token')).toBeFalsy();
});
});
describe('setup routes', () => {
it('GET /api/setup/status reports needsAdmin', async () => {
const res = await request(app).get('/api/setup/status');
expect(res.status).toBe(200);
expect(res.body).toEqual({ needsAdmin: true, complete: false });
});
it('POST /api/setup/verify-token accepts the right token without burning it (200)', async () => {
const token = await setupService.ensureSetupToken();
const res = await request(app).post('/api/setup/verify-token').send({ token });
expect(res.status).toBe(200);
expect(res.body).toEqual({ valid: true });
// Token is NOT consumed — it still works for the actual create.
expect(await getAppSetting('setup_token')).toBe(token);
});
it('POST /api/setup/verify-token rejects a wrong token (400, field token)', async () => {
await setupService.ensureSetupToken();
const res = await request(app).post('/api/setup/verify-token').send({ token: 'nope' });
expect(res.status).toBe(400);
expect(res.body.field).toBe('token');
});
it('POST /api/setup/verify-token is closed once an admin exists (409)', async () => {
const token = await setupService.ensureSetupToken();
await setupService.createInitialAdmin({ token, email: '[email protected]', password: VALID_PW });
const res = await request(app).post('/api/setup/verify-token').send({ token });
expect(res.status).toBe(409);
});
it('POST /api/setup/admin rejects a wrong token (400)', async () => {
await setupService.ensureSetupToken();
const res = await request(app)
.post('/api/setup/admin')
.send({ token: 'nope', email: '[email protected]', password: VALID_PW });
expect(res.status).toBe(400);
expect(await setupService.getSetupStatus()).toMatchObject({ needsAdmin: true });
});
it('POST /api/setup/admin creates the first admin + sets the auth cookie (201)', async () => {
const token = await setupService.ensureSetupToken();
const res = await request(app)
.post('/api/setup/admin')
.send({ token, email: '[email protected]', password: VALID_PW });
expect(res.status).toBe(201);
expect(res.body.user.role.name).toBe('super_admin');
expect((res.headers['set-cookie'] || []).join(';')).toMatch(/admin_token/);
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: false, complete: true });
});
it('POST /api/setup/admin is closed once an admin exists (409)', async () => {
const token = await setupService.ensureSetupToken();
await setupService.createInitialAdmin({ token, email: '[email protected]', password: VALID_PW });
const res = await request(app)
.post('/api/setup/admin')
.send({ token, email: '[email protected]', password: VALID_PW });
expect(res.status).toBe(409);
});
});
+9 -2
View File
@@ -11,9 +11,16 @@ exports.up = async function(knex) {
// Initialize tables // Initialize tables
await initializeDatabase(); await initializeDatabase();
// Create default admin user if none exists // Create default admin user if none exists.
//
// Legacy path — only when ADMIN_PASSWORD is explicitly provided (keeps
// existing docker-compose installs working unchanged). When it is NOT set,
// we deliberately leave admin_users empty so the first-run setup wizard
// (setupService / /setup) creates the admin in-browser — no ADMIN_PASSWORD
// in .env. Existing deployments already ran this migration, so this only
// affects fresh installs.
const adminExists = await knex('admin_users').first(); const adminExists = await knex('admin_users').first();
if (!adminExists) { if (!adminExists && process.env.ADMIN_PASSWORD) {
// Use ADMIN_PASSWORD from environment if set, otherwise generate a random one // Use ADMIN_PASSWORD from environment if set, otherwise generate a random one
const generatedPassword = process.env.ADMIN_PASSWORD || generateReadablePassword(); const generatedPassword = process.env.ADMIN_PASSWORD || generateReadablePassword();
const passwordHash = await bcrypt.hash(generatedPassword, 12); // Increased rounds for better security const passwordHash = await bcrypt.hash(generatedPassword, 12); // Increased rounds for better security
+21
View File
@@ -43,6 +43,7 @@ const galleryRoutes = require('./src/routes/gallery');
const adminRoutes = require('./src/routes/admin'); const adminRoutes = require('./src/routes/admin');
const adminAuthRoutes = require('./src/routes/adminAuth'); const adminAuthRoutes = require('./src/routes/adminAuth');
const secureImagesRoutes = require('./src/routes/secureImages'); const secureImagesRoutes = require('./src/routes/secureImages');
const setupRoutes = require('./src/routes/setup');
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
@@ -397,6 +398,8 @@ async function initializeRateLimiters() {
app.use('/api/auth', authRateLimiter); app.use('/api/auth', authRateLimiter);
app.use('/api/gallery/:slug/verify', authRateLimiter); app.use('/api/gallery/:slug/verify', authRateLimiter);
app.use('/api/admin/auth/login', authRateLimiter); app.use('/api/admin/auth/login', authRateLimiter);
app.use('/api/setup/admin', authRateLimiter);
app.use('/api/setup/verify-token', authRateLimiter);
} }
// Note: Rate limiters will be initialized after database connection // Note: Rate limiters will be initialized after database connection
@@ -690,6 +693,7 @@ app.get('/health', async (req, res) => {
}); });
// Routes // Routes
app.use('/api/setup', setupRoutes); // public first-run bootstrap (self-closes after setup)
app.use('/api/auth', authRoutes); app.use('/api/auth', authRoutes);
app.use('/api/events', eventRoutes); app.use('/api/events', eventRoutes);
app.use('/api/admin/external-media', require('./src/routes/adminExternalMedia')); app.use('/api/admin/external-media', require('./src/routes/adminExternalMedia'));
@@ -999,6 +1003,16 @@ async function startServer() {
logger.warn('Install-from-backup hook threw:', err.message); logger.warn('Install-from-backup hook threw:', err.message);
} }
// First-run: surface a one-time setup token while no admin account exists.
// Runs AFTER install-from-backup so a restored instance (which repopulates
// admin_users) never prints a throwaway token. Best-effort — never blocks boot.
let setupToken = null;
try {
setupToken = await require('./src/services/setupService').ensureSetupToken();
} catch (err) {
logger.warn(`[setup] ensureSetupToken skipped: ${err.message}`);
}
// Start backup service // Start backup service
await startBackupService(); await startBackupService();
@@ -1014,6 +1028,13 @@ async function startServer() {
logger.info(`Server running on port ${PORT}`); logger.info(`Server running on port ${PORT}`);
logger.info(`Admin interface: ${process.env.ADMIN_URL || 'http://localhost:3000'}`); logger.info(`Admin interface: ${process.env.ADMIN_URL || 'http://localhost:3000'}`);
logger.info(`Frontend: ${process.env.FRONTEND_URL || 'http://localhost:3001'}`); logger.info(`Frontend: ${process.env.FRONTEND_URL || 'http://localhost:3001'}`);
// First-run: print the one-time setup token to STDOUT (the file logger
// doesn't reach `docker logs`), as the last + most visible thing at boot.
if (setupToken) {
const url = `${process.env.ADMIN_URL || 'http://localhost:3000'}/admin`;
const line = '='.repeat(64);
console.log(`\n${line}\n PicPeak first-run setup — no admin account yet.\n Open: ${url}\n One-time setup token: ${setupToken}\n (also saved to data/SETUP_TOKEN)\n${line}\n`);
}
}); });
} catch (error) { } catch (error) {
logger.error('Failed to start server:', error); logger.error('Failed to start server:', error);
+82
View File
@@ -0,0 +1,82 @@
'use strict';
// Public first-run setup endpoints. UNAUTHENTICATED by design — they exist so a
// fresh instance can create its first admin from the browser (no ADMIN_PASSWORD
// in .env). Both are hard-gated on "no admin exists yet", and POST /admin also
// requires the one-time setup token, so they self-close after setup. The POST is
// rate-limited at the mount point in server.js (authRateLimiter).
const express = require('express');
const { body, validationResult } = require('express-validator');
const setupService = require('../services/setupService');
const { getClientIp } = require('../utils/requestIp');
const { setAdminAuthCookie } = require('../utils/tokenUtils');
const logger = require('../utils/logger');
const router = express.Router();
router.get('/status', async (req, res) => {
try {
res.json(await setupService.getSetupStatus());
} catch (err) {
logger.error('[setup] status failed', { error: err.message });
res.status(500).json({ error: 'Failed to read setup status' });
}
});
// Step-1 pre-flight: validate the setup token without consuming it, so the
// two-step wizard can block "Continue" on a wrong token. Rate-limited at the
// mount point in server.js (authRateLimiter), same as POST /admin.
router.post('/verify-token', [
body('token').notEmpty().withMessage('Setup token is required'),
], async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
try {
const valid = await setupService.verifySetupToken(req.body.token);
if (!valid) {
return res.status(400).json({ error: 'Invalid setup token', field: 'token' });
}
return res.json({ valid: true });
} catch (err) {
if (err.statusCode) {
return res.status(err.statusCode).json({ error: err.message, field: err.details || undefined });
}
logger.error('[setup] verifyToken failed', { error: err.message });
return res.status(500).json({ error: 'Setup failed' });
}
});
router.post('/admin', [
body('token').notEmpty().withMessage('Setup token is required'),
body('email').isEmail().withMessage('A valid email is required'),
body('password').notEmpty().withMessage('Password is required'),
], async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
try {
const { token, email, password } = req.body;
const result = await setupService.createInitialAdmin({
token,
email,
password,
ip: getClientIp(req),
});
setAdminAuthCookie(res, result.token);
// Token delivered via HttpOnly cookie only (mirrors admin login).
res.status(201).json({ user: result.user });
} catch (err) {
if (err.statusCode) {
// `field` (token/email/password) lets the client show a translated
// message instead of rendering the raw English error verbatim.
return res.status(err.statusCode).json({ error: err.message, field: err.details || undefined });
}
logger.error('[setup] createInitialAdmin failed', { error: err.message });
return res.status(500).json({ error: 'Setup failed' });
}
});
module.exports = router;
+176
View File
@@ -0,0 +1,176 @@
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const logger = require('../utils/logger');
const { getAppSetting, upsertAppSetting } = require('../utils/appSettings');
const { ValidationError, ConflictError } = require('../utils/errors');
const { validatePassword, getBcryptRounds } = require('../utils/passwordValidation');
const { formatBoolean } = require('../utils/dbCompat');
// First-run bootstrap. The app boots with NO admin account and no
// ADMIN_PASSWORD in the environment; the first browser visit creates the admin.
// That create call is guarded by a one-time setup token, generated at boot
// while no admin exists and printed to the logs (+ a best-effort data/SETUP_TOKEN
// file). The token is ALWAYS required and burned on first use, so the endpoint
// is permanently closed once setup is done — safe even on a public IP.
const SETUP_TOKEN_KEY = 'setup_token';
async function noAdminExists() {
const row = await db('admin_users').count({ c: '*' }).first();
return Number(row?.c || 0) === 0;
}
// Public status the /setup gate reads. Deliberately leaks nothing beyond
// "is the instance still waiting for its first admin".
async function getSetupStatus() {
const needsAdmin = await noAdminExists();
return { needsAdmin, complete: !needsAdmin };
}
// Logs are the source of truth; the file is a convenience for operators who
// reach a shell more easily than the container log view (e.g. `cat data/SETUP_TOKEN`).
function setupTokenFilePath() {
const dir = process.env.DATA_DIR || path.join(__dirname, '..', '..', 'data');
return path.join(dir, 'SETUP_TOKEN');
}
// Called once at startup. Idempotent: generates + surfaces a token only while
// the instance still needs an admin, and clears any stale token afterwards.
// Clear the token everywhere — the app_settings row AND the on-disk file — so a
// completed (or restored) install leaves no stale token behind.
async function clearSetupToken() {
await upsertAppSetting(SETUP_TOKEN_KEY, null, 'string');
try { fs.unlinkSync(setupTokenFilePath()); } catch (_) { /* file may be absent — best-effort */ }
}
async function ensureSetupToken() {
if (!(await noAdminExists())) {
await clearSetupToken();
return null;
}
let token = await getAppSetting(SETUP_TOKEN_KEY);
if (!token) {
token = crypto.randomBytes(24).toString('base64url');
// app_settings.setting_value is JSON on Postgres — store JSON-stringified
// (getAppSetting JSON.parses on read). A raw string is rejected by jsonb.
await upsertAppSetting(SETUP_TOKEN_KEY, JSON.stringify(token), 'string');
}
logger.warn(`[setup] No admin account yet — open /admin to finish setup. One-time setup token: ${token}`);
try {
const file = setupTokenFilePath();
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `${token}\n`, { mode: 0o600 });
} catch (err) {
logger.warn(`[setup] Could not write setup token file (logs still have it): ${err.message}`);
}
return token;
}
// Constant-time compare so the token can't be recovered by timing the response.
function tokensMatch(provided, expected) {
if (!provided || !expected) return false;
const a = Buffer.from(String(provided));
const b = Buffer.from(String(expected));
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
// Pre-flight check for the two-step wizard: lets step 1 confirm the token is
// valid before advancing to the account step, so a wrong token is caught at
// "Continue" rather than after the user has filled in email + password. Does
// NOT burn the token — createInitialAdmin still claims it atomically on submit.
// Rate-limited at the mount point (same as /admin) so it can't be used to
// brute-force the token; the token is also 24 random bytes, so guessing is
// infeasible regardless.
async function verifySetupToken(token) {
if (!(await noAdminExists())) {
// Setup already finished — treat the endpoint as closed (the client
// redirects to login on a 409).
throw new ConflictError('Setup already completed — an admin account exists');
}
const expected = await getAppSetting(SETUP_TOKEN_KEY);
return tokensMatch(token, expected);
}
// Creates the first admin as super_admin (the highest role) and returns a
// ready-to-set admin JWT so the browser flows straight into the wizard.
async function createInitialAdmin({ token, email, password, ip }) {
if (!(await noAdminExists())) {
throw new ConflictError('Setup already completed — an admin account exists');
}
const expected = await getAppSetting(SETUP_TOKEN_KEY);
if (!tokensMatch(token, expected)) {
throw new ValidationError('Invalid setup token', 'token');
}
const cleanEmail = String(email || '').trim().toLowerCase();
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(cleanEmail)) {
throw new ValidationError('A valid email address is required', 'email');
}
const strength = validatePassword(password);
if (!strength.valid) {
throw new ValidationError(strength.errors[0] || 'Password does not meet requirements', 'password');
}
const role = await db('roles').where('name', 'super_admin').first();
if (!role) {
throw new ConflictError('super_admin role missing — database not initialised');
}
const passwordHash = await bcrypt.hash(password, getBcryptRounds());
// Create the admin and burn the token ATOMICALLY. The claim (null the token
// row expecting exactly one match) serialises concurrent valid-token submits,
// so a double-submit can't create two super_admins. All writes use `trx`
// (never the global db) to avoid the SQLite in-transaction deadlock.
const id = await db.transaction(async (trx) => {
const claimed = await trx('app_settings')
.where({ setting_key: SETUP_TOKEN_KEY })
.whereNotNull('setting_value')
.update({ setting_value: null, updated_at: new Date() });
if (claimed !== 1) {
throw new ConflictError('Setup already completed — an admin account exists');
}
const cnt = await trx('admin_users').count({ c: '*' }).first();
if (Number(cnt?.c || 0) !== 0) {
throw new ConflictError('Setup already completed — an admin account exists');
}
const inserted = await trx('admin_users').insert({
username: cleanEmail,
email: cleanEmail,
password_hash: passwordHash,
role_id: role.id,
is_active: formatBoolean(true),
must_change_password: formatBoolean(false),
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
return inserted[0]?.id || inserted[0];
});
// DB token cleared inside the tx; remove the on-disk file too (best-effort).
try { fs.unlinkSync(setupTokenFilePath()); } catch (_) { /* best-effort */ }
logger.info(`[setup] Initial super_admin created (id=${id}, email=${cleanEmail})`);
const authToken = jwt.sign(
{ id, username: cleanEmail, type: 'admin', role: role.name, ip: ip || null, loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '24h', issuer: 'picpeak-auth' }
);
return {
token: authToken,
user: {
id,
username: cleanEmail,
email: cleanEmail,
role: { name: role.name, displayName: role.display_name },
},
};
}
module.exports = { getSetupStatus, ensureSetupToken, verifySetupToken, createInitialAdmin };
+15
View File
@@ -3,6 +3,21 @@
set -e set -e
# Machine secrets (JWT/DB/Redis): if not supplied via the environment, read them
# from the generated secret files that the compose `secrets-init` service writes
# to /run/secrets. Explicit env ALWAYS wins, so installs that set
# JWT_SECRET/DB_PASSWORD/REDIS_PASSWORD in .env are unaffected. Runs before the
# root -> nodejs re-exec so the exported values survive su-exec.
for _pair in JWT_SECRET:jwt_secret DB_PASSWORD:db_password REDIS_PASSWORD:redis_password; do
_var="${_pair%%:*}"
_file="/run/secrets/${_pair##*:}"
eval "_cur=\${$_var:-}"
if [ -z "$_cur" ] && [ -s "$_file" ]; then
export "$_var=$(cat "$_file")"
fi
done
unset _pair _var _file _cur
# Permission handling (#484): the image starts as root so this script can # Permission handling (#484): the image starts as root so this script can
# chown bind-mounted host volumes to UID 1001 (nodejs) before dropping # chown bind-mounted host volumes to UID 1001 (nodejs) before dropping
# privileges via su-exec. This avoids the fresh-install restart loop where # privileges via su-exec. This avoids the fresh-install restart loop where
+55 -2
View File
@@ -1,16 +1,56 @@
version: '3.8' version: '3.8'
services: services:
# Generates machine secrets (JWT/DB/Redis) on first run when they aren't set
# in .env, so a fresh install needs zero secret management. Each file is seeded
# from the matching env var when provided (backward-compatible), otherwise a
# strong random value. Idempotent — never overwrites an existing file, so the
# DB password can't drift out from under an already-initialised Postgres volume.
secrets-init:
image: alpine:3.20
container_name: picpeak-secrets-init
env_file: .env
entrypoint:
- sh
- -c
- |
set -e
mkdir -p /run/secrets
if [ ! -s /run/secrets/jwt_secret ]; then
if [ -n "$$JWT_SECRET" ]; then printf '%s' "$$JWT_SECRET" > /run/secrets/jwt_secret;
else tr -dc A-Za-z0-9 < /dev/urandom | head -c 48 > /run/secrets/jwt_secret; fi
fi
if [ ! -s /run/secrets/db_password ]; then
if [ -n "$$DB_PASSWORD" ]; then printf '%s' "$$DB_PASSWORD" > /run/secrets/db_password;
else tr -dc A-Za-z0-9 < /dev/urandom | head -c 48 > /run/secrets/db_password; fi
fi
if [ ! -s /run/secrets/redis_password ]; then
if [ -n "$$REDIS_PASSWORD" ]; then printf '%s' "$$REDIS_PASSWORD" > /run/secrets/redis_password;
else tr -dc A-Za-z0-9 < /dev/urandom | head -c 48 > /run/secrets/redis_password; fi
fi
# 644: the readers run as three different users (postgres, redis, nodejs),
# so a non-root reader must be able to read them. The volume is private to
# these containers and never host-exposed.
chmod 644 /run/secrets/jwt_secret /run/secrets/db_password /run/secrets/redis_password
volumes:
- picpeak-secrets:/run/secrets
restart: "no"
postgres: postgres:
image: postgres:15-alpine image: postgres:15-alpine
container_name: picpeak-postgres container_name: picpeak-postgres
userns_mode: "host" userns_mode: "host"
environment: environment:
POSTGRES_USER: ${DB_USER:-picpeak} POSTGRES_USER: ${DB_USER:-picpeak}
POSTGRES_PASSWORD: ${DB_PASSWORD} # Reads the generated (or .env-seeded) password from the shared secrets volume.
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
POSTGRES_DB: ${DB_NAME:-picpeak} POSTGRES_DB: ${DB_NAME:-picpeak}
volumes: volumes:
- postgres-data:/var/lib/postgresql/data - postgres-data:/var/lib/postgresql/data
- picpeak-secrets:/run/secrets:ro
depends_on:
secrets-init:
condition: service_completed_successfully
networks: networks:
- picpeak-network - picpeak-network
restart: unless-stopped restart: unless-stopped
@@ -30,9 +70,14 @@ services:
image: redis:7-alpine image: redis:7-alpine
container_name: picpeak-redis container_name: picpeak-redis
userns_mode: "host" userns_mode: "host"
command: redis-server --requirepass ${REDIS_PASSWORD} # Reads the generated (or .env-seeded) password from the shared secrets volume.
command: sh -c 'exec redis-server --requirepass "$$(cat /run/secrets/redis_password)"'
volumes: volumes:
- redis-data:/data - redis-data:/data
- picpeak-secrets:/run/secrets:ro
depends_on:
secrets-init:
condition: service_completed_successfully
networks: networks:
- picpeak-network - picpeak-network
restart: unless-stopped restart: unless-stopped
@@ -59,11 +104,14 @@ services:
- ${APP_STORAGE}:/app/storage - ${APP_STORAGE}:/app/storage
- ${LOGS}:/app/logs - ${LOGS}:/app/logs
- ${APP_DATA}:/app/data - ${APP_DATA}:/app/data
- picpeak-secrets:/run/secrets:ro
ports: ports:
- "${BACKEND_PORT:-3001}:3000" - "${BACKEND_PORT:-3001}:3000"
networks: networks:
- picpeak-network - picpeak-network
depends_on: depends_on:
secrets-init:
condition: service_completed_successfully
postgres: postgres:
condition: service_healthy condition: service_healthy
redis: redis:
@@ -144,6 +192,11 @@ volumes:
driver: local driver: local
redis-data: redis-data:
driver: local driver: local
# Holds the auto-generated machine secrets (jwt_secret, db_password,
# redis_password). Keep it — deleting it orphans the DB password from the
# Postgres volume. Back it up alongside postgres-data.
picpeak-secrets:
driver: local
networks: networks:
picpeak-network: picpeak-network:
+53 -6
View File
@@ -1,4 +1,37 @@
services: services:
# Generates machine secrets (JWT/DB/Redis) on first run when they aren't set
# in .env (seeds from the env var when provided, else a random value).
# Idempotent — never overwrites an existing file. See docker-compose.production.yml.
secrets-init:
image: alpine:3.20
container_name: picpeak-secrets-init
env_file: .env
entrypoint:
- sh
- -c
- |
set -e
mkdir -p /run/secrets
if [ ! -s /run/secrets/jwt_secret ]; then
if [ -n "$$JWT_SECRET" ]; then printf '%s' "$$JWT_SECRET" > /run/secrets/jwt_secret;
else tr -dc A-Za-z0-9 < /dev/urandom | head -c 48 > /run/secrets/jwt_secret; fi
fi
if [ ! -s /run/secrets/db_password ]; then
if [ -n "$$DB_PASSWORD" ]; then printf '%s' "$$DB_PASSWORD" > /run/secrets/db_password;
else tr -dc A-Za-z0-9 < /dev/urandom | head -c 48 > /run/secrets/db_password; fi
fi
if [ ! -s /run/secrets/redis_password ]; then
if [ -n "$$REDIS_PASSWORD" ]; then printf '%s' "$$REDIS_PASSWORD" > /run/secrets/redis_password;
else tr -dc A-Za-z0-9 < /dev/urandom | head -c 48 > /run/secrets/redis_password; fi
fi
# 644: the readers run as three different users (postgres, redis, nodejs),
# so a non-root reader must be able to read them. The volume is private to
# these containers and never host-exposed.
chmod 644 /run/secrets/jwt_secret /run/secrets/db_password /run/secrets/redis_password
volumes:
- picpeak-secrets:/run/secrets
restart: "no"
backend: backend:
build: build:
context: ./backend context: ./backend
@@ -8,17 +41,16 @@ services:
environment: environment:
- NODE_ENV=${NODE_ENV:-production} - NODE_ENV=${NODE_ENV:-production}
- PORT=3000 - PORT=3000
- JWT_SECRET=${JWT_SECRET} - JWT_SECRET=${JWT_SECRET:-}
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin} - ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
- ADMIN_EMAIL=${ADMIN_EMAIL:[email protected]} - ADMIN_EMAIL=${ADMIN_EMAIL:[email protected]}
- ADMIN_PASSWORD=${ADMIN_PASSWORD} - ADMIN_PASSWORD=${ADMIN_PASSWORD:-}
- DATABASE_CLIENT=pg - DATABASE_CLIENT=pg
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@postgres:5432/${DB_NAME}
- DB_TYPE=postgresql - DB_TYPE=postgresql
- DB_HOST=postgres - DB_HOST=postgres
- DB_PORT=5432 - DB_PORT=5432
- DB_USER=${DB_USER} - DB_USER=${DB_USER}
- DB_PASSWORD=${DB_PASSWORD} - DB_PASSWORD=${DB_PASSWORD:-}
- DB_NAME=${DB_NAME} - DB_NAME=${DB_NAME}
- EXTERNAL_MEDIA_ROOT=${EXTERNAL_MEDIA_ROOT:-/app/storage/external-media} - EXTERNAL_MEDIA_ROOT=${EXTERNAL_MEDIA_ROOT:-/app/storage/external-media}
- SMTP_HOST=${SMTP_HOST} - SMTP_HOST=${SMTP_HOST}
@@ -42,9 +74,12 @@ services:
- ./logs:/app/logs - ./logs:/app/logs
- ./backup:/backup - ./backup:/backup
- ./storage:/app/storage - ./storage:/app/storage
- picpeak-secrets:/run/secrets:ro
ports: ports:
- "${BACKEND_PORT:-3001}:3000" - "${BACKEND_PORT:-3001}:3000"
depends_on: depends_on:
secrets-init:
condition: service_completed_successfully
postgres: postgres:
condition: service_healthy condition: service_healthy
healthcheck: healthcheck:
@@ -63,12 +98,16 @@ services:
userns_mode: "host" userns_mode: "host"
environment: environment:
- POSTGRES_USER=${DB_USER} - POSTGRES_USER=${DB_USER}
- POSTGRES_PASSWORD=${DB_PASSWORD} - POSTGRES_PASSWORD_FILE=/run/secrets/db_password
- POSTGRES_DB=${DB_NAME} - POSTGRES_DB=${DB_NAME}
- PGDATA=/var/lib/postgresql/data/pgdata - PGDATA=/var/lib/postgresql/data/pgdata
- TZ=${TZ:-UTC} - TZ=${TZ:-UTC}
volumes: volumes:
- postgres-data:/var/lib/postgresql/data - postgres-data:/var/lib/postgresql/data
- picpeak-secrets:/run/secrets:ro
depends_on:
secrets-init:
condition: service_completed_successfully
ports: ports:
- "127.0.0.1:${DB_PORT:-5432}:5432" - "127.0.0.1:${DB_PORT:-5432}:5432"
healthcheck: healthcheck:
@@ -85,9 +124,13 @@ services:
container_name: picpeak-redis container_name: picpeak-redis
restart: unless-stopped restart: unless-stopped
userns_mode: "host" userns_mode: "host"
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-picpeak_redis_pass} command: sh -c 'exec redis-server --appendonly yes --requirepass "$$(cat /run/secrets/redis_password)"'
volumes: volumes:
- redis-data:/data - redis-data:/data
- picpeak-secrets:/run/secrets:ro
depends_on:
secrets-init:
condition: service_completed_successfully
ports: ports:
- "127.0.0.1:${REDIS_PORT:-6379}:6379" - "127.0.0.1:${REDIS_PORT:-6379}:6379"
healthcheck: healthcheck:
@@ -142,6 +185,10 @@ volumes:
driver: local driver: local
redis-data: redis-data:
driver: local driver: local
# Auto-generated machine secrets (jwt/db/redis). Keep it — deleting it orphans
# the DB password from the Postgres volume.
picpeak-secrets:
driver: local
networks: networks:
picpeak-network: picpeak-network:
+9
View File
@@ -79,6 +79,8 @@ import { MaintenanceWrapper } from './components/MaintenanceWrapper';
import { GlobalThemeProvider } from './components/GlobalThemeProvider'; import { GlobalThemeProvider } from './components/GlobalThemeProvider';
import { ConfirmDialogProvider } from './components/common'; import { ConfirmDialogProvider } from './components/common';
import { usePublicSettings } from './hooks/usePublicSettings'; import { usePublicSettings } from './hooks/usePublicSettings';
import { SetupPage } from './pages/SetupPage';
import { AdminAuthProvider } from './contexts';
// Create a client // Create a client
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@@ -214,6 +216,13 @@ function App() {
</GalleryAuthProvider> </GalleryAuthProvider>
} /> } />
{/* First-run setup — public, self-closes once an admin exists */}
<Route path="/setup" element={
<AdminAuthProvider>
<SetupPage />
</AdminAuthProvider>
} />
{/* Admin routes - wrap with AdminAuthProvider */} {/* Admin routes - wrap with AdminAuthProvider */}
<Route path="/admin" element={<AdminAuthWrapper />}> <Route path="/admin" element={<AdminAuthWrapper />}>
<Route path="login" element={<AdminLoginPage />} /> <Route path="login" element={<AdminLoginPage />} />
+34
View File
@@ -3462,6 +3462,40 @@
"regenerateToken": "Link neu generieren", "regenerateToken": "Link neu generieren",
"tokenRegenerated": "Kundenzugangs-Link neu generiert" "tokenRegenerated": "Kundenzugangs-Link neu generiert"
}, },
"setup": {
"title": "Willkommen bei PicPeak",
"subtitle": "Erstellen Sie Ihr Administrator-Konto, um loszulegen",
"tokenStepSubtitle": "Geben Sie zunächst Ihren einmaligen Setup-Token ein",
"accountStepSubtitle": "Erstellen Sie nun Ihr Administrator-Konto",
"stepOf": "Schritt {{current}} von {{total}}",
"continue": "Weiter",
"back": "Zurück",
"tokenLabel": "Setup-Token",
"tokenPlaceholder": "Einmaligen Setup-Token einfügen",
"tokenHint": "Wird beim ersten Start in den Server-Logs ausgegeben (auch in data/SETUP_TOKEN gespeichert).",
"tokenRequired": "Der Setup-Token ist erforderlich",
"tokenCommandLabel": "Token nicht gefunden? Führen Sie dies im Projektverzeichnis aus:",
"copyCommand": "Befehl kopieren",
"tokenRotatedLink": "Logs bereits rotiert? Zur Einrichtungsanleitung",
"tokenLocationHint": "Nicht gefunden? Führen Sie aus: docker compose logs backend | grep -i \"setup token\"",
"invalidToken": "Dieser Setup-Token ist ungültig.",
"passwordRequirements": "Verwenden Sie mindestens 8 Zeichen mit einem Groß- und einem Kleinbuchstaben sowie einer Ziffer.",
"emailLabel": "E-Mail-Adresse",
"emailPlaceholder": "[email protected]",
"emailRequired": "E-Mail ist erforderlich",
"invalidEmail": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"passwordLabel": "Passwort",
"passwordPlaceholder": "Wählen Sie ein sicheres Passwort",
"passwordRequired": "Passwort ist erforderlich",
"passwordMinLength": "Das Passwort muss mindestens 8 Zeichen lang sein",
"confirmLabel": "Passwort bestätigen",
"confirmPlaceholder": "Passwort erneut eingeben",
"passwordMismatch": "Die Passwörter stimmen nicht überein",
"submit": "Admin-Konto erstellen",
"success": "Admin-Konto erstellt. Willkommen!",
"genericError": "Einrichtung fehlgeschlagen. Bitte versuchen Sie es erneut.",
"tooManyAttempts": "Zu viele Versuche. Bitte warten Sie einen Moment und versuchen Sie es erneut."
},
"adminLogin": { "adminLogin": {
"title": "Admin-Anmeldung", "title": "Admin-Anmeldung",
"subtitle": "Melden Sie sich an, um Ihre Fotogalerien zu verwalten", "subtitle": "Melden Sie sich an, um Ihre Fotogalerien zu verwalten",
+34
View File
@@ -3358,6 +3358,40 @@
"fourStarsPlus": "4+ Stars", "fourStarsPlus": "4+ Stars",
"fiveStarsOnly": "5 Stars Only" "fiveStarsOnly": "5 Stars Only"
}, },
"setup": {
"title": "Welcome to PicPeak",
"subtitle": "Create your administrator account to get started",
"tokenStepSubtitle": "First, enter your one-time setup token",
"accountStepSubtitle": "Now create your administrator account",
"stepOf": "Step {{current}} of {{total}}",
"continue": "Continue",
"back": "Back",
"tokenLabel": "Setup token",
"tokenPlaceholder": "Paste the one-time setup token",
"tokenHint": "Printed to the server logs on first start (also saved to data/SETUP_TOKEN).",
"tokenRequired": "The setup token is required",
"tokenCommandLabel": "Can't find your token? Run this in the project directory:",
"copyCommand": "Copy command",
"tokenRotatedLink": "Logs already rotated away? Read the setup guide",
"tokenLocationHint": "Can't find it? Run: docker compose logs backend | grep -i \"setup token\"",
"invalidToken": "That setup token is not valid.",
"passwordRequirements": "Use at least 8 characters with an upper-case letter, a lower-case letter and a number.",
"emailLabel": "Email address",
"emailPlaceholder": "[email protected]",
"emailRequired": "Email is required",
"invalidEmail": "Please enter a valid email address",
"passwordLabel": "Password",
"passwordPlaceholder": "Choose a strong password",
"passwordRequired": "Password is required",
"passwordMinLength": "Password must be at least 8 characters",
"confirmLabel": "Confirm password",
"confirmPlaceholder": "Re-enter your password",
"passwordMismatch": "Passwords do not match",
"submit": "Create admin account",
"success": "Admin account created. Welcome!",
"genericError": "Setup failed. Please try again.",
"tooManyAttempts": "Too many attempts. Please wait a moment and try again."
},
"adminLogin": { "adminLogin": {
"title": "Admin Login", "title": "Admin Login",
"subtitle": "Sign in to manage your photo galleries", "subtitle": "Sign in to manage your photo galleries",
+358
View File
@@ -0,0 +1,358 @@
import React, { useState } from 'react';
import { Navigate, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Key, Mail, Lock, Eye, EyeOff, AlertCircle, ArrowLeft, ArrowRight, Copy, Check, ExternalLink } from 'lucide-react';
import { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next';
import { Button, Input, Card, Loading } from '../components/common';
import { useAdminAuth } from '../contexts';
import { setupService } from '../services/setup.service';
import { resolveLoginLogoClasses } from '../utils/loginLogoSize';
import type { AdminUser } from '../types';
// Where the first-run setup is documented, for the case where the server logs
// have already rotated away and the admin can no longer grep the token out.
const SETUP_DOCS_URL =
'https://github.com/PicPeak/picpeak/blob/main/README.md#first-run--create-your-admin-account';
// First-run screen. Reached on a fresh instance where no admin account exists
// yet — creates the first (super_admin) account from the browser using the
// one-time setup token printed to the server logs. Once an admin exists the
// endpoints self-close and this page redirects to the login.
//
// Split into two steps so the token-recovery guidance gets the space it needs:
// 1. paste the one-time setup token (with the `docker compose logs` recovery
// command shown prominently right under the field)
// 2. choose the admin email + password
export const SetupPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { login } = useAdminAuth();
const { data: status, isLoading: statusLoading, isError: statusError } = useQuery({
queryKey: ['setup-status'],
queryFn: setupService.getSetupStatus,
retry: false,
staleTime: Infinity,
});
const [step, setStep] = useState<'token' | 'account'>('token');
const [form, setForm] = useState({ token: '', email: '', password: '', confirm: '' });
const [showPassword, setShowPassword] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isVerifyingToken, setIsVerifyingToken] = useState(false);
const [copied, setCopied] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
if (statusLoading) {
return <Loading fullScreen />;
}
// Setup already done, OR the status couldn't be read (e.g. a transient 500) →
// go to login rather than flashing the create-admin form on a configured
// instance. Only render the wizard when we know an admin is genuinely missing.
if (statusError || !status?.needsAdmin) {
return <Navigate to="/admin/login" replace />;
}
const setField = (field: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) => {
setForm((prev) => ({ ...prev, [field]: e.target.value }));
if (errors[field]) setErrors((prev) => ({ ...prev, [field]: '' }));
};
const recoveryCommand = 'docker compose logs backend | grep -i "setup token"';
const copyRecoveryCommand = async () => {
try {
await navigator.clipboard.writeText(recoveryCommand);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard unavailable (e.g. non-secure context) — the command is still
// visible for the user to copy by hand, so fail quietly.
}
};
const validateToken = (): boolean => {
const next: Record<string, string> = {};
if (!form.token.trim()) next.token = t('setup.tokenRequired');
setErrors(next);
return Object.keys(next).length === 0;
};
const validateAccount = (): boolean => {
const next: Record<string, string> = {};
if (!form.email) next.email = t('setup.emailRequired');
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) next.email = t('setup.invalidEmail');
if (!form.password) next.password = t('setup.passwordRequired');
// Mirror the server's rule (validatePassword): >=8 chars with upper, lower
// and a digit — so the user isn't bounced by the server after a green client.
else if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/.test(form.password)) next.password = t('setup.passwordRequirements');
if (form.confirm !== form.password) next.confirm = t('setup.passwordMismatch');
setErrors(next);
return Object.keys(next).length === 0;
};
const handleTokenContinue = async (e: React.FormEvent) => {
e.preventDefault();
toast.dismiss();
if (!validateToken()) return;
// Verify the token server-side before advancing — a wrong token is caught
// here at "Continue" rather than after the user has filled in the account
// step. The token is checked, not consumed; createInitialAdmin still burns
// it atomically on final submit.
setIsVerifyingToken(true);
setErrors({});
try {
await setupService.verifyToken(form.token.trim());
setStep('account');
} catch (error: any) {
const httpStatus = error.response?.status;
if (httpStatus === 429) {
toast.error(t('setup.tooManyAttempts'));
} else if (httpStatus === 409) {
// Someone else finished setup first — send to login.
navigate('/admin/login', { replace: true });
} else {
setErrors({ token: t('setup.invalidToken') });
}
} finally {
setIsVerifyingToken(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
toast.dismiss();
if (!validateAccount()) return;
setIsSubmitting(true);
setErrors({});
try {
const { user } = await setupService.createInitialAdmin({
token: form.token.trim(),
email: form.email.trim(),
password: form.password,
});
// Cookie is set by the backend; register the session and enter the app.
const adminUser: AdminUser = {
id: user.id,
username: user.username,
email: user.email,
mustChangePassword: false,
role: { name: user.role.name, displayName: user.role.displayName ?? user.role.name },
};
login('', adminUser);
toast.success(t('setup.success'));
navigate('/admin/dashboard', { replace: true });
} catch (error: any) {
const httpStatus = error.response?.status;
const data = error.response?.data;
// Map the server's field back to a translated message instead of
// rendering its raw English error verbatim.
const fieldKey: Record<string, string> = {
token: 'setup.invalidToken',
email: 'setup.invalidEmail',
password: 'setup.passwordRequirements',
};
// A rejected token belongs to step 1 — send the user back there to fix it
// rather than showing the error on a field the account step doesn't render.
const bounceToTokenStep = (field: string) => {
if (field === 'token') setStep('token');
};
if (httpStatus === 429) {
toast.error(t('setup.tooManyAttempts'));
} else if (httpStatus === 409) {
// Someone else finished setup first — send to login.
navigate('/admin/login', { replace: true });
} else if (data?.field && fieldKey[data.field]) {
setErrors({ [data.field]: t(fieldKey[data.field]) });
bounceToTokenStep(data.field);
} else if (Array.isArray(data?.errors) && data.errors.length) {
const p = data.errors[0]?.path || data.errors[0]?.param;
if (p && fieldKey[p]) {
setErrors({ [p]: t(fieldKey[p]) });
bounceToTokenStep(p);
} else {
setErrors({ form: t('setup.genericError') });
}
} else {
setErrors({ form: t('setup.genericError') });
}
} finally {
setIsSubmitting(false);
}
};
const stepNumber = step === 'token' ? 1 : 2;
return (
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="w-full max-w-md">
<div className="text-center mb-8">
{/* On a fresh instance there are no branding settings yet, so use the
bundled PicPeak logo the same default the login page falls back
to on the cream brand plate. Size matches the login default
(`medium`) so the two screens read identically. */}
{(() => {
const cls = resolveLoginLogoClasses(undefined);
return (
<div className={`${cls.frameOuter} mx-auto mb-6 rounded-2xl flex items-center justify-center`} style={{ backgroundColor: '#eee6d2' }}>
<img src="/picpeak-logo-transparent.png" alt="PicPeak" className={`${cls.frameInner} object-contain`} />
</div>
);
})()}
<h1 className="text-3xl font-bold" style={{ color: 'var(--color-text, #171717)' }}>{t('setup.title')}</h1>
<p className="mt-2" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
{step === 'token' ? t('setup.tokenStepSubtitle') : t('setup.accountStepSubtitle')}
</p>
<p className="mt-3 text-xs font-medium tracking-wide uppercase" style={{ color: 'var(--color-text, #171717)', opacity: 0.5 }}>
{t('setup.stepOf', { current: stepNumber, total: 2 })}
</p>
</div>
<Card padding="lg">
{errors.form && (
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4 flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-800">{errors.form}</p>
</div>
)}
{step === 'token' ? (
<form onSubmit={handleTokenContinue} className="space-y-6">
<div>
<label htmlFor="setup-token" className="block text-sm font-medium text-neutral-700 mb-1">
{t('setup.tokenLabel')}
</label>
<Input
id="setup-token"
type="text"
value={form.token}
onChange={setField('token')}
error={errors.token}
placeholder={t('setup.tokenPlaceholder')}
leftIcon={<Key className="w-5 h-5 text-neutral-400" />}
autoFocus
/>
<p className="mt-1 text-xs text-neutral-500">{t('setup.tokenHint')}</p>
{/* Recovery guidance sits directly under the field it explains. */}
<div className="mt-4 rounded-lg border border-neutral-200 bg-neutral-50 p-3">
<p className="text-xs font-medium text-neutral-600">{t('setup.tokenCommandLabel')}</p>
<div className="mt-2 flex items-center gap-2">
<code className="flex-1 overflow-x-auto whitespace-nowrap rounded bg-neutral-900 px-3 py-2 font-mono text-xs text-neutral-100">
{recoveryCommand}
</code>
<button
type="button"
onClick={copyRecoveryCommand}
className="flex-shrink-0 rounded-md border border-neutral-200 bg-white p-2 text-neutral-500 hover:text-neutral-700 transition-colors"
aria-label={t('setup.copyCommand')}
title={t('setup.copyCommand')}
>
{copied ? <Check className="w-4 h-4 text-green-600" /> : <Copy className="w-4 h-4" />}
</button>
</div>
<a
href={SETUP_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-flex items-center gap-1 text-xs hover:underline"
style={{ color: 'var(--color-primary, #5C8762)' }}
>
{t('setup.tokenRotatedLink')}
<ExternalLink className="w-3 h-3" />
</a>
</div>
</div>
<Button type="submit" variant="primary" size="lg" isLoading={isVerifyingToken} className="w-full" rightIcon={<ArrowRight className="w-4 h-4" />}>
{t('setup.continue')}
</Button>
</form>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="setup-email" className="block text-sm font-medium text-neutral-700 mb-1">
{t('setup.emailLabel')}
</label>
<Input
id="setup-email"
type="email"
value={form.email}
onChange={setField('email')}
error={errors.email}
placeholder={t('setup.emailPlaceholder')}
leftIcon={<Mail className="w-5 h-5 text-neutral-400" />}
autoComplete="email"
autoFocus
/>
</div>
<div>
<label htmlFor="setup-password" className="block text-sm font-medium text-neutral-700 mb-1">
{t('setup.passwordLabel')}
</label>
<div className="relative">
<Input
id="setup-password"
type={showPassword ? 'text' : 'password'}
value={form.password}
onChange={setField('password')}
error={errors.password}
placeholder={t('setup.passwordPlaceholder')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
autoComplete="new-password"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-3 text-neutral-400 hover:text-neutral-600 transition-colors"
tabIndex={-1}
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
</div>
<div>
<label htmlFor="setup-confirm" className="block text-sm font-medium text-neutral-700 mb-1">
{t('setup.confirmLabel')}
</label>
<Input
id="setup-confirm"
type={showPassword ? 'text' : 'password'}
value={form.confirm}
onChange={setField('confirm')}
error={errors.confirm}
placeholder={t('setup.confirmPlaceholder')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
autoComplete="new-password"
/>
</div>
<div className="flex gap-3">
<Button
type="button"
variant="outline"
size="lg"
onClick={() => { toast.dismiss(); setErrors({}); setStep('token'); }}
disabled={isSubmitting}
leftIcon={<ArrowLeft className="w-4 h-4" />}
>
{t('setup.back')}
</Button>
<Button type="submit" variant="primary" size="lg" isLoading={isSubmitting} className="flex-1">
{t('setup.submit')}
</Button>
</div>
</form>
)}
</Card>
</div>
</div>
);
};
SetupPage.displayName = 'SetupPage';
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Navigate, useSearchParams } from 'react-router-dom'; import { Navigate, useSearchParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Lock, Mail, Eye, EyeOff, AlertCircle } from 'lucide-react'; import { Lock, Mail, Eye, EyeOff, AlertCircle } from 'lucide-react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -7,6 +8,7 @@ import { useTranslation } from 'react-i18next';
import { Button, Input, Card, ReCaptcha } from '../../components/common'; import { Button, Input, Card, ReCaptcha } from '../../components/common';
import { useAdminAuth } from '../../contexts'; import { useAdminAuth } from '../../contexts';
import { authService } from '../../services/auth.service'; import { authService } from '../../services/auth.service';
import { setupService } from '../../services/setup.service';
import { usePublicSettings } from '../../hooks/usePublicSettings'; import { usePublicSettings } from '../../hooks/usePublicSettings';
import { useAdminDarkMode } from '../../contexts/AdminDarkModeContext'; import { useAdminDarkMode } from '../../contexts/AdminDarkModeContext';
import { resolveLoginLogoClasses } from '../../utils/loginLogoSize'; import { resolveLoginLogoClasses } from '../../utils/loginLogoSize';
@@ -50,6 +52,17 @@ export const AdminLoginPage: React.FC = () => {
} }
}, [searchParams, t]); }, [searchParams, t]);
// Fresh instance with no admin yet → send to first-run setup.
const { data: setupStatus } = useQuery({
queryKey: ['setup-status'],
queryFn: setupService.getSetupStatus,
retry: false,
staleTime: Infinity,
});
if (setupStatus?.needsAdmin) {
return <Navigate to="/setup" replace />;
}
// Redirect if already authenticated or login successful // Redirect if already authenticated or login successful
if (isAuthenticated || loginSuccess) { if (isAuthenticated || loginSuccess) {
return <Navigate to="/admin/dashboard" replace />; return <Navigate to="/admin/dashboard" replace />;
+41
View File
@@ -0,0 +1,41 @@
import { api } from '../config/api';
export interface SetupStatus {
needsAdmin: boolean;
complete: boolean;
}
export interface SetupAdminUser {
id: number;
username: string;
email: string;
role: { name: string; displayName?: string };
}
export interface CreateInitialAdminInput {
token: string;
email: string;
password: string;
}
// First-run bootstrap. Public endpoints that self-close once an admin exists.
export const setupService = {
async getSetupStatus(): Promise<SetupStatus> {
const response = await api.get<SetupStatus>('/setup/status');
return response.data;
},
// Step-1 pre-flight: confirm the token is valid before advancing to the
// account step. Rejects (400, field: 'token') on a wrong token without
// burning it. Throws on non-2xx so the caller can branch on the status.
async verifyToken(token: string): Promise<{ valid: boolean }> {
const response = await api.post<{ valid: boolean }>('/setup/verify-token', { token });
return response.data;
},
async createInitialAdmin(input: CreateInitialAdminInput): Promise<{ user: SetupAdminUser }> {
// Admin JWT is returned as an HttpOnly cookie (mirrors login); body carries the user.
const response = await api.post<{ user: SetupAdminUser }>('/setup/admin', input);
return response.data;
},
};
+29 -10
View File
@@ -563,24 +563,43 @@ EOF
fi fi
fi fi
# Always surface the admin credentials file (#427: iSchumi reported # Surface how to finish setup. Two paths:
# admins couldn't find the generated password — the migration writes it # - Legacy: if ADMIN_PASSWORD was set, migration seeded an admin and wrote
# to the in-container path and we never copied it to the host unless # data/ADMIN_CREDENTIALS.txt (#427) — print those credentials.
# --reset-admin-password was used). Best-effort: a missing file just # - Default (no ADMIN_PASSWORD): no admin is seeded; the app shows a
# means the migration ran on a pre-existing DB and didn't generate one. # first-run wizard at /setup guarded by a one-time token
# (data/SETUP_TOKEN). Print the token and point the operator there.
local login_base="${DOMAIN_NAME:+https://$DOMAIN_NAME}${DOMAIN_NAME:-http://YOUR_HOST_IP:3000}"
if docker compose cp backend:/app/data/ADMIN_CREDENTIALS.txt "$app_dir/data/ADMIN_CREDENTIALS.txt" 2>/dev/null; then if docker compose cp backend:/app/data/ADMIN_CREDENTIALS.txt "$app_dir/data/ADMIN_CREDENTIALS.txt" 2>/dev/null; then
chown "$host_uid":"$host_gid" "$app_dir/data/ADMIN_CREDENTIALS.txt" 2>/dev/null || true chown "$host_uid":"$host_gid" "$app_dir/data/ADMIN_CREDENTIALS.txt" 2>/dev/null || true
chmod 600 "$app_dir/data/ADMIN_CREDENTIALS.txt" 2>/dev/null || true chmod 600 "$app_dir/data/ADMIN_CREDENTIALS.txt" 2>/dev/null || true
log_step "Admin credentials saved to: $app_dir/data/ADMIN_CREDENTIALS.txt" log_step "Admin credentials saved to: $app_dir/data/ADMIN_CREDENTIALS.txt"
# Show the password in the install output so the operator can log
# in immediately. The file remains as a backup record.
echo echo
echo "--------------------------------------------------" echo "--------------------------------------------------"
grep -E '^Email:|^Password:' "$app_dir/data/ADMIN_CREDENTIALS.txt" 2>/dev/null || true grep -E '^Email:|^Password:' "$app_dir/data/ADMIN_CREDENTIALS.txt" 2>/dev/null || true
echo "--------------------------------------------------" echo "--------------------------------------------------"
echo " Login URL: ${DOMAIN_NAME:+https://$DOMAIN_NAME}${DOMAIN_NAME:-http://YOUR_HOST_IP:3000}/admin" echo " Login URL: $login_base/admin"
echo " Full credentials file: $app_dir/data/ADMIN_CREDENTIALS.txt" echo " Delete the credentials file after recording the password."
echo " Delete the file after recording the password." echo
else
# First-run wizard path — surface the one-time setup token.
docker compose cp backend:/app/data/SETUP_TOKEN "$app_dir/data/SETUP_TOKEN" 2>/dev/null || true
chown "$host_uid":"$host_gid" "$app_dir/data/SETUP_TOKEN" 2>/dev/null || true
local setup_token
setup_token="$(cat "$app_dir/data/SETUP_TOKEN" 2>/dev/null)"
[ -z "$setup_token" ] && setup_token="$(docker compose logs backend 2>/dev/null | grep -i 'setup token:' | tail -1 | sed -E 's/.*setup token: *([A-Za-z0-9_-]+).*/\1/')"
echo
echo "--------------------------------------------------"
echo " Finish setup in your browser — create the admin account:"
echo " 1. Open $login_base/admin"
echo " 2. Enter this one-time setup token:"
if [ -n "$setup_token" ]; then
echo " $setup_token"
else
echo " (run: cd $app_dir && docker compose logs backend | grep -i \"setup token\")"
fi
echo " 3. Set your admin email and password."
echo "--------------------------------------------------"
echo echo
fi fi