feat: first-run web installer with admin account creation
- Backend: app_settings table + users.is_admin flag (schema + migrate)
- POST /api/setup creates admin user, first workspace and marks installed
(transactional, single-use, rate-limited); GET /api/setup/status
- Guard middleware: API answers 503 { setupRequired: true } until installed
- Frontend: /setup wizard (welcome, admin account, workspace), router guard
redirects to installer when uninstalled, API interceptor handles 503
- Docs: installer covered in README, SETUP, HOSTING and ROADMAP
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
"name": "openstack-backend",
|
||||
"version": "0.1.0",
|
||||
"description": "Express + Socket.IO API for openStack",
|
||||
"type": "module",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
|
||||
@@ -66,6 +66,16 @@ CREATE TABLE IF NOT EXISTS comments (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_page ON comments(page_id);
|
||||
|
||||
-- App settings (first-run installer state, feature flags)
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Global admin flag (first user created via web installer)
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- Ensure workspace owner is always a member with role=owner
|
||||
CREATE OR REPLACE FUNCTION ensure_owner_membership() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
|
||||
@@ -7,6 +7,7 @@ import morgan from 'morgan';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { pool } from './config/db.js';
|
||||
import authRoutes from './routes/auth.js';
|
||||
import setupRoutes, { isInstalled } from './routes/setup.js';
|
||||
import workspaceRoutes from './routes/workspaces.js';
|
||||
import pageRoutes from './routes/pages.js';
|
||||
import userRoutes from './routes/users.js';
|
||||
@@ -26,6 +27,18 @@ app.use(
|
||||
}),
|
||||
);
|
||||
|
||||
app.use('/api/setup', rateLimit({ windowMs: 15 * 60 * 1000, max: 20 }), setupRoutes);
|
||||
|
||||
// First-run guard: until the web installer completes, every API route
|
||||
// except /api/setup/* and /api/health answers 503 { setupRequired: true }.
|
||||
app.use('/api', async (req, res, next) => {
|
||||
if (req.path === '/health' || req.path.startsWith('/setup')) return next();
|
||||
if (!(await isInstalled())) {
|
||||
return res.status(503).json({ error: 'Initial setup required', setupRequired: true });
|
||||
}
|
||||
return next();
|
||||
});
|
||||
|
||||
app.use('/api/auth', rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }), authRoutes);
|
||||
app.use('/api/workspaces', workspaceRoutes);
|
||||
app.use('/api/pages', pageRoutes);
|
||||
|
||||
@@ -7,7 +7,7 @@ const router = Router();
|
||||
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
||||
|
||||
function publicUser(row) {
|
||||
return { id: row.id, email: row.email, name: row.name, createdAt: row.created_at };
|
||||
return { id: row.id, email: row.email, name: row.name, isAdmin: !!row.is_admin, createdAt: row.created_at };
|
||||
}
|
||||
|
||||
// POST /api/auth/register { email, name, password }
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Router } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { pool, query } from '../config/db.js';
|
||||
import { signToken } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
||||
|
||||
/** True once the first-run web installer has completed. */
|
||||
export async function isInstalled() {
|
||||
try {
|
||||
const { rows } = await query("SELECT value FROM app_settings WHERE key = 'installed'");
|
||||
return rows[0]?.value === 'true';
|
||||
} catch {
|
||||
// Fail open: a missing table (pre-migration DB) must not lock out /api/health.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/setup/status -> { installed: boolean }
|
||||
router.get('/status', async (_req, res) => {
|
||||
return res.json({ installed: await isInstalled() });
|
||||
});
|
||||
|
||||
// POST /api/setup { adminName, adminEmail, adminPassword, workspaceName? }
|
||||
// Creates the admin account + first workspace and marks the app installed.
|
||||
// Only works once; afterwards responds 409.
|
||||
router.post('/', async (req, res) => {
|
||||
if (await isInstalled()) return res.status(409).json({ error: 'Already installed' });
|
||||
const { adminName = '', adminEmail = '', adminPassword = '', workspaceName = 'My workspace' } = req.body ?? {};
|
||||
|
||||
if (String(adminName).trim().length < 1 || String(adminName).length > 100) {
|
||||
return res.status(400).json({ error: 'Admin name must be 1-100 chars' });
|
||||
}
|
||||
if (!EMAIL_RE.test(String(adminEmail).toLowerCase())) {
|
||||
return res.status(400).json({ error: 'Valid admin email required' });
|
||||
}
|
||||
if (String(adminPassword).length < 8) {
|
||||
return res.status(400).json({ error: 'Admin password must be at least 8 chars' });
|
||||
}
|
||||
const wsName = String(workspaceName).trim().slice(0, 120) || 'My workspace';
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const existing = await client.query('SELECT id FROM users WHERE email = $1', [
|
||||
String(adminEmail).toLowerCase(),
|
||||
]);
|
||||
if (existing.rowCount > 0) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(409).json({ error: 'Email already registered' });
|
||||
}
|
||||
const hash = await bcrypt.hash(String(adminPassword), 12);
|
||||
const { rows: users } = await client.query(
|
||||
`INSERT INTO users (email, name, password_hash, is_admin)
|
||||
VALUES ($1,$2,$3,TRUE) RETURNING id, email, name, is_admin, created_at`,
|
||||
[String(adminEmail).toLowerCase(), String(adminName).trim(), hash],
|
||||
);
|
||||
const { rows: spaces } = await client.query(
|
||||
'INSERT INTO workspaces (name, owner_id) VALUES ($1,$2) RETURNING *',
|
||||
[wsName, users[0].id],
|
||||
);
|
||||
await client.query(
|
||||
`INSERT INTO app_settings (key, value) VALUES ('installed','true')
|
||||
ON CONFLICT (key) DO UPDATE SET value = 'true'`,
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
const user = {
|
||||
id: users[0].id,
|
||||
email: users[0].email,
|
||||
name: users[0].name,
|
||||
isAdmin: users[0].is_admin,
|
||||
createdAt: users[0].created_at,
|
||||
};
|
||||
return res.status(201).json({ user, token: signToken(users[0]), workspace: spaces[0] });
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('[setup]', err.message);
|
||||
return res.status(500).json({ error: 'Setup failed' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user