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