diff --git a/frontend/src/composables/setupState.js b/frontend/src/composables/setupState.js index c1e2905..67806a1 100644 --- a/frontend/src/composables/setupState.js +++ b/frontend/src/composables/setupState.js @@ -2,7 +2,7 @@ // 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' +import { settingsApi, setupApi } from '../api' export const setupComplete = ref(true) // 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 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 +} diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 03c7cbb..f01a802 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -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 } }) diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index a5c43be..646d32e 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -51,6 +51,7 @@ import { ref, onMounted } from 'vue' import { useRouter, useRoute } from 'vue-router' import { useAuthStore } from '../stores/auth' import { setupApi } from '../api' +import { clearNeedsAdmin } from '../composables/setupState' import { getSiteLogo } from '../utils/siteSettings' import { withBase, stripBase } from '../utils/basePath' @@ -102,6 +103,7 @@ async function handleCreateAdmin() { loading.value = true try { 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. const result = await authStore.login(username.value, password.value) if (result.success) router.push('/setup')