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:
+20
-8
@@ -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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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 };
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 />;
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user