diff --git a/README.md b/README.md index 384dda6..ffc40c7 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,9 @@ cd frontend && npm install && npm run dev # -> http://localhost:5173 ``` -Default dev flow creates the first user via **Register** in the UI; that user can then -create workspaces and invite others by email (invited email must have an account). +Open the frontend: on first start a **web installer** guides you through creating the +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) diff --git a/backend/package.json b/backend/package.json index fa74232..5019204 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/db/schema.sql b/backend/src/db/schema.sql index b598eac..e154c0a 100644 --- a/backend/src/db/schema.sql +++ b/backend/src/db/schema.sql @@ -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 diff --git a/backend/src/index.js b/backend/src/index.js index ee8665c..3c07d00 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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); diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index bd41f27..6de6c39 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -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 } diff --git a/backend/src/routes/setup.js b/backend/src/routes/setup.js new file mode 100644 index 0000000..632184d --- /dev/null +++ b/backend/src/routes/setup.js @@ -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; diff --git a/docs/HOSTING.md b/docs/HOSTING.md index e9237ac..b6ebb5c 100644 --- a/docs/HOSTING.md +++ b/docs/HOSTING.md @@ -122,6 +122,9 @@ curl http://localhost:3000/api/health - Frontend: `http://:8080` - Backend API: `http://:3000/api/health` - 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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f6c16bb..c6d8359 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -3,6 +3,7 @@ ## Phase 1 — Web app MVP (this repo, v0.1) ✅ - [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] Socket.IO realtime (live updates, presence, typing) - [x] Light/dark mode diff --git a/docs/SETUP.md b/docs/SETUP.md index a1d6ce2..43f7776 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -57,8 +57,10 @@ npm install npm run dev # http://localhost:5173 ``` -Open the frontend, **Register** the first user, create a workspace, invite teammates -by email (they must register with the same email first). +Open the frontend, where the **first-run web installer** (`/setup`) creates the admin +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 diff --git a/frontend/src/api.js b/frontend/src/api.js index 4af03ef..04219fd 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -13,6 +13,10 @@ api.interceptors.request.use((config) => { api.interceptors.response.use( (r) => r, (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')) { localStorage.removeItem('token'); localStorage.removeItem('user'); diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 59ba900..a48efc5 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -1,11 +1,14 @@ import { createRouter, createWebHistory } from 'vue-router'; import { useAuthStore } from '../stores/auth'; +import { useSetupStore } from '../stores/setup'; import Login from '../views/Login.vue'; import Register from '../views/Register.vue'; +import Setup from '../views/Setup.vue'; import Dashboard from '../views/Dashboard.vue'; import PageEditor from '../views/PageEditor.vue'; const routes = [ + { path: '/setup', component: Setup }, { path: '/login', component: Login, meta: { guest: true } }, { path: '/register', component: Register, meta: { guest: true } }, { path: '/', component: Dashboard, meta: { auth: true } }, @@ -14,7 +17,11 @@ const 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(); if (to.meta.auth && !auth.isLoggedIn) return '/login'; if (to.meta.guest && auth.isLoggedIn) return '/'; diff --git a/frontend/src/stores/setup.js b/frontend/src/stores/setup.js new file mode 100644 index 0000000..b21f19f --- /dev/null +++ b/frontend/src/stores/setup.js @@ -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; + }, + }, +}); diff --git a/frontend/src/views/Setup.vue b/frontend/src/views/Setup.vue new file mode 100644 index 0000000..e68e6a5 --- /dev/null +++ b/frontend/src/views/Setup.vue @@ -0,0 +1,94 @@ + + +