Plugins list now carries a displayname (manifest display_name, else the machine name title-cased). Adds display_name to the four whose title-case was wrong: GE-Enforce, USB, Measuring Tools, Knowledge Base. The setup wizard Features step and Settings > Plugins render it, so "Geenforce"/"Usb" are gone. Finish step shows a pointer when GE-Enforce is enabled: it still needs a scoped service token (Settings > API Tokens) and a share export root (GE-Enforce page) before the fleet uses it - operational config the wizard does not collect. frontend build green; naming green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
459 lines
20 KiB
Vue
459 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 ? '✓' : 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="West Jefferson" />
|
|
</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 > 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="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="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>Starter data</h2>
|
|
<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>
|
|
|
|
<!-- 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 > 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 { 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: 'Starter 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>
|