Skip to content

Commit 4d998d6

Browse files
JohnMcLearclaude
andauthored
fix(admin): show resolved runtime values on /admin/settings (#7803) (#7807)
* docs: spec for admin/settings resolved runtime values (#7803) Side-channel resolved+redacted settings alongside raw file blob. Form view dropdowns and env pill chips reflect actual runtime values instead of falling back to template defaults. Save round-trip is unchanged so ${VAR:default} literals stay intact on disk. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: implementation plan for admin/settings resolved runtime (#7803) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(admin): add redactor for resolved settings payload (#7803) Pure helper that walks the live settings module and replaces known sensitive paths (users.*.password, dbSettings.password, sso.clients[*].client_secret, sessionKey, …) with [REDACTED] sentinel. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(admin): emit redacted runtime settings on /settings socket load (#7803) Existing 'results' raw-file blob is unchanged so the textarea editor and saveSettings round-trip continue to preserve \${VAR:default} literals on disk. New 'resolved' field carries the in-memory settings module run through the redactor — admin SPA can use it to show actual runtime values next to env-var placeholders. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(admin): show resolved runtime value on EnvPill (#7803) Admin SPA now stores the resolved field from the /settings socket payload and exposes useResolvedAt(path) to walk it. EnvPill renders a "→ active value" chip when the path is resolved, or "→ ••••••" with a redacted tooltip when the server returned the [REDACTED] sentinel. Old-server fallback (undefined resolved) keeps current behaviour. The admin test script glob now picks up .test.tsx alongside .test.ts so the new EnvPill tests run under tsx --test. Closes #7803. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f6ab856 commit 4d998d6

16 files changed

Lines changed: 2054 additions & 6 deletions

File tree

admin/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
1212
"build-copy": "pnpm gen:api && tsc && vite build --outDir ../src/templates/admin --emptyOutDir",
1313
"preview": "vite preview",
14-
"test": "pnpm gen:api && tsx --test 'src/**/__tests__/*.test.ts'"
14+
"test": "pnpm gen:api && tsx --test 'src/**/__tests__/*.test.ts' 'src/**/__tests__/*.test.tsx'"
1515
},
1616
"dependencies": {
1717
"@radix-ui/react-switch": "^1.2.6",

admin/src/App.css

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,32 @@ textarea.settings:focus {
233233
outline: 2px solid var(--accent, #2b8a3e);
234234
outline-offset: 1px;
235235
}
236+
.settings-widget-env-runtime {
237+
display: inline-flex;
238+
align-items: center;
239+
gap: 4px;
240+
margin-left: 4px;
241+
padding: 1px 8px;
242+
border-radius: 10px;
243+
background: #e6f4ea;
244+
color: #1e5631;
245+
font-family: "Fira Code", monospace;
246+
font-size: 12px;
247+
}
248+
.settings-widget-env-runtime-redacted {
249+
background: #ececec;
250+
color: #555;
251+
}
252+
.settings-widget-env-runtime-arrow {
253+
opacity: 0.6;
254+
}
255+
.settings-widget-env-runtime-label {
256+
font-style: italic;
257+
opacity: 0.7;
258+
}
259+
.settings-widget-env-runtime-value {
260+
font-weight: 600;
261+
}
236262

237263
/* Radix switch (boolean) */
238264
.settings-widget-boolean {

admin/src/App.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,14 @@ export const App = () => {
5656
}
5757
if (settings.results === 'NOT_ALLOWED') {
5858
console.log('Not allowed to view settings.json')
59+
useStore.getState().setResolved(null);
5960
return;
6061
}
6162
if (isJSONClean(settings.results)) setSettings(settings.results);
6263
else alert(t('admin_settings.invalid_json'));
64+
// The resolved field is optional — old servers won't send it,
65+
// and the SPA degrades to today's behaviour when it's null.
66+
useStore.getState().setResolved(settings.resolved ?? null);
6367
useStore.getState().setShowLoading(false);
6468
});
6569

admin/src/components/settings/JsoncNode.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { NumberInput } from './widgets/NumberInput';
99
import { BooleanToggle } from './widgets/BooleanToggle';
1010
import { NullChip } from './widgets/NullChip';
1111
import { EnvPill } from './widgets/EnvPill';
12+
import { useResolvedAt } from '../../store/store';
1213

1314
type Props = {
1415
/** The value node (not the property node). */
@@ -36,6 +37,7 @@ const renderLeaf = (
3637
path: JSONPath,
3738
text: string,
3839
onEdit: (path: JSONPath, value: unknown) => void,
40+
resolvedValue: unknown,
3941
) => {
4042
if (node.type === 'string') {
4143
const raw = text.slice(node.offset, node.offset + node.length);
@@ -46,6 +48,7 @@ const renderLeaf = (
4648
placeholder={env}
4749
path={path}
4850
onChange={(d) => onEdit(path, `\${${env.variable}:${d}}`)}
51+
resolvedValue={resolvedValue}
4952
/>
5053
);
5154
}
@@ -84,6 +87,11 @@ const renderLeaf = (
8487
export const JsoncNode = ({ node, property, text, onEdit, suppressOwnHeader }: Props) => {
8588
const path = getNodePath(node);
8689
const key = propertyKey(property);
90+
// useResolvedAt must be called unconditionally for every JsoncNode
91+
// render (React hook rules). It's cheap: a shallow zustand selector +
92+
// an object-walk that returns undefined when the resolved payload is
93+
// absent (old server) — in which case EnvPill simply omits the chip.
94+
const resolvedValue = useResolvedAt(path);
8795

8896
const anchor = property ?? node;
8997
const fileComments = extractAdjacentComments(text, anchor.offset, node.offset, node.length);
@@ -163,7 +171,7 @@ export const JsoncNode = ({ node, property, text, onEdit, suppressOwnHeader }: P
163171
{label}
164172
</label>
165173
<div className="settings-row-control">
166-
{renderLeaf(node, path, text, onEdit)}
174+
{renderLeaf(node, path, text, onEdit, resolvedValue)}
167175
</div>
168176
{help && (
169177
<p className="settings-row-help" id={helpId}>{help}</p>

admin/src/components/settings/widgets/EnvPill.tsx

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,29 +3,43 @@ import { useTranslation } from 'react-i18next';
33
import type { JSONPath } from 'jsonc-parser';
44
import type { EnvPlaceholder } from '../envPill';
55

6+
const REDACTED = '[REDACTED]';
7+
68
type Props = {
79
placeholder: EnvPlaceholder;
810
path: JSONPath;
911
onChange: (newDefault: string) => void;
12+
resolvedValue?: unknown;
1013
};
1114

1215
const sanitize = (s: string) => s.replace(/[}]/g, '');
1316

14-
export const EnvPill = ({ placeholder, path, onChange }: Props) => {
17+
const formatDisplay = (v: unknown): string => {
18+
if (v === null) return 'null';
19+
if (typeof v === 'string') return v;
20+
return String(v);
21+
};
22+
23+
export const EnvPill = ({ placeholder, path, onChange, resolvedValue }: Props) => {
1524
const { t } = useTranslation();
1625
const initial = placeholder.defaultValue ?? '';
1726
const [draft, setDraft] = useState(initial);
1827
const focused = useRef(false);
1928

20-
// Sync local draft from upstream (server canonicalisation, raw-mode edit)
21-
// only while the input isn't focused so we don't trample mid-typing.
2229
useEffect(() => {
2330
if (!focused.current) setDraft(initial);
2431
}, [initial]);
2532

2633
const id = `field-${path.join('.')}`;
2734
const testid = `env-${path.join('.')}`;
2835

36+
// Three runtime states:
37+
// undefined → server didn't send resolved (old server, or path absent)
38+
// '[REDACTED]' → secret hidden
39+
// anything else → live runtime value
40+
const hasResolved = resolvedValue !== undefined;
41+
const isRedacted = resolvedValue === REDACTED;
42+
2943
return (
3044
<span
3145
className="settings-widget settings-widget-env"
@@ -52,6 +66,31 @@ export const EnvPill = ({ placeholder, path, onChange }: Props) => {
5266
onChange(v);
5367
}}
5468
/>
69+
{hasResolved && !isRedacted && (
70+
<span
71+
className="settings-widget-env-runtime"
72+
data-testid={`env-runtime-${path.join('.')}`}
73+
title={t('admin_settings.env_pill.runtime_tooltip', { variable: placeholder.variable })}
74+
>
75+
<span className="settings-widget-env-runtime-arrow" aria-hidden></span>
76+
<span className="settings-widget-env-runtime-label" aria-hidden>
77+
{t('admin_settings.env_pill.runtime_label')}
78+
</span>
79+
<span className="settings-widget-env-runtime-value">
80+
{formatDisplay(resolvedValue)}
81+
</span>
82+
</span>
83+
)}
84+
{isRedacted && (
85+
<span
86+
className="settings-widget-env-runtime settings-widget-env-runtime-redacted"
87+
data-testid={`env-runtime-redacted-${path.join('.')}`}
88+
title={t('admin_settings.env_pill.redacted_tooltip', { variable: placeholder.variable })}
89+
aria-label={t('admin_settings.env_pill.redacted_tooltip', { variable: placeholder.variable })}
90+
>
91+
<span aria-hidden>→ ••••••</span>
92+
</span>
93+
)}
5594
</span>
5695
);
5796
};
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { test } from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import * as React from 'react';
4+
import { renderToStaticMarkup } from 'react-dom/server';
5+
import { I18nextProvider } from 'react-i18next';
6+
import i18next from 'i18next';
7+
8+
import { EnvPill } from '../EnvPill.tsx';
9+
10+
i18next.init({
11+
lng: 'en',
12+
resources: {
13+
en: {
14+
translation: {
15+
'admin_settings.env_pill.tooltip': 'env {{variable}}',
16+
'admin_settings.env_pill.default_label': 'default',
17+
'admin_settings.env_pill.input_aria': 'aria {{variable}}',
18+
'admin_settings.env_pill.runtime_label': 'active value',
19+
'admin_settings.env_pill.runtime_tooltip': 'using {{variable}}',
20+
'admin_settings.env_pill.redacted_tooltip': 'hidden {{variable}}',
21+
},
22+
},
23+
},
24+
interpolation: { escapeValue: false },
25+
});
26+
27+
const wrap = (el: React.ReactElement) =>
28+
renderToStaticMarkup(
29+
React.createElement(I18nextProvider, { i18n: i18next }, el),
30+
);
31+
32+
test('omits runtime chip when resolvedValue is undefined', () => {
33+
const html = wrap(React.createElement(EnvPill, {
34+
placeholder: { variable: 'DB_TYPE', defaultValue: 'dirty' },
35+
path: ['dbType'],
36+
onChange: () => {},
37+
}));
38+
assert.ok(!html.includes('settings-widget-env-runtime'),
39+
`runtime chip should be absent, got: ${html}`);
40+
});
41+
42+
test('renders runtime chip with resolved value', () => {
43+
const html = wrap(React.createElement(EnvPill, {
44+
placeholder: { variable: 'DB_TYPE', defaultValue: 'dirty' },
45+
path: ['dbType'],
46+
onChange: () => {},
47+
resolvedValue: 'sqlite',
48+
} as any));
49+
assert.ok(html.includes('settings-widget-env-runtime'),
50+
`runtime chip class should appear, got: ${html}`);
51+
assert.ok(html.includes('sqlite'),
52+
`resolved value text should appear, got: ${html}`);
53+
});
54+
55+
test('renders redacted chip when resolvedValue is [REDACTED]', () => {
56+
const html = wrap(React.createElement(EnvPill, {
57+
placeholder: { variable: 'DB_PASS', defaultValue: '' },
58+
path: ['dbSettings', 'password'],
59+
onChange: () => {},
60+
resolvedValue: '[REDACTED]',
61+
} as any));
62+
assert.ok(html.includes('settings-widget-env-runtime-redacted'),
63+
`redacted chip class should appear, got: ${html}`);
64+
assert.ok(!html.includes('[REDACTED]'),
65+
`literal sentinel must not be displayed to the user, got: ${html}`);
66+
});
67+
68+
test('coerces non-string resolved values to display strings', () => {
69+
const html = wrap(React.createElement(EnvPill, {
70+
placeholder: { variable: 'TRUST_PROXY', defaultValue: 'false' },
71+
path: ['trustProxy'],
72+
onChange: () => {},
73+
resolvedValue: true,
74+
} as any));
75+
assert.ok(html.includes('true'), `expected "true" in ${html}`);
76+
});
77+
78+
test('renders null resolved value as the string null', () => {
79+
const html = wrap(React.createElement(EnvPill, {
80+
placeholder: { variable: 'IP', defaultValue: '' },
81+
path: ['ip'],
82+
onChange: () => {},
83+
resolvedValue: null,
84+
} as any));
85+
assert.ok(html.includes('null'), `expected "null" in ${html}`);
86+
});

admin/src/store/store.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import {create} from "zustand";
22
import {Socket} from "socket.io-client";
3+
import type {JSONPath} from "jsonc-parser";
34
import {PadSearchResult} from "../utils/PadSearch.ts";
45
import {AuthorSearchResult} from "../utils/AuthorSearch.ts";
56
import {InstalledPlugin} from "../pages/Plugin.ts";
7+
import {resolveByPath} from "../utils/resolveByPath.ts";
68

79
export type Execution =
810
| {status: 'idle'}
@@ -65,6 +67,11 @@ type ToastState = {
6567
type StoreState = {
6668
settings: string|undefined,
6769
setSettings: (settings: string) => void,
70+
// Resolved runtime values for the /admin/settings page. The server
71+
// emits this alongside the raw `settings` string so the SPA can show
72+
// env-substituted values; secrets are redacted to "[REDACTED]".
73+
resolved: unknown | null,
74+
setResolved: (resolved: unknown | null) => void,
6875
settingsSocket: Socket|undefined,
6976
setSettingsSocket: (socket: Socket) => void,
7077
showLoading: boolean,
@@ -91,6 +98,8 @@ type StoreState = {
9198
export const useStore = create<StoreState>()((set) => ({
9299
settings: undefined,
93100
setSettings: (settings: string) => set({settings}),
101+
resolved: null,
102+
setResolved: (resolved) => set({resolved}),
94103
settingsSocket: undefined,
95104
setSettingsSocket: (socket: Socket) => set({settingsSocket: socket}),
96105
showLoading: false,
@@ -117,3 +126,6 @@ export const useStore = create<StoreState>()((set) => ({
117126
gdprAuthorErasureEnabled: false,
118127
setGdprAuthorErasureEnabled: (gdprAuthorErasureEnabled)=>set({gdprAuthorErasureEnabled}),
119128
}));
129+
130+
export const useResolvedAt = (path: JSONPath): unknown =>
131+
useStore(s => resolveByPath(s.resolved, path));
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { test } from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { resolveByPath } from '../resolveByPath.ts';
4+
5+
test('returns undefined for null/undefined root', () => {
6+
assert.equal(resolveByPath(null, ['a']), undefined);
7+
assert.equal(resolveByPath(undefined, ['a']), undefined);
8+
});
9+
10+
test('walks nested object keys', () => {
11+
assert.equal(resolveByPath({a: {b: {c: 42}}}, ['a', 'b', 'c']), 42);
12+
});
13+
14+
test('walks arrays with numeric indices', () => {
15+
assert.equal(resolveByPath({xs: [10, 20, 30]}, ['xs', 1]), 20);
16+
});
17+
18+
test('walks mixed objects and arrays', () => {
19+
assert.equal(
20+
resolveByPath({sso: {clients: [{id: 'A'}, {id: 'B'}]}}, ['sso', 'clients', 1, 'id']),
21+
'B',
22+
);
23+
});
24+
25+
test('returns undefined for missing keys', () => {
26+
assert.equal(resolveByPath({a: 1}, ['b']), undefined);
27+
assert.equal(resolveByPath({a: {b: 1}}, ['a', 'c']), undefined);
28+
});
29+
30+
test('returns undefined when traversing into a primitive', () => {
31+
assert.equal(resolveByPath({a: 1}, ['a', 'b']), undefined);
32+
});
33+
34+
test('returns the root when path is empty', () => {
35+
const obj = {a: 1};
36+
assert.equal(resolveByPath(obj, []), obj);
37+
});
38+
39+
test('handles string-form numeric indices for arrays', () => {
40+
assert.equal(resolveByPath({xs: [10, 20]}, ['xs', '1']), 20);
41+
});

admin/src/utils/resolveByPath.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { JSONPath } from 'jsonc-parser';
2+
3+
export const resolveByPath = (obj: unknown, path: JSONPath): unknown => {
4+
let cur: unknown = obj;
5+
for (const seg of path) {
6+
if (cur === null || cur === undefined) return undefined;
7+
if (typeof cur !== 'object') return undefined;
8+
if (Array.isArray(cur)) {
9+
const i = typeof seg === 'number' ? seg : Number(seg);
10+
if (!Number.isInteger(i)) return undefined;
11+
cur = cur[i];
12+
} else {
13+
cur = (cur as Record<string, unknown>)[String(seg)];
14+
}
15+
}
16+
return cur;
17+
};

0 commit comments

Comments
 (0)