First-run creates the superadmin in-app; wizard seeds reference data

No CLI needed for a UI-driven install.

- Login page: when the instance has no users yet (/setup/needs-admin), it shows
  a "create the first admin" form instead of login. Submitting creates the admin
  (admin role = full access / superadmin) via /setup/create-admin, logs in, and
  goes to the setup wizard.
- Wizard Starter Data step: "Seed core reference data" button (statuses, types,
  permissions, default settings via /setup/seed-reference) alongside the common
  vendors, so a UI-first install gets the data the app needs.
- Add waitress to requirements.txt (the IIS web.config launches it but it was
  missing - found while validating the Windows install).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-10 11:53:38 -04:00
parent 301eaa6375
commit 57efb5f965
3 changed files with 90 additions and 32 deletions

View File

@@ -4,34 +4,40 @@
<img src="/ge-aerospace-logo.svg" alt="GE Aerospace" class="login-logo" />
<h1>ShopDB</h1>
<div v-if="error" class="error-message">
{{ error }}
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<form @submit.prevent="handleLogin">
<!-- First run: no users yet - create the first admin -->
<template v-if="mode === 'createadmin'">
<p class="first-run-note">First run - create your admin account.</p>
<form @submit.prevent="handleCreateAdmin">
<div class="form-group">
<label for="newusername">Username <span class="hint">(your SSO)</span></label>
<input id="newusername" v-model="username" type="text" class="form-control" required autofocus />
</div>
<div class="form-group">
<label for="email">Email</label>
<input id="email" v-model="email" type="email" class="form-control" required />
</div>
<div class="form-group">
<label for="newpassword">Password</label>
<input id="newpassword" v-model="password" type="password" class="form-control" required minlength="8" />
</div>
<button type="submit" class="btn btn-primary" :disabled="loading">
{{ loading ? 'Creating...' : 'Create admin & continue' }}
</button>
</form>
</template>
<!-- Normal login -->
<form v-else @submit.prevent="handleLogin">
<div class="form-group">
<label for="username">Username</label>
<input
id="username"
v-model="username"
type="text"
class="form-control"
required
autofocus
/>
<input id="username" v-model="username" type="text" class="form-control" required autofocus />
</div>
<div class="form-group">
<label for="password">Password</label>
<input
id="password"
v-model="password"
type="password"
class="form-control"
required
/>
<input id="password" v-model="password" type="password" class="form-control" required />
</div>
<button type="submit" class="btn btn-primary" :disabled="loading">
{{ loading ? 'Logging in...' : 'Login' }}
</button>
@@ -41,30 +47,56 @@
</template>
<script setup>
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { setupApi } from '../api'
const router = useRouter()
const authStore = useAuthStore()
const mode = ref('login')
const username = ref('')
const email = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)
onMounted(async () => {
// If the instance has no users yet, offer to create the first admin.
try {
const response = await setupApi.needsAdmin()
if (response.data?.data?.needsadmin) mode.value = 'createadmin'
} catch (err) { /* fall back to normal login */ }
})
async function handleLogin() {
error.value = ''
loading.value = true
const result = await authStore.login(username.value, password.value)
loading.value = false
if (result.success) router.push('/')
else error.value = result.message
}
if (result.success) {
router.push('/')
} else {
error.value = result.message
async function handleCreateAdmin() {
error.value = ''
loading.value = true
try {
await setupApi.createAdmin({ username: username.value, email: email.value, password: password.value })
// 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')
else { mode.value = 'login'; error.value = 'Admin created - please sign in.' }
} catch (err) {
error.value = err.response?.data?.data?.error?.message || err.response?.data?.message || 'Could not create admin'
} finally {
loading.value = false
}
}
</script>
<style scoped>
.first-run-note { color: var(--text-light); font-size: 0.9rem; margin-bottom: 1rem; }
.hint { color: var(--text-light); font-weight: normal; font-size: 0.85em; }
</style>

View File

@@ -112,10 +112,21 @@
<!-- Starter data -->
<div v-else-if="current.key === 'data'">
<h2>Starter data</h2>
<p class="hint">Add common hardware vendors so you can start adding assets right away. Safe to run - it skips ones you already have.</p>
<button class="btn btn-secondary" :disabled="seeding" @click="seedStarter">
{{ seeding ? 'Adding...' : 'Add common vendors' }}
</button>
<p class="hint">Seed the data a new site needs. Both are idempotent - safe to run more than once.</p>
<div class="starter-actions">
<div>
<button class="btn btn-secondary" :disabled="seeding" @click="seedReference">
{{ seeding ? 'Working...' : 'Seed core reference data' }}
</button>
<p class="hint">Statuses, machine/location/relationship types, permissions, default settings. Run this if you installed without the CLI seed.</p>
</div>
<div>
<button class="btn btn-secondary" :disabled="seeding" @click="seedStarter">
{{ seeding ? 'Working...' : 'Add common vendors' }}
</button>
<p class="hint">Dell, HP, Lenovo, and other common hardware vendors.</p>
</div>
</div>
<p v-if="seedResult" class="seed-result">{{ seedResult }}</p>
</div>
@@ -325,6 +336,18 @@ async function togglePlugin(plugin, enabled) {
}
}
async function seedReference() {
seeding.value = true
try {
const response = await setupApi.seedReference()
seedResult.value = response.data?.message || 'Reference data seeded.'
} catch (err) {
toast.error(apiError(err, 'Could not seed reference data'))
} finally {
seeding.value = false
}
}
async function seedStarter() {
seeding.value = true
try {
@@ -384,6 +407,8 @@ async function finish() {
.provision-note p:last-child { margin-bottom: 0; }
.provision-tables code, .provision-docs code { background: var(--bg-card); border: 1px solid var(--border); border-radius: 3px; padding: 0 0.3rem; margin-right: 0.3rem; font-size: 0.78rem; }
.seed-result { margin-top: 0.75rem; color: var(--success); font-size: 0.88rem; }
.starter-actions { display: flex; flex-direction: column; gap: 1.1rem; }
.starter-actions .hint { margin: 0.35rem 0 0; }
.config-block { margin-top: 1.25rem; padding-top: 1rem; border-top: 1px solid var(--border); }
.config-title { margin: 0 0 0.75rem; font-size: 1rem; text-transform: capitalize; }
.secret-tag { margin-left: 0.5rem; font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--warning); border: 1px solid var(--warning); border-radius: 4px; padding: 0 0.3rem; }

View File

@@ -117,3 +117,4 @@ werkzeug==3.1.8
# flask-cors
# flask-jwt-extended
# pytest-flask
waitress>=3.0