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:
@@ -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; }
|
||||
|
||||
@@ -58,6 +58,21 @@ class EmployeesPlugin(BasePlugin):
|
||||
"""No models - the directory is an external database."""
|
||||
return []
|
||||
|
||||
def get_config_schema(self) -> List[Dict]:
|
||||
"""Employee directory DB connection. Host/name/user are settings the
|
||||
wizard can edit; the password stays in .env (emitted, not stored)."""
|
||||
return [
|
||||
{'key': 'employee_db_host', 'label': 'Employee DB host', 'type': 'text',
|
||||
'secret': False, 'default': 'localhost'},
|
||||
{'key': 'employee_db_name', 'label': 'Employee DB name', 'type': 'text',
|
||||
'secret': False, 'default': 'wjf_employees'},
|
||||
{'key': 'employee_db_user', 'label': 'Employee DB user', 'type': 'text',
|
||||
'secret': False},
|
||||
{'key': 'employee_db_password', 'label': 'Employee DB password', 'type': 'password',
|
||||
'secret': True, 'envvar': 'EMPLOYEE_DB_PASSWORD',
|
||||
'help': 'Stored in .env, not the database. The wizard shows the line to paste.'},
|
||||
]
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
"""Initialize plugin with Flask app."""
|
||||
logger.info(f"Employees plugin initialized (v{self.meta.version})")
|
||||
|
||||
@@ -112,8 +112,11 @@ def update_setting(key: str):
|
||||
|
||||
setting = Setting.query.filter_by(key=key).first()
|
||||
|
||||
# Upsert: create the row on first write (e.g. plugin config keys the setup
|
||||
# wizard saves). New keys default to a plugin-scoped string setting.
|
||||
if not setting:
|
||||
return error_response(ErrorCodes.NOT_FOUND, f'Setting {key} not found', http_code=404)
|
||||
setting = Setting(key=key, value='', valuetype='string', category='plugin')
|
||||
db.session.add(setting)
|
||||
|
||||
# Track old value for audit
|
||||
old_value = setting.value
|
||||
|
||||
@@ -143,6 +143,10 @@ class PluginManager:
|
||||
meta = temp.meta
|
||||
state = self.registry.get(name)
|
||||
|
||||
try:
|
||||
config_schema = temp.get_config_schema()
|
||||
except Exception:
|
||||
config_schema = []
|
||||
available.append({
|
||||
'name': meta.name,
|
||||
'version': meta.version,
|
||||
@@ -151,7 +155,8 @@ class PluginManager:
|
||||
'dependencies': meta.dependencies,
|
||||
'installed': state is not None,
|
||||
'enabled': state.enabled if state else False,
|
||||
'installedat': state.installed_at if state else None
|
||||
'installedat': state.installed_at if state else None,
|
||||
'config_schema': config_schema,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Error inspecting plugin {name}: {e}")
|
||||
|
||||
@@ -74,6 +74,22 @@ class BasePlugin(ABC):
|
||||
"""Return dict of service name -> service class."""
|
||||
return {}
|
||||
|
||||
def get_config_schema(self) -> List[Dict]:
|
||||
"""Declare the config fields this plugin needs, for the setup wizard.
|
||||
|
||||
Each field is a dict:
|
||||
key - the Setting key (non-secret) it maps to
|
||||
label - human label
|
||||
type - 'text' | 'number' | 'password'
|
||||
secret - True for credentials; these are NOT stored in the DB, the
|
||||
wizard emits an .env line for the operator to paste instead
|
||||
envvar - (secret only) the .env variable name to emit
|
||||
default - optional default shown as a placeholder
|
||||
help - optional hint
|
||||
Return [] (default) if the plugin needs no configuration.
|
||||
"""
|
||||
return []
|
||||
|
||||
def get_setting(self, key: str, default=None):
|
||||
"""Read a plugin-scoped setting from the core Setting store.
|
||||
|
||||
|
||||
@@ -9,12 +9,28 @@ import pymysql
|
||||
from flask import current_app
|
||||
|
||||
|
||||
def _setting_or_config(setting_key, config_key):
|
||||
"""Non-secret config: a saved Setting wins, else the env-backed app config.
|
||||
|
||||
Lets the setup wizard edit host/name/user without touching .env, while the
|
||||
password stays env-only.
|
||||
"""
|
||||
from shopdb.core.models import Setting
|
||||
try:
|
||||
row = Setting.query.filter_by(key=setting_key).first()
|
||||
if row and row.value:
|
||||
return row.value
|
||||
except Exception:
|
||||
pass
|
||||
return current_app.config[config_key]
|
||||
|
||||
|
||||
def employee_connection():
|
||||
"""Open a pymysql connection to the employee directory DB."""
|
||||
return pymysql.connect(
|
||||
host=current_app.config['EMPLOYEE_DB_HOST'],
|
||||
user=current_app.config['EMPLOYEE_DB_USER'],
|
||||
host=_setting_or_config('employee_db_host', 'EMPLOYEE_DB_HOST'),
|
||||
user=_setting_or_config('employee_db_user', 'EMPLOYEE_DB_USER'),
|
||||
password=current_app.config['EMPLOYEE_DB_PASSWORD'],
|
||||
database=current_app.config['EMPLOYEE_DB_NAME'],
|
||||
database=_setting_or_config('employee_db_name', 'EMPLOYEE_DB_NAME'),
|
||||
cursorclass=pymysql.cursors.DictCursor,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user