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..d7395251b4f 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -32,12 +32,19 @@ export const App = () => { const settingSocket = connect(`${WS_URL}/settings`, {transports: ['websocket']}); const pluginsSocket = connect(`${WS_URL}/pluginfw/installer`, {transports: ['websocket']}) + // When the server explicitly rejects us for not being admin, we must + // NOT reconnect on the subsequent `disconnect` event — otherwise the + // socket cycles forever (connect → admin_auth_error → server + // disconnect → SPA auto-reconnect → …). The flag is reset on each + // successful connect. + let authErrored = false; pluginsSocket.on('connect', () => { useStore.getState().setPluginsSocket(pluginsSocket); }); settingSocket.on('connect', () => { + authErrored = false; useStore.getState().setSettingsSocket(settingSocket); useStore.getState().setShowLoading(false) settingSocket.emit('load'); @@ -46,7 +53,7 @@ export const App = () => { settingSocket.on('disconnect', (reason) => { useStore.getState().setShowLoading(true) - if (reason === 'io server disconnect') settingSocket.connect(); + if (reason === 'io server disconnect' && !authErrored) settingSocket.connect(); }); settingSocket.on('settings', (settings: any) => { @@ -75,6 +82,22 @@ 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 AND + // suppress the automatic reconnect — without this flag the SPA would + // immediately reconnect to a socket that will reject it again. + settingSocket.on('admin_auth_error', (payload?: {message?: string}) => { + authErrored = true; + 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 (