diff --git a/frontend/src/views/SetupWizard.vue b/frontend/src/views/SetupWizard.vue
index 9eca05f..091e24d 100644
--- a/frontend/src/views/SetupWizard.vue
+++ b/frontend/src/views/SetupWizard.vue
@@ -43,6 +43,27 @@
No optional plugins found.
+
+
+
+
{{ p.name }} configuration
+
+
+
+ {{ field.help }}
+
+
+
+
+
+
Paste these into .env and restart the app (secrets are not stored in the database):
+
{{ envLines }}
+
+
@@ -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; }
diff --git a/plugins/employees/plugin.py b/plugins/employees/plugin.py
index 4e9e689..c0fa30f 100644
--- a/plugins/employees/plugin.py
+++ b/plugins/employees/plugin.py
@@ -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})")
diff --git a/shopdb/core/api/settings.py b/shopdb/core/api/settings.py
index 62ce1f5..9c3731a 100644
--- a/shopdb/core/api/settings.py
+++ b/shopdb/core/api/settings.py
@@ -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
diff --git a/shopdb/plugins/__init__.py b/shopdb/plugins/__init__.py
index 615e0d9..3d57eb3 100644
--- a/shopdb/plugins/__init__.py
+++ b/shopdb/plugins/__init__.py
@@ -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}")
diff --git a/shopdb/plugins/base.py b/shopdb/plugins/base.py
index d1d215d..3cf59b4 100644
--- a/shopdb/plugins/base.py
+++ b/shopdb/plugins/base.py
@@ -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.
diff --git a/shopdb/utils/employee_db.py b/shopdb/utils/employee_db.py
index 7e6b7e9..78c789e 100644
--- a/shopdb/utils/employee_db.py
+++ b/shopdb/utils/employee_db.py
@@ -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,
)