feat(frontend): gate first run on needs-admin so a fresh instance shows the wizard

A fresh install landed on the anonymous dashboard instead of prompting to create
the first admin, so an operator had no way to discover /setup.

The router now asks /api/setup/needs-admin before rendering any unauthenticated
route and redirects to /login?firstrun=1 while no user exists. The result is
cached in a composable so it costs one request per session, and the lookup fails
open (a backend that cannot answer must not lock the login screen). Racing it
against a 4s timeout keeps a slow or hung backend from blocking the first paint.

Login.vue clears the flag after creating the admin so the gate stops firing
without a reload.
This commit is contained in:
2026-08-02 16:08:42 -04:00
parent 6639afd1f0
commit 94d6878a03
3 changed files with 51 additions and 2 deletions

View File

@@ -2,7 +2,8 @@ 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'
import { setupComplete, setupSkipped, isSetupLoaded, refreshSetupState,
needsAdmin, isNeedsAdminLoaded, refreshNeedsAdmin } from '../composables/setupState'
import { loadEnabledPlugins, isPluginEnabled } from '../composables/enabledPlugins'
import { useToast } from '../composables/toast'
import { getFacilityName } from '../utils/siteSettings'
@@ -196,6 +197,24 @@ router.afterEach(() => {
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore()
// FIRST RUN: until an admin exists, nothing else in the app is useful. Without
// this, a fresh instance serves an anonymous dashboard and the operator has no
// indication that setup is unfinished (they must guess their way to /login).
// Unauthenticated only, so it costs an established site nothing.
if (!authStore.isAuthenticated && to.path !== '/login') {
if (!isNeedsAdminLoaded()) {
// Bounded like the plugin gate: a hung request must not hold navigation
// open. refreshNeedsAdmin fails open on error.
await Promise.race([
refreshNeedsAdmin(),
new Promise(resolve => setTimeout(resolve, 4000)),
])
}
if (needsAdmin.value) {
return next({ path: '/login', query: { firstrun: '1' } })
}
}
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
// Remember where they were headed so login can send them back there.
return next({ path: '/login', query: { redirect: to.fullPath } })