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. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
// admin into the /setup wizard. Defaults to "complete" so the wizard never
|
// admin into the /setup wizard. Defaults to "complete" so the wizard never
|
||||||
// flashes before the real value loads.
|
// flashes before the real value loads.
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { settingsApi } from '../api'
|
import { settingsApi, setupApi } from '../api'
|
||||||
|
|
||||||
export const setupComplete = ref(true)
|
export const setupComplete = ref(true)
|
||||||
// Session-only: a fresh admin who clicks "Skip for now" is not nagged again
|
// Session-only: a fresh admin who clicks "Skip for now" is not nagged again
|
||||||
@@ -26,3 +26,31 @@ export async function refreshSetupState() {
|
|||||||
loaded.value = true
|
loaded.value = true
|
||||||
return setupComplete.value
|
return setupComplete.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// First run, before any user exists. Defaults to false so the app never flashes
|
||||||
|
// the create-admin screen at an established site while this loads.
|
||||||
|
export const needsAdmin = ref(false)
|
||||||
|
const adminLoaded = ref(false)
|
||||||
|
|
||||||
|
export function isNeedsAdminLoaded() {
|
||||||
|
return adminLoaded.value
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshNeedsAdmin() {
|
||||||
|
try {
|
||||||
|
const response = await setupApi.needsAdmin()
|
||||||
|
needsAdmin.value = response.data?.data?.needsadmin === true
|
||||||
|
} catch (err) {
|
||||||
|
// Fail open: if we cannot tell, assume an admin exists rather than trap
|
||||||
|
// everyone behind a create-admin screen they cannot complete.
|
||||||
|
needsAdmin.value = false
|
||||||
|
}
|
||||||
|
adminLoaded.value = true
|
||||||
|
return needsAdmin.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called after the first admin is created so the gate stops firing.
|
||||||
|
export function clearNeedsAdmin() {
|
||||||
|
needsAdmin.value = false
|
||||||
|
adminLoaded.value = true
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { createRouter, createWebHistory } from 'vue-router'
|
|||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import AppLayout from '../views/AppLayout.vue'
|
import AppLayout from '../views/AppLayout.vue'
|
||||||
import SettingsLayout from '../views/settings/SettingsLayout.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 { loadEnabledPlugins, isPluginEnabled } from '../composables/enabledPlugins'
|
||||||
import { useToast } from '../composables/toast'
|
import { useToast } from '../composables/toast'
|
||||||
import { getFacilityName } from '../utils/siteSettings'
|
import { getFacilityName } from '../utils/siteSettings'
|
||||||
@@ -196,6 +197,24 @@ router.afterEach(() => {
|
|||||||
router.beforeEach(async (to, from, next) => {
|
router.beforeEach(async (to, from, next) => {
|
||||||
const authStore = useAuthStore()
|
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) {
|
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||||
// Remember where they were headed so login can send them back there.
|
// Remember where they were headed so login can send them back there.
|
||||||
return next({ path: '/login', query: { redirect: to.fullPath } })
|
return next({ path: '/login', query: { redirect: to.fullPath } })
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import { ref, onMounted } from 'vue'
|
|||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { setupApi } from '../api'
|
import { setupApi } from '../api'
|
||||||
|
import { clearNeedsAdmin } from '../composables/setupState'
|
||||||
import { getSiteLogo } from '../utils/siteSettings'
|
import { getSiteLogo } from '../utils/siteSettings'
|
||||||
import { withBase, stripBase } from '../utils/basePath'
|
import { withBase, stripBase } from '../utils/basePath'
|
||||||
|
|
||||||
@@ -102,6 +103,7 @@ async function handleCreateAdmin() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
await setupApi.createAdmin({ username: username.value, email: email.value, password: password.value })
|
await setupApi.createAdmin({ username: username.value, email: email.value, password: password.value })
|
||||||
|
clearNeedsAdmin() // an admin now exists; stop the first-run gate firing
|
||||||
// Log straight in with the new credentials, then on to the setup wizard.
|
// Log straight in with the new credentials, then on to the setup wizard.
|
||||||
const result = await authStore.login(username.value, password.value)
|
const result = await authStore.login(username.value, password.value)
|
||||||
if (result.success) router.push('/setup')
|
if (result.success) router.push('/setup')
|
||||||
|
|||||||
Reference in New Issue
Block a user