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:
@@ -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');
|
||||
|
||||
@@ -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 '/';
|
||||
|
||||
@@ -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