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>
|
</label>
|
||||||
</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 -->
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Floor map -->
|
<!-- Floor map -->
|
||||||
@@ -120,10 +141,29 @@ const form = ref({
|
|||||||
map_width: null, map_height: null,
|
map_width: null, map_height: null,
|
||||||
})
|
})
|
||||||
const plugins = ref([])
|
const plugins = ref([])
|
||||||
|
const pluginConfig = ref({}) // flat map: setting/field key -> value
|
||||||
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.
|
||||||
|
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.
|
// Which settings each step owns, so Next only saves what changed on that step.
|
||||||
const stepSettings = {
|
const stepSettings = {
|
||||||
site: ['facility_name', 'site_base_url', 'pc_access_domain'],
|
site: ['facility_name', 'site_base_url', 'pc_access_domain'],
|
||||||
@@ -142,11 +182,35 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const response = await pluginsApi.list()
|
const response = await pluginsApi.list()
|
||||||
plugins.value = 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.
|
||||||
|
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() {
|
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]
|
const keys = stepSettings[current.value.key]
|
||||||
if (!keys) return
|
if (!keys) return
|
||||||
for (const key of keys) {
|
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() {
|
async function next() {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
@@ -236,6 +309,11 @@ async function finish() {
|
|||||||
.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; }
|
||||||
.seed-result { margin-top: 0.75rem; color: var(--success); font-size: 0.88rem; }
|
.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); }
|
.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; }
|
.foot-right { display: flex; align-items: center; gap: 0.75rem; }
|
||||||
.btn-text { background: none; border: none; color: var(--text-light); text-decoration: none; }
|
.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."""
|
"""No models - the directory is an external database."""
|
||||||
return []
|
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:
|
def init_app(self, app: Flask, db_instance) -> None:
|
||||||
"""Initialize plugin with Flask app."""
|
"""Initialize plugin with Flask app."""
|
||||||
logger.info(f"Employees plugin initialized (v{self.meta.version})")
|
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()
|
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:
|
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
|
# Track old value for audit
|
||||||
old_value = setting.value
|
old_value = setting.value
|
||||||
|
|||||||
@@ -143,6 +143,10 @@ class PluginManager:
|
|||||||
meta = temp.meta
|
meta = temp.meta
|
||||||
state = self.registry.get(name)
|
state = self.registry.get(name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
config_schema = temp.get_config_schema()
|
||||||
|
except Exception:
|
||||||
|
config_schema = []
|
||||||
available.append({
|
available.append({
|
||||||
'name': meta.name,
|
'name': meta.name,
|
||||||
'version': meta.version,
|
'version': meta.version,
|
||||||
@@ -151,7 +155,8 @@ class PluginManager:
|
|||||||
'dependencies': meta.dependencies,
|
'dependencies': meta.dependencies,
|
||||||
'installed': state is not None,
|
'installed': state is not None,
|
||||||
'enabled': state.enabled if state else False,
|
'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:
|
except Exception as e:
|
||||||
logger.warning(f"Error inspecting plugin {name}: {e}")
|
logger.warning(f"Error inspecting plugin {name}: {e}")
|
||||||
|
|||||||
@@ -74,6 +74,22 @@ class BasePlugin(ABC):
|
|||||||
"""Return dict of service name -> service class."""
|
"""Return dict of service name -> service class."""
|
||||||
return {}
|
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):
|
def get_setting(self, key: str, default=None):
|
||||||
"""Read a plugin-scoped setting from the core Setting store.
|
"""Read a plugin-scoped setting from the core Setting store.
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,28 @@ import pymysql
|
|||||||
from flask import current_app
|
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():
|
def employee_connection():
|
||||||
"""Open a pymysql connection to the employee directory DB."""
|
"""Open a pymysql connection to the employee directory DB."""
|
||||||
return pymysql.connect(
|
return pymysql.connect(
|
||||||
host=current_app.config['EMPLOYEE_DB_HOST'],
|
host=_setting_or_config('employee_db_host', 'EMPLOYEE_DB_HOST'),
|
||||||
user=current_app.config['EMPLOYEE_DB_USER'],
|
user=_setting_or_config('employee_db_user', 'EMPLOYEE_DB_USER'),
|
||||||
password=current_app.config['EMPLOYEE_DB_PASSWORD'],
|
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,
|
cursorclass=pymysql.cursors.DictCursor,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user