feat: first-run web installer with admin account creation
ci / backend (push) Canceled after 0s
ci / frontend (push) Canceled after 0s

- 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:
x3
2026-09-22 13:39:33 +02:00
parent 375df06d59
commit 663f7c16cc
13 changed files with 248 additions and 6 deletions
+3 -2
View File
@@ -59,8 +59,9 @@ cd frontend && npm install && npm run dev
# -> http://localhost:5173 # -> http://localhost:5173
``` ```
Default dev flow creates the first user via **Register** in the UI; that user can then Open the frontend: on first start a **web installer** guides you through creating the
create workspaces and invite others by email (invited email must have an account). admin account and your first workspace. Afterwards the team registers via the UI
and joins workspaces by email invite (invited email must have an account).
## Quick start (Docker Compose) ## Quick start (Docker Compose)
+1
View File
@@ -2,6 +2,7 @@
"name": "openstack-backend", "name": "openstack-backend",
"version": "0.1.0", "version": "0.1.0",
"description": "Express + Socket.IO API for openStack", "description": "Express + Socket.IO API for openStack",
"type": "module",
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {
"start": "node src/index.js", "start": "node src/index.js",
+10
View File
@@ -66,6 +66,16 @@ CREATE TABLE IF NOT EXISTS comments (
); );
CREATE INDEX IF NOT EXISTS idx_comments_page ON comments(page_id); 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 -- Ensure workspace owner is always a member with role=owner
CREATE OR REPLACE FUNCTION ensure_owner_membership() RETURNS trigger AS $$ CREATE OR REPLACE FUNCTION ensure_owner_membership() RETURNS trigger AS $$
BEGIN BEGIN
+13
View File
@@ -7,6 +7,7 @@ import morgan from 'morgan';
import rateLimit from 'express-rate-limit'; import rateLimit from 'express-rate-limit';
import { pool } from './config/db.js'; import { pool } from './config/db.js';
import authRoutes from './routes/auth.js'; import authRoutes from './routes/auth.js';
import setupRoutes, { isInstalled } from './routes/setup.js';
import workspaceRoutes from './routes/workspaces.js'; import workspaceRoutes from './routes/workspaces.js';
import pageRoutes from './routes/pages.js'; import pageRoutes from './routes/pages.js';
import userRoutes from './routes/users.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/auth', rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }), authRoutes);
app.use('/api/workspaces', workspaceRoutes); app.use('/api/workspaces', workspaceRoutes);
app.use('/api/pages', pageRoutes); app.use('/api/pages', pageRoutes);
+1 -1
View File
@@ -7,7 +7,7 @@ const router = Router();
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
function publicUser(row) { 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 } // POST /api/auth/register { email, name, password }
+85
View File
@@ -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;
+3
View File
@@ -122,6 +122,9 @@ curl http://localhost:3000/api/health
- Frontend: `http://<host>:8080` - Frontend: `http://<host>:8080`
- Backend API: `http://<host>:3000/api/health` - Backend API: `http://<host>:3000/api/health`
- Data persists in the `pgdata` volume. - Data persists in the `pgdata` volume.
- On first start, open the frontend and complete the **web installer**
(admin account + first workspace). Until then the API answers
`503 { setupRequired: true }` for all app routes.
### Reverse proxy (TLS) in front of Compose ### Reverse proxy (TLS) in front of Compose
+1
View File
@@ -3,6 +3,7 @@
## Phase 1 — Web app MVP (this repo, v0.1) ✅ ## Phase 1 — Web app MVP (this repo, v0.1) ✅
- [x] Auth (register/login/JWT), workspaces, invites, roles - [x] Auth (register/login/JWT), workspaces, invites, roles
- [x] First-run web installer (admin account + first workspace, API locked until done)
- [x] Nested pages, TipTap rich text, comments, search - [x] Nested pages, TipTap rich text, comments, search
- [x] Socket.IO realtime (live updates, presence, typing) - [x] Socket.IO realtime (live updates, presence, typing)
- [x] Light/dark mode - [x] Light/dark mode
+4 -2
View File
@@ -57,8 +57,10 @@ npm install
npm run dev # http://localhost:5173 npm run dev # http://localhost:5173
``` ```
Open the frontend, **Register** the first user, create a workspace, invite teammates Open the frontend, where the **first-run web installer** (`/setup`) creates the admin
by email (they must register with the same email first). account (`is_admin=true`) and the first workspace, then logs you in automatically.
Further teammates **Register** themselves and are invited by email
(they must register with the same email first).
## Troubleshooting ## Troubleshooting
+4
View File
@@ -13,6 +13,10 @@ api.interceptors.request.use((config) => {
api.interceptors.response.use( api.interceptors.response.use(
(r) => r, (r) => r,
(err) => { (err) => {
if (err.response?.status === 503 && err.response?.data?.setupRequired) {
if (!location.pathname.startsWith('/setup')) location.href = '/setup';
return Promise.reject(err);
}
if (err.response?.status === 401 && !location.pathname.startsWith('/login')) { if (err.response?.status === 401 && !location.pathname.startsWith('/login')) {
localStorage.removeItem('token'); localStorage.removeItem('token');
localStorage.removeItem('user'); localStorage.removeItem('user');
+8 -1
View File
@@ -1,11 +1,14 @@
import { createRouter, createWebHistory } from 'vue-router'; import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore } from '../stores/auth'; import { useAuthStore } from '../stores/auth';
import { useSetupStore } from '../stores/setup';
import Login from '../views/Login.vue'; import Login from '../views/Login.vue';
import Register from '../views/Register.vue'; import Register from '../views/Register.vue';
import Setup from '../views/Setup.vue';
import Dashboard from '../views/Dashboard.vue'; import Dashboard from '../views/Dashboard.vue';
import PageEditor from '../views/PageEditor.vue'; import PageEditor from '../views/PageEditor.vue';
const routes = [ const routes = [
{ path: '/setup', component: Setup },
{ path: '/login', component: Login, meta: { guest: true } }, { path: '/login', component: Login, meta: { guest: true } },
{ path: '/register', component: Register, meta: { guest: true } }, { path: '/register', component: Register, meta: { guest: true } },
{ path: '/', component: Dashboard, meta: { auth: true } }, { path: '/', component: Dashboard, meta: { auth: true } },
@@ -14,7 +17,11 @@ const routes = [
const router = createRouter({ history: createWebHistory(), routes }); const router = createRouter({ history: createWebHistory(), routes });
router.beforeEach((to) => { router.beforeEach(async (to) => {
const setup = useSetupStore();
if (setup.installed === null) await setup.check();
if (!setup.installed && to.path !== '/setup') return '/setup';
if (setup.installed && to.path === '/setup') return '/';
const auth = useAuthStore(); const auth = useAuthStore();
if (to.meta.auth && !auth.isLoggedIn) return '/login'; if (to.meta.auth && !auth.isLoggedIn) return '/login';
if (to.meta.guest && auth.isLoggedIn) return '/'; if (to.meta.guest && auth.isLoggedIn) return '/';
+21
View File
@@ -0,0 +1,21 @@
import { defineStore } from 'pinia';
import api from '../api';
export const useSetupStore = defineStore('setup', {
state: () => ({
installed: null, // null = unknown, checked once on first navigation
}),
actions: {
async check() {
try {
const { data } = await api.get('/setup/status');
this.installed = !!data.installed;
} catch {
// Fail open: if the backend is unreachable, don't trap the user
// on the setup screen; the auth views will show the real error.
this.installed = true;
}
return this.installed;
},
},
});
+94
View File
@@ -0,0 +1,94 @@
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import api from '../api';
import { useAuthStore } from '../stores/auth';
import { useSetupStore } from '../stores/setup';
import { useWorkspaceStore } from '../stores/workspace';
const router = useRouter();
const auth = useAuthStore();
const setup = useSetupStore();
const ws = useWorkspaceStore();
const step = ref(1);
const name = ref('');
const email = ref('');
const password = ref('');
const confirm = ref('');
const workspaceName = ref('My workspace');
const err = ref('');
const busy = ref(false);
function next() {
err.value = '';
if (step.value === 2) {
if (!name.value.trim()) { err.value = 'Please enter a display name.'; return; }
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email.value)) { err.value = 'Please enter a valid email.'; return; }
if (password.value.length < 8) { err.value = 'Password must be at least 8 characters.'; return; }
if (password.value !== confirm.value) { err.value = 'Passwords do not match.'; return; }
}
step.value += 1;
}
async function finish() {
err.value = '';
busy.value = true;
try {
const { data } = await api.post('/setup', {
adminName: name.value.trim(),
adminEmail: email.value.trim(),
adminPassword: password.value,
workspaceName: workspaceName.value.trim() || 'My workspace',
});
auth.apply({ user: data.user, token: data.token });
setup.installed = true;
ws.workspaces = [];
ws.setCurrent(data.workspace.id);
await ws.fetchWorkspaces();
await ws.fetchPages();
router.push('/');
} catch (e) {
err.value = e.response?.data?.error || 'Setup failed. Is the backend reachable?';
} finally {
busy.value = false;
}
}
</script>
<template>
<div class="auth-wrap card">
<h1>Welcome to openStack</h1>
<p class="muted">Step {{ Math.min(step, 3) }} of 3 — first-run setup</p>
<div v-if="step === 1">
<p>This wizard creates your <strong>admin account</strong> and your first
<strong>workspace</strong>. It only runs once.</p>
<button @click="step = 2">Get started</button>
</div>
<div v-if="step === 2" style="display: grid; gap: 10px">
<h3 style="margin: 0">Admin account</h3>
<input v-model="name" placeholder="Display name" />
<input v-model="email" type="email" placeholder="Admin email" />
<input v-model="password" type="password" placeholder="Password (min 8 chars)" />
<input v-model="confirm" type="password" placeholder="Confirm password" />
<div class="row">
<button class="ghost" @click="step = 1">Back</button>
<button @click="next">Continue</button>
</div>
</div>
<div v-if="step === 3" style="display: grid; gap: 10px">
<h3 style="margin: 0">First workspace</h3>
<p class="muted">Workspaces hold pages. Teammates join via invite.</p>
<input v-model="workspaceName" placeholder="Workspace name" />
<div class="row">
<button class="ghost" @click="step = 2">Back</button>
<button @click="finish" :disabled="busy">{{ busy ? 'Installing…' : 'Finish setup' }}</button>
</div>
</div>
<p v-if="err" style="color: red">{{ err }}</p>
</div>
</template>