Files
shopdb-flask/frontend/src/views/SetupWizard.vue
cproudlock 035419fa51 ADR-015: stop shipping one site's values, and make the rule a gate
The scanner has been reporting the same count for weeks, which is what a rule
that only prints becomes. It now FAILS the build, and it looks where the leaks
actually were: PowerShell, the installer, the seeds, generated JSON, the
frontend - case-insensitively, across plugins, shopdb, scripts, deploy, tools.
A line that is deliberate declares itself with an ADR-015-OK marker and a
reason, so the claim is visible in review instead of tolerated in silence.

What it found, fixed here:

- The shadow client wrote one site's ShopDB URL into HKLM whenever the registry
  disagreed. At the site it was written for that reads as healing drift;
  anywhere else it overwrites the site's own address on every enforce cycle,
  and the site cannot win because the cycle repeats. The bay's value now wins,
  an explicit -BaseUrl seeds it, and with neither there is nothing honest to
  write, so it says so and skips.
- The kiosk dispatcher fell back to one plant's host when HKLM was unset, so a
  kiosk elsewhere quietly opened a server it has no business reaching. The
  fallback is now this site's site_base_url, baked in at seed time, and the
  dispatcher refuses rather than guessing when neither is set. Its legacy
  shortcut matcher derives the host from that URL instead of naming one.
- The OpenAPI generator hardcoded a production hostname into every spec it
  generated, which then published to a public wiki. The relative mount is the
  only server it can honestly name; a site passes its own by environment.
- Placeholders and examples in the UI and the client help offered real internal
  subnets and a real production URL. They now use documentation ranges.

Both publication gates - the export scrub and the docs publishability test -
carry the site patterns, which neither did. One plant's hostname, FQDN and
internal networks are out of the documentation and the generated specs.

Comments naming the reference site are reworded rather than deleted: the
reasoning is worth keeping, the plant name is not what makes it true.
2026-08-14 13:47:39 -04:00

461 lines
20 KiB
Vue

<template>
<div class="setup-wizard">
<div class="wizard-card">
<header class="wizard-head">
<h1>Set up ShopDB</h1>
<p class="wizard-sub">A few quick steps to get this site ready.</p>
<ol class="wizard-steps">
<li v-for="(s, i) in steps" :key="s.key" :class="{ active: i === step, done: i < step }">
<span class="step-num">{{ i < step ? '&check;' : i + 1 }}</span>
<span class="step-label">{{ s.label }}</span>
</li>
</ol>
</header>
<section class="wizard-body">
<!-- Site -->
<div v-if="current.key === 'site'">
<h2>Site details</h2>
<p class="hint">Used for the dashboard header and QR / absolute links.</p>
<div class="form-group">
<label>Facility name</label>
<input v-model="form.facility_name" type="text" class="form-control" placeholder="Main Plant" />
</div>
<div class="form-group">
<label>Site base URL <span class="hint">(blank = use the browsing origin)</span></label>
<input v-model="form.site_base_url" type="url" class="form-control" placeholder="https://shopdb.example.net" />
</div>
<div class="form-group">
<label>Site logo <span class="hint">(optional)</span></label>
<input type="file" accept="image/*" @change="uploadSiteLogo($event)" :disabled="logoUploading" />
<img v-if="siteLogo" :src="siteLogo" class="wizard-map-thumb" alt="site logo" />
<small class="hint">Skip to keep the shipped GE branding. You can change this later under Settings &gt; Branding.</small>
</div>
</div>
<!-- Plugins -->
<div v-else-if="current.key === 'plugins'">
<h2>Features</h2>
<p class="hint">Turn features on or off. Changes to routes take effect after the app restarts.</p>
<div v-if="plugins.length" class="plugin-list">
<div v-for="p in plugins" :key="p.name" class="plugin-item">
<label class="plugin-row">
<input type="checkbox" :checked="p.enabled" @change="togglePlugin(p, $event.target.checked)" />
<span class="plugin-name">{{ p.displayname || p.name }}</span>
<span class="plugin-desc">{{ p.description }}</span>
</label>
<!-- Plugins that provision tables: choose to create them here or
connect an existing database -->
<div v-if="p.enabled && p.provisioning_note && p.provisioning_note.mode_setting" class="plugin-mode">
<label class="mode-opt">
<input type="radio" value="selfhosted" v-model="pluginModes[p.provisioning_note.mode_setting]" />
Create the tables here <span class="hint">(recommended)</span>
</label>
<label class="mode-opt">
<input type="radio" value="external" v-model="pluginModes[p.provisioning_note.mode_setting]" />
Connect to your own database
</label>
<!-- Self-hosted: transparency note -->
<div v-if="pluginModes[p.provisioning_note.mode_setting] !== 'external'" class="provision-note">
<p>{{ p.provisioning_note.note }}</p>
<p class="provision-tables">Creates in shopdb: <code v-for="t in p.provisioning_note.tables" :key="t">{{ t }}</code></p>
<p v-if="p.provisioning_note.docs" class="provision-docs">Schema: <code>{{ p.provisioning_note.docs }}</code></p>
</div>
<!-- External: connection config -->
<div v-else class="config-block">
<div v-for="field in p.config_schema" :key="field.key" class="form-group">
<label>{{ field.label }}<span v-if="field.secret" class="secret-tag">secret</span></label>
<input
:type="field.type === 'password' ? 'password' : (field.type === 'number' ? 'number' : 'text')"
:placeholder="field.default || ''"
v-model="pluginConfig[field.key]"
class="form-control" />
<small v-if="field.help" class="hint">{{ field.help }}</small>
</div>
</div>
</div>
</div>
</div>
<p v-else class="hint">No optional plugins found.</p>
<!-- Secrets are never stored in the DB; emit .env lines to paste -->
<div v-if="envLines" class="env-block">
<p class="hint">Paste these into <code>.env</code> and restart the app (secrets are not stored in the database):</p>
<pre class="env-pre">{{ envLines }}</pre>
<button type="button" class="btn btn-secondary btn-sm" @click="copyEnv">Copy</button>
</div>
</div>
<!-- Floor map -->
<div v-else-if="current.key === 'map'">
<h2>Floor map</h2>
<p class="hint">Upload your facility blueprint (light and dark), and set its native pixel size.</p>
<div class="form-group">
<label>Blueprint image (light theme)</label>
<input type="file" accept="image/*" @change="uploadBlueprint('light', $event)" :disabled="mapUploading" />
<img v-if="blueprintLight" :src="withBase(blueprintLight)" class="wizard-map-thumb" alt="light blueprint" />
</div>
<div class="form-group">
<label>Blueprint image (dark theme)</label>
<input type="file" accept="image/*" @change="uploadBlueprint('dark', $event)" :disabled="mapUploading" />
<img v-if="blueprintDark" :src="withBase(blueprintDark)" class="wizard-map-thumb dark" alt="dark blueprint" />
</div>
<div class="form-row">
<div class="form-group">
<label>Map width (px)</label>
<input v-model.number="form.map_width" type="number" class="form-control" placeholder="2000" />
</div>
<div class="form-group">
<label>Map height (px)</label>
<input v-model.number="form.map_height" type="number" class="form-control" placeholder="1200" />
</div>
</div>
</div>
<!-- Starter data -->
<div v-else-if="current.key === 'data'">
<h2>Reference data</h2>
<p class="hint">Seed the lookup data a new site needs: statuses, types, permissions, and a short list of common vendors. Both are idempotent - safe to run more than once.</p>
<p class="hint">This does not create any assets. The site starts empty and assets are loaded afterwards via the import API, so an empty dashboard here is expected.</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>
<!-- Finish -->
<div v-else-if="current.key === 'finish'">
<h2>All set</h2>
<p class="hint">You can change any of this later under Settings. Finish to go to the dashboard.</p>
<div v-if="geenforceEnabled" class="finish-note">
<strong>GE-Enforce is on.</strong> Two more steps before the fleet
uses it: create a service token with the geenforce scopes under
Settings &gt; API Tokens, and set the on-share export root on the
GE-Enforce page.
</div>
</div>
</section>
<footer class="wizard-foot">
<button v-if="step > 0" class="btn btn-secondary" @click="back">Back</button>
<div class="foot-right">
<button type="button" class="btn btn-text" @click="skipForNow">Skip for now</button>
<button v-if="step < steps.length - 1" class="btn btn-primary" :disabled="saving" @click="next">
{{ saving ? 'Saving...' : 'Next' }}
</button>
<button v-else class="btn btn-primary" :disabled="saving" @click="finish">
{{ saving ? 'Finishing...' : 'Finish' }}
</button>
</div>
</footer>
</div>
</div>
</template>
<script setup>
import { withBase } from '../utils/basePath'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { settingsApi, pluginsApi, setupApi } from '../api'
import { useToast } from '../composables/toast'
import { apiError } from '../utils/apiError'
import { refreshSetupState, setupSkipped } from '../composables/setupState'
const router = useRouter()
const toast = useToast()
const steps = [
{ key: 'site', label: 'Site' },
{ key: 'plugins', label: 'Features' },
{ key: 'map', label: 'Floor Map' },
{ key: 'data', label: 'Reference Data' },
{ key: 'finish', label: 'Finish' },
]
const step = ref(0)
const current = computed(() => steps[step.value])
const form = ref({
facility_name: '', site_base_url: '',
map_width: null, map_height: null,
})
const plugins = ref([])
const pluginConfig = ref({}) // flat map: setting/field key -> value
const pluginModes = ref({}) // mode_setting key -> 'selfhosted' | 'external'
const saving = ref(false)
const seeding = ref(false)
const seedResult = ref('')
const mapUploading = ref(false)
const blueprintLight = ref('')
const blueprintDark = ref('')
const logoUploading = ref(false)
const siteLogo = ref('')
async function uploadSiteLogo(event) {
const file = event.target.files[0]
if (!file) return
logoUploading.value = true
try {
const { data } = await settingsApi.uploadBrandingLogo('site', file)
siteLogo.value = data?.data?.value || ''
toast.success('Logo uploaded')
} catch (err) {
toast.error(apiError(err, 'Upload failed'))
} finally {
logoUploading.value = false
event.target.value = ''
}
}
async function uploadBlueprint(theme, event) {
const file = event.target.files[0]
if (!file) return
mapUploading.value = true
try {
const { data } = await settingsApi.uploadMapBlueprint(theme, file)
const url = data?.data?.value
if (theme === 'light') blueprintLight.value = url
else blueprintDark.value = url
toast.success('Blueprint uploaded')
} catch (err) {
toast.error(apiError(err, 'Upload failed'))
} finally {
mapUploading.value = false
event.target.value = ''
}
}
function modeOf(plugin) {
const key = plugin.provisioning_note?.mode_setting
return key ? (pluginModes.value[key] || 'selfhosted') : null
}
// GE-Enforce needs post-setup operational config (service token + share root)
// the wizard does not collect; the Finish step points there when it is on.
const geenforceEnabled = computed(() =>
plugins.value.some(p => p.name === 'geenforce' && p.enabled))
// Enabled plugins whose external connection config should be collected right
// now: those in external mode (or with config but no mode concept).
const configurablePlugins = computed(() =>
plugins.value.filter(p => p.enabled && (p.config_schema || []).length &&
(!p.provisioning_note?.mode_setting || modeOf(p) === 'external')))
// .env lines for secret fields that have a value entered.
const envLines = computed(() => {
const lines = []
for (const plugin of configurablePlugins.value) {
for (const field of plugin.config_schema) {
const value = pluginConfig.value[field.key]
if (field.secret && field.envvar && value) {
lines.push(`${field.envvar}=${value}`)
}
}
}
return lines.join('\n')
})
// Which settings each step owns, so Next only saves what changed on that step.
const stepSettings = {
site: ['facility_name', 'site_base_url'],
map: ['map_width', 'map_height'],
}
onMounted(async () => {
// Load current values for the settings the wizard edits.
const keys = ['facility_name', 'site_base_url', 'map_width', 'map_height']
for (const key of keys) {
try {
const response = await settingsApi.get(key)
const value = response.data?.data?.value
if (value !== undefined && value !== null && value !== '') form.value[key] = value
} catch (err) { /* setting may not exist yet */ }
}
try {
const response = await pluginsApi.list()
plugins.value = response.data.data?.plugins || response.data.data || []
} catch (err) { /* ignore */ }
// Preload directory mode (defaults self-hosted) + non-secret config fields.
for (const plugin of plugins.value) {
const modeKey = plugin.provisioning_note?.mode_setting
if (modeKey && pluginModes.value[modeKey] === undefined) {
try {
const response = await settingsApi.get(modeKey)
pluginModes.value[modeKey] = response.data?.data?.value || 'selfhosted'
} catch (err) { pluginModes.value[modeKey] = 'selfhosted' }
}
for (const field of (plugin.config_schema || [])) {
if (field.secret) continue
try {
const response = await settingsApi.get(field.key)
const value = response.data?.data?.value
if (value !== undefined && value !== null && value !== '') pluginConfig.value[field.key] = value
} catch (err) { /* not set yet */ }
}
}
})
async function saveStep() {
// Plugins step: save each plugin's directory mode, plus non-secret external
// connection config (secrets stay in .env).
if (current.value.key === 'plugins') {
for (const [modeKey, modeValue] of Object.entries(pluginModes.value)) {
await settingsApi.update(modeKey, modeValue)
}
for (const plugin of configurablePlugins.value) {
for (const field of plugin.config_schema) {
if (field.secret) continue
const value = pluginConfig.value[field.key]
if (value === undefined || value === null || value === '') continue
await settingsApi.update(field.key, String(value))
}
}
return
}
const keys = stepSettings[current.value.key]
if (!keys) return
for (const key of keys) {
const value = form.value[key]
if (value === null || value === undefined) continue
await settingsApi.update(key, String(value))
}
}
async function copyEnv() {
try {
await navigator.clipboard.writeText(envLines.value)
toast.success('.env lines copied.')
} catch (err) {
toast.error('Copy failed - select and copy manually.')
}
}
async function next() {
saving.value = true
try {
await saveStep()
step.value++
} catch (err) {
toast.error(apiError(err, 'Could not save this step'))
} finally {
saving.value = false
}
}
function back() { if (step.value > 0) step.value-- }
function skipForNow() {
setupSkipped.value = true
router.push('/')
}
async function togglePlugin(plugin, enabled) {
try {
await pluginsApi.setEnabled(plugin.name, enabled)
plugin.enabled = enabled
toast.success(`${plugin.name} ${enabled ? 'enabled' : 'disabled'} (restart to apply route changes).`)
} catch (err) {
toast.error(apiError(err, 'Could not change plugin'))
}
}
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 {
const response = await setupApi.seedStarter()
const data = response.data?.data || {}
seedResult.value = data.addedcount ? `Added ${data.addedcount}: ${data.added.join(', ')}` : 'All common vendors already present.'
} catch (err) {
toast.error(apiError(err, 'Could not seed starter data'))
} finally {
seeding.value = false
}
}
async function finish() {
saving.value = true
try {
await setupApi.complete()
await refreshSetupState()
toast.success('Setup complete.')
router.push('/')
} catch (err) {
toast.error(apiError(err, 'Could not finish setup'))
} finally {
saving.value = false
}
}
</script>
<style scoped>
.setup-wizard { display: flex; justify-content: center; padding: 2rem 1rem; }
.wizard-card { width: 100%; max-width: 640px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
.wizard-head { padding: 1.5rem 1.75rem 1rem; border-bottom: 1px solid var(--border); }
.wizard-head h1 { margin: 0; }
.wizard-sub { color: var(--text-light); margin: 0.25rem 0 1rem; }
.wizard-steps { list-style: none; display: flex; gap: 0.5rem; padding: 0; margin: 0; flex-wrap: wrap; }
.wizard-steps li { display: flex; align-items: center; gap: 0.4rem; font-size: 0.82rem; color: var(--text-light); }
.wizard-steps li.active { color: var(--primary); font-weight: 600; }
.wizard-steps li.done { color: var(--success); }
.step-num { display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: 50%; border: 1px solid currentColor; font-size: 0.72rem; }
.wizard-body { padding: 1.5rem 1.75rem; min-height: 220px; }
.wizard-body h2 { margin-top: 0; }
.hint { color: var(--text-light); font-size: 0.88rem; }
.form-group { margin-bottom: 1rem; }
.form-group label { display: block; margin-bottom: 0.3rem; font-weight: 500; }
.form-row { display: flex; gap: 1rem; }
.form-row .form-group { flex: 1; }
.plugin-list { display: flex; flex-direction: column; gap: 0.6rem; }
.plugin-row { display: grid; grid-template-columns: auto auto 1fr; gap: 0.6rem; align-items: baseline; padding: 0.5rem 0.6rem; background: var(--bg); border-radius: 6px; }
.plugin-name { font-weight: 600; }
.plugin-desc { color: var(--text-light); font-size: 0.85rem; }
.plugin-mode { margin: 0.4rem 0 0.2rem 1.9rem; }
.mode-opt { display: block; font-size: 0.86rem; margin-bottom: 0.3rem; cursor: pointer; }
.mode-opt input { margin-right: 0.4rem; }
.plugin-mode .provision-note, .plugin-mode .config-block { margin-left: 0; }
.provision-note { margin: 0.3rem 0 0.2rem 1.9rem; padding: 0.6rem 0.75rem; background: var(--bg); border-left: 3px solid var(--warning); border-radius: 4px; font-size: 0.82rem; }
.finish-note { margin: 0.75rem 0 0; padding: 0.6rem 0.75rem; background: var(--bg); border-left: 3px solid var(--primary); border-radius: 4px; font-size: 0.85rem; }
.provision-note p { margin: 0 0 0.35rem; }
.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; }
.env-block { margin-top: 1rem; }
.env-pre { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 0.75rem; overflow-x: auto; font-size: 0.82rem; margin: 0.5rem 0; }
.wizard-map-thumb { display: block; margin-top: 0.5rem; max-height: 90px; border: 1px solid var(--border); border-radius: 4px; background: #fff; }
.wizard-map-thumb.dark { background: #222; }
.wizard-foot { display: flex; justify-content: space-between; align-items: center; padding: 1rem 1.75rem; border-top: 1px solid var(--border); }
.foot-right { display: flex; align-items: center; gap: 0.75rem; }
.btn-text { background: none; border: none; color: var(--text-light); text-decoration: none; }
</style>