Setup wizard P2: per-plugin config schema + settings-first creds

- Plugin contract gains get_config_schema(); the plugins list API returns it.
  Employees plugin declares its directory-DB fields (host/name/user + password).
- employee_connection reads host/name/user settings-first (env fallback); the
  password stays env-only.
- Setup wizard Features step renders each enabled plugin's config: non-secret
  fields save to settings; secrets are never stored - the wizard emits .env
  lines to paste. Fixed the plugins-list data path (data.plugins).
- Settings PUT now upserts (creates the row on first write) so plugin-config
  keys can be saved without pre-seeding.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-10 08:10:48 -04:00
parent 843b225a47
commit 087ece0f8c
6 changed files with 139 additions and 6 deletions

View File

@@ -43,6 +43,27 @@
</label>
</div>
<p v-else class="hint">No optional plugins found.</p>
<!-- Per-plugin config for enabled plugins that declare a schema -->
<div v-for="p in configurablePlugins" :key="`config-${p.name}`" class="config-block">
<h3 class="config-title">{{ p.name }} configuration</h3>
<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>
<!-- 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 -->
@@ -120,10 +141,29 @@ const form = ref({
map_width: null, map_height: null,
})
const plugins = ref([])
const pluginConfig = ref({}) // flat map: setting/field key -> value
const saving = ref(false)
const seeding = ref(false)
const seedResult = ref('')
// Enabled plugins that declare config fields.
const configurablePlugins = computed(() =>
plugins.value.filter(p => p.enabled && (p.config_schema || []).length))
// .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', 'pc_access_domain'],
@@ -142,11 +182,35 @@ onMounted(async () => {
}
try {
const response = await pluginsApi.list()
plugins.value = response.data.data || []
plugins.value = response.data.data?.plugins || response.data.data || []
} catch (err) { /* ignore */ }
// Preload current values for non-secret plugin config fields.
for (const plugin of plugins.value) {
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: persist non-secret config fields (secrets stay in .env).
if (current.value.key === 'plugins') {
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) {
@@ -156,6 +220,15 @@ async function saveStep() {
}
}
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 {
@@ -236,6 +309,11 @@ async function finish() {
.plugin-name { font-weight: 600; text-transform: capitalize; }
.plugin-desc { color: var(--text-light); font-size: 0.85rem; }
.seed-result { margin-top: 0.75rem; color: var(--success); font-size: 0.88rem; }
.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-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; }