Wizard: choose "create tables here" vs "connect your own database" per plugin

Answers the confusion of asking for a DB connection while also offering to
create the tables. Each self-host-capable plugin (employees, usb) now shows a
mode choice; the external connection fields appear only for "connect your own
database". Default is self-hosted (create tables here) - the external path is
the niche/our-site option.

- provisioning_note gains mode_setting; employee_directory_mode + usb_directory_
  mode settings (both default 'selfhosted').
- Wizard renders the radio, shows the note for self-hosted and the config fields
  for external, and saves the chosen mode.

Employees works fully in both modes. USB self-hosted ROUTING is still TODO - the
USB routes read the external cmmc_usb schema; wiring them to the app-owned
tables is the remaining work (tracked).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-10 10:38:39 -04:00
parent 0e0bbc0604
commit d08f341403
4 changed files with 70 additions and 28 deletions

View File

@@ -42,33 +42,43 @@
<span class="plugin-name">{{ p.name }}</span> <span class="plugin-name">{{ p.name }}</span>
<span class="plugin-desc">{{ p.description }}</span> <span class="plugin-desc">{{ p.description }}</span>
</label> </label>
<!-- Transparency note for plugins that provision extra tables -->
<div v-if="p.enabled && p.provisioning_note" class="provision-note"> <!-- Plugins that provision tables: choose to create them here or
<p>{{ p.provisioning_note.note }}</p> connect an existing database -->
<p class="provision-tables"> <div v-if="p.enabled && p.provisioning_note && p.provisioning_note.mode_setting" class="plugin-mode">
Creates in shopdb: <label class="mode-opt">
<code v-for="t in p.provisioning_note.tables" :key="t">{{ t }}</code> <input type="radio" value="selfhosted" v-model="pluginModes[p.provisioning_note.mode_setting]" />
</p> Create the tables here <span class="hint">(recommended)</span>
<p v-if="p.provisioning_note.docs" class="provision-docs">Schema: <code>{{ p.provisioning_note.docs }}</code></p> </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> </div>
</div> </div>
<p v-else class="hint">No optional plugins found.</p> <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 --> <!-- Secrets are never stored in the DB; emit .env lines to paste -->
<div v-if="envLines" class="env-block"> <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> <p class="hint">Paste these into <code>.env</code> and restart the app (secrets are not stored in the database):</p>
@@ -153,13 +163,21 @@ const form = ref({
}) })
const plugins = ref([]) const plugins = ref([])
const pluginConfig = ref({}) // flat map: setting/field key -> value const pluginConfig = ref({}) // flat map: setting/field key -> value
const pluginModes = ref({}) // mode_setting key -> 'selfhosted' | 'external'
const saving = ref(false) const saving = ref(false)
const seeding = ref(false) const seeding = ref(false)
const seedResult = ref('') const seedResult = ref('')
// Enabled plugins that declare config fields. function modeOf(plugin) {
const key = plugin.provisioning_note?.mode_setting
return key ? (pluginModes.value[key] || 'selfhosted') : null
}
// 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(() => const configurablePlugins = computed(() =>
plugins.value.filter(p => p.enabled && (p.config_schema || []).length)) 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. // .env lines for secret fields that have a value entered.
const envLines = computed(() => { const envLines = computed(() => {
@@ -196,8 +214,15 @@ onMounted(async () => {
plugins.value = response.data.data?.plugins || response.data.data || [] plugins.value = response.data.data?.plugins || response.data.data || []
} catch (err) { /* ignore */ } } catch (err) { /* ignore */ }
// Preload current values for non-secret plugin config fields. // Preload directory mode (defaults self-hosted) + non-secret config fields.
for (const plugin of plugins.value) { 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 || [])) { for (const field of (plugin.config_schema || [])) {
if (field.secret) continue if (field.secret) continue
try { try {
@@ -210,8 +235,12 @@ onMounted(async () => {
}) })
async function saveStep() { async function saveStep() {
// Plugins step: persist non-secret config fields (secrets stay in .env). // Plugins step: save each plugin's directory mode, plus non-secret external
// connection config (secrets stay in .env).
if (current.value.key === 'plugins') { 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 plugin of configurablePlugins.value) {
for (const field of plugin.config_schema) { for (const field of plugin.config_schema) {
if (field.secret) continue if (field.secret) continue
@@ -319,6 +348,10 @@ async function finish() {
.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-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; text-transform: capitalize; } .plugin-name { font-weight: 600; text-transform: capitalize; }
.plugin-desc { color: var(--text-light); font-size: 0.85rem; } .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; } .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; }
.provision-note p { margin: 0 0 0.35rem; } .provision-note p { margin: 0 0 0.35rem; }
.provision-note p:last-child { margin-bottom: 0; } .provision-note p:last-child { margin-bottom: 0; }

View File

@@ -62,6 +62,7 @@ class EmployeesPlugin(BasePlugin):
def get_provisioning_note(self) -> Optional[Dict]: def get_provisioning_note(self) -> Optional[Dict]:
return { return {
'mode_setting': 'employee_directory_mode',
'tables': ['directoryemployees'], 'tables': ['directoryemployees'],
'note': ('Enabling this in self-hosted mode creates a ' 'note': ('Enabling this in self-hosted mode creates a '
'"directoryemployees" table in the shopdb database (SSO, ' '"directoryemployees" table in the shopdb database (SSO, '

View File

@@ -58,6 +58,7 @@ class USBPlugin(BasePlugin):
def get_provisioning_note(self) -> Optional[Dict]: def get_provisioning_note(self) -> Optional[Dict]:
return { return {
'mode_setting': 'usb_directory_mode',
'tables': ['usbdevicetypes', 'usbdevices', 'usbcheckouts'], 'tables': ['usbdevicetypes', 'usbdevices', 'usbcheckouts'],
'note': ('Enabling this creates USB tracking tables in the shopdb ' 'note': ('Enabling this creates USB tracking tables in the shopdb '
'database (usbdevicetypes, usbdevices, usbcheckouts) for CMMC ' 'database (usbdevicetypes, usbdevices, usbcheckouts) for CMMC '

View File

@@ -265,10 +265,17 @@ def build_default_settings():
}, },
{ {
'key': 'employee_directory_mode', 'key': 'employee_directory_mode',
'value': 'external', 'value': 'selfhosted',
'valuetype': 'string', 'valuetype': 'string',
'category': 'site', 'category': 'site',
'description': "Employee directory source: 'external' (a separate HR database) or 'selfhosted' (managed in-app under Employees)" 'description': "Employee directory source: 'selfhosted' (tables in this app, default) or 'external' (a separate HR database)"
},
{
'key': 'usb_directory_mode',
'value': 'selfhosted',
'valuetype': 'string',
'category': 'site',
'description': "USB check-in/out source: 'selfhosted' (tables in this app, default) or 'external' (a separate cmmc_usb database)"
}, },
{ {
'key': 'site_base_url', 'key': 'site_base_url',