From 58ee650f15e32405b02c9f3cf440790918f0e72c Mon Sep 17 00:00:00 2001 From: John McLear Date: Wed, 20 May 2026 15:17:31 +0100 Subject: [PATCH 1/2] feat(admin): explain env-var substitution in /settings, surface auth errors (#7819) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small, env-var-only UX improvements driven by issue #7819, where a Docker operator saved an ep_oauth block in the admin /settings raw view and reported it "disappeared" — but the underlying confusion was that settings.json on disk is a *template*, not the effective config. None of these changes is visible to installs that don't use ${VAR} placeholders. * Banner above the editor explaining the template/env-substitution model, only rendered when the loaded file contains a ${VAR} placeholder. Tells the operator that the file is not env-substituted in place and that the Effective tab shows the live values. * Effective tab in the mode toggle, read-only, also gated on ${VAR}. The backend was already emitting redacted runtime settings as `resolved` alongside every `load`; the SPA now exposes them so an operator can verify what Etherpad is actually using. * admin_auth_error event from the /settings socket handler. The handler previously silently returned when the connecting session wasn't admin, which made misrouted Traefik+SSO auth look like "save did nothing" with no error path in the UI. Emit a dedicated event before dropping the socket so the SPA can show a clear toast. Tests: - src/tests/backend/specs/admin/adminSettingsAuthError.ts — new spec for the auth_error/disconnect contract. - src/tests/frontend-new/admin-spec/adminsettings.spec.ts — new Playwright test asserting the banner + Effective tab only appear after a ${VAR} is added to settings.json, and that the Effective view is read-only + shows [REDACTED] for secrets. No behaviour change for installs without ${VAR} placeholders — banner, Effective tab, and auth-error contract are all the same as before. Co-Authored-By: Claude Opus 4.7 (1M context) --- admin/src/App.css | 31 +++++ admin/src/App.tsx | 13 ++ admin/src/components/settings/ModeToggle.tsx | 21 ++- admin/src/pages/SettingsPage.tsx | 127 ++++++++++++------ src/locales/en.json | 5 + src/node/hooks/express/adminsettings.ts | 12 +- .../specs/admin/adminSettingsAuthError.ts | 56 ++++++++ .../backend/specs/admin/adminSettingsSave.ts | 1 + .../admin-spec/adminsettings.spec.ts | 52 +++++++ 9 files changed, 277 insertions(+), 41 deletions(-) create mode 100644 src/tests/backend/specs/admin/adminSettingsAuthError.ts diff --git a/admin/src/App.css b/admin/src/App.css index a064a56f9aa..c0e02cd0e8a 100644 --- a/admin/src/App.css +++ b/admin/src/App.css @@ -34,6 +34,37 @@ textarea.settings:focus { border-top: 1px solid #ddd; } +/* --- env-var banner --- */ +/* Shown only when settings.json contains ${VAR} placeholders. The + typical reader is a Docker/K8s operator who has just been surprised + by env-var substitution semantics, so the copy must explain rather + than warn — visual weight matches a note, not an error. */ +.settings-envvar-banner { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px 16px; + margin-bottom: 16px; + background: #f5f7ff; + border: 1px solid #c7d2fe; + border-radius: 6px; + color: #1e293b; +} +.settings-envvar-banner svg { + flex-shrink: 0; + color: #4f46e5; + margin-top: 2px; +} +.settings-envvar-banner strong { + display: block; + margin-bottom: 4px; +} +.settings-envvar-banner p { + margin: 0; + font-size: 13px; + line-height: 1.5; +} + /* --- mode toggle --- */ .settings-mode-toggle { display: inline-flex; diff --git a/admin/src/App.tsx b/admin/src/App.tsx index b182be02fa9..c8b65aef8e9 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -75,6 +75,19 @@ export const App = () => { const detail = payload?.message ?? ''; setToastState({open: true, title: t('admin_settings.toast.save_failed') + (detail ? ` (${detail})` : ''), success: false}); } + }); + + // Backend emits this when the connecting socket does not have an + // admin session. Previously the server just dropped silently, so the + // SPA would sit on a loading screen with no clue. Surface it. + settingSocket.on('admin_auth_error', (payload?: {message?: string}) => { + const {setToastState} = useStore.getState(); + setToastState({ + open: true, + title: payload?.message || t('admin_settings.toast.auth_error'), + success: false, + }); + useStore.getState().setShowLoading(false); }) return () => { diff --git a/admin/src/components/settings/ModeToggle.tsx b/admin/src/components/settings/ModeToggle.tsx index 0f87f62ce84..bce7ef81d34 100644 --- a/admin/src/components/settings/ModeToggle.tsx +++ b/admin/src/components/settings/ModeToggle.tsx @@ -1,13 +1,17 @@ import { Trans, useTranslation } from 'react-i18next'; -export type Mode = 'form' | 'raw'; +export type Mode = 'form' | 'raw' | 'effective'; type Props = { mode: Mode; onChange: (mode: Mode) => void; + // When false, the Effective tab is hidden. We hide it for installs that + // aren't using env-var substitution at all — there's no useful difference + // between the raw file and the effective in-memory config for them. + showEffective?: boolean; }; -export const ModeToggle = ({ mode, onChange }: Props) => { +export const ModeToggle = ({ mode, onChange, showEffective = false }: Props) => { const { t } = useTranslation(); return (
@@ -31,6 +35,19 @@ export const ModeToggle = ({ mode, onChange }: Props) => { > + {showEffective && ( + + )}
); }; diff --git a/admin/src/pages/SettingsPage.tsx b/admin/src/pages/SettingsPage.tsx index 7511b8e399f..8c5529ea806 100644 --- a/admin/src/pages/SettingsPage.tsx +++ b/admin/src/pages/SettingsPage.tsx @@ -1,22 +1,42 @@ -import React, { useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useStore } from '../store/store'; import { isJSONClean, cleanComments } from '../utils/utils'; import { Trans, useTranslation } from 'react-i18next'; import { IconButton } from '../components/IconButton'; -import { RotateCw, Save, AlignLeft, ShieldCheck } from 'lucide-react'; +import { RotateCw, Save, AlignLeft, ShieldCheck, Info } from 'lucide-react'; import { FormView } from '../components/settings/FormView'; import { ModeToggle, type Mode } from '../components/settings/ModeToggle'; const TAB_INDENT = ' '; +// Heuristic: `${VAR}` or `${VAR:default}` in the file means the operator is +// running with env-var substitution (overwhelmingly Docker / Kubernetes). +// We use this to gate the Docker-aware UX (the explanatory banner and the +// Effective-config tab) so non-container installs see the existing UI +// unchanged. Conservative on purpose — false negatives just keep the old +// behaviour. +const ENV_VAR_PATTERN = /\$\{[A-Za-z_][A-Za-z0-9_]*(?::[^}]*)?\}/; + export const SettingsPage = () => { const { t } = useTranslation(); const settingsSocket = useStore(state => state.settingsSocket); const settings = useStore(state => state.settings) ?? ''; + const resolved = useStore(state => state.resolved); + + const usesEnvVars = useMemo(() => ENV_VAR_PATTERN.test(settings), [settings]); const [mode, setMode] = useState('form'); const [exposeExperimental] = useState(false); + // The Effective tab is only meaningful when there is a `resolved` + // payload AND the file uses substitution. Falling back to Raw on + // either condition keeps the toggle honest if the user opens this + // page against an older server. + const canShowEffective = usesEnvVars && resolved != null; + useEffect(() => { + if (mode === 'effective' && !canShowEffective) setMode('raw'); + }, [mode, canShowEffective]); + // Tab in textarea inserts two spaces instead of moving focus; rAF restores caret position after React re-renders. const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key !== 'Tab') return; @@ -57,49 +77,80 @@ export const SettingsPage = () => { settingsSocket.emit('saveSettings', settings); }; + const effectiveJson = useMemo(() => { + if (resolved == null) return ''; + try { return JSON.stringify(resolved, null, 2); } catch { return ''; } + }, [resolved]); + return (

- - - {mode === 'form' - ? setMode('raw')} /> - : ( -