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, )