diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 80f69d0..dc4ecd3 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -754,6 +754,15 @@ export const pluginsApi = { } } +export const setupApi = { + seedStarter() { + return api.post('/setup/seed-starter') + }, + complete() { + return api.post('/setup/complete') + } +} + export const settingsApi = { list(params = {}) { return api.get('/settings', { params }) diff --git a/frontend/src/composables/setupState.js b/frontend/src/composables/setupState.js new file mode 100644 index 0000000..c1e2905 --- /dev/null +++ b/frontend/src/composables/setupState.js @@ -0,0 +1,28 @@ +// Tracks whether first-run setup is complete, so the app can steer a fresh +// admin into the /setup wizard. Defaults to "complete" so the wizard never +// flashes before the real value loads. +import { ref } from 'vue' +import { settingsApi } from '../api' + +export const setupComplete = ref(true) +// Session-only: a fresh admin who clicks "Skip for now" is not nagged again +// until the next login/reload. +export const setupSkipped = ref(false) +const loaded = ref(false) + +export function isSetupLoaded() { + return loaded.value +} + +export async function refreshSetupState() { + try { + const response = await settingsApi.get('setup_complete') + const value = response.data?.data?.value + setupComplete.value = value === true || value === 'true' + } catch (err) { + // If we cannot read it, assume complete so we do not trap the user. + setupComplete.value = true + } + loaded.value = true + return setupComplete.value +} diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 3e11e4e..a442c5c 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router' import { useAuthStore } from '../stores/auth' import AppLayout from '../views/AppLayout.vue' import SettingsLayout from '../views/settings/SettingsLayout.vue' +import { setupComplete, setupSkipped, isSetupLoaded, refreshSetupState } from '../composables/setupState' // Auto-discover all route modules from routes/ directory const routeModules = import.meta.glob('./routes/*.js', { eager: true }) @@ -42,6 +43,13 @@ const routes = [ component: () => import('../views/Login.vue'), meta: { guest: true } }, + // First-run setup wizard (standalone, admin-only) + { + path: '/setup', + name: 'setup', + component: () => import('../views/SetupWizard.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, // Standalone full-screen dashboards (no sidebar, no auth required) { path: '/shopfloor', @@ -87,18 +95,30 @@ const router = createRouter({ }) // Navigation guard -router.beforeEach((to, from, next) => { +router.beforeEach(async (to, from, next) => { const authStore = useAuthStore() if (to.meta.requiresAuth && !authStore.isAuthenticated) { - next('/login') - } else if (to.meta.requiresAdmin && !authStore.isAdmin) { - next('/') - } else if (to.meta.guest && authStore.isAuthenticated) { - next('/') - } else { - next() + return next('/login') } + if (to.meta.requiresAdmin && !authStore.isAdmin) { + return next('/') + } + if (to.meta.guest && authStore.isAuthenticated) { + return next('/') + } + + // First-run: steer a fresh admin into the setup wizard (once, until finished). + if (authStore.isAuthenticated && authStore.isAdmin && to.path !== '/setup') { + if (!isSetupLoaded()) { + await refreshSetupState() + } + if (!setupComplete.value && !setupSkipped.value) { + return next('/setup') + } + } + + next() }) export default router diff --git a/frontend/src/views/SetupWizard.vue b/frontend/src/views/SetupWizard.vue new file mode 100644 index 0000000..9eca05f --- /dev/null +++ b/frontend/src/views/SetupWizard.vue @@ -0,0 +1,242 @@ + + + + + diff --git a/shopdb/__init__.py b/shopdb/__init__.py index 597e802..981bc10 100644 --- a/shopdb/__init__.py +++ b/shopdb/__init__.py @@ -110,6 +110,7 @@ CORE_BLUEPRINT_NAMES = ( 'auditlogs', 'users', 'customfields', + 'setup', ) diff --git a/shopdb/core/api/__init__.py b/shopdb/core/api/__init__.py index 3d0010b..f260b1c 100644 --- a/shopdb/core/api/__init__.py +++ b/shopdb/core/api/__init__.py @@ -19,6 +19,7 @@ from .settings import settings_bp from .auditlogs import auditlogs_bp from .users import users_bp from .customfields import customfields_bp +from .setup import setup_bp __all__ = [ 'auth_bp', @@ -40,4 +41,5 @@ __all__ = [ 'auditlogs_bp', 'users_bp', 'customfields_bp', + 'setup_bp', ] diff --git a/shopdb/core/api/settings.py b/shopdb/core/api/settings.py index 977301b..62ce1f5 100644 --- a/shopdb/core/api/settings.py +++ b/shopdb/core/api/settings.py @@ -253,6 +253,13 @@ def build_default_settings(): # the shopfloor dashboard. Blank site_base_url falls back to the browsing # origin so nothing breaks before a site configures it. sitedefaults = [ + { + 'key': 'setup_complete', + 'value': 'false', + 'valuetype': 'boolean', + 'category': 'site', + 'description': 'Set true once the first-run setup wizard has been finished' + }, { 'key': 'site_base_url', 'value': '', diff --git a/shopdb/core/api/setup.py b/shopdb/core/api/setup.py new file mode 100644 index 0000000..42c4461 --- /dev/null +++ b/shopdb/core/api/setup.py @@ -0,0 +1,62 @@ +"""First-run setup wizard support endpoints. + +The wizard itself is frontend; these endpoints cover the pieces that need +server work: seeding common starter data and marking setup finished. Site, +plugin, and map configuration reuse the existing settings / plugins APIs. +""" + +from flask import Blueprint +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import Vendor, Setting +from shopdb.utils.responses import success_response +from shopdb.utils.authz import require_role + +setup_bp = Blueprint('setup', __name__) + +# Common hardware vendors most sites will want on hand. +STARTER_VENDORS = [ + ('Dell Inc.', 'https://www.dell.com'), + ('HP Inc.', 'https://www.hp.com'), + ('Lenovo', 'https://www.lenovo.com'), + ('Xerox', 'https://www.xerox.com'), + ('Zebra Technologies', 'https://www.zebra.com'), + ('Cisco', 'https://www.cisco.com'), + ('Brother', 'https://www.brother.com'), + ('Microsoft', 'https://www.microsoft.com'), +] + + +@setup_bp.route('/seed-starter', methods=['POST']) +@jwt_required() +@require_role('admin') +def seed_starter(): + """Add common vendors that are not already present. Idempotent.""" + existing = {v.vendor.strip().lower() for v in Vendor.query.all()} + added = [] + for name, website in STARTER_VENDORS: + if name.strip().lower() in existing: + continue + db.session.add(Vendor(vendor=name, website=website)) + added.append(name) + db.session.commit() + return success_response( + {'added': added, 'addedcount': len(added)}, + message=f'Seeded {len(added)} vendor(s).' + ) + + +@setup_bp.route('/complete', methods=['POST']) +@jwt_required() +@require_role('admin') +def mark_complete(): + """Flag the first-run setup as finished.""" + row = Setting.query.filter_by(key='setup_complete').first() + if row: + row.value = 'true' + else: + db.session.add(Setting(key='setup_complete', value='true', + valuetype='boolean', category='site')) + db.session.commit() + return success_response({'complete': True}, message='Setup marked complete.')