Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"build-copy": "pnpm gen:api && tsc && vite build --outDir ../src/templates/admin --emptyOutDir",
"preview": "vite preview",
"test": "pnpm gen:api && tsx --test 'src/**/__tests__/*.test.ts'"
"test": "pnpm gen:api && tsx --test 'src/**/__tests__/*.test.ts' 'src/**/__tests__/*.test.tsx'"
},
"dependencies": {
"@radix-ui/react-switch": "^1.2.6",
Expand Down
26 changes: 26 additions & 0 deletions admin/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,32 @@ textarea.settings:focus {
outline: 2px solid var(--accent, #2b8a3e);
outline-offset: 1px;
}
.settings-widget-env-runtime {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 4px;
padding: 1px 8px;
border-radius: 10px;
background: #e6f4ea;
color: #1e5631;
font-family: "Fira Code", monospace;
font-size: 12px;
}
.settings-widget-env-runtime-redacted {
background: #ececec;
color: #555;
}
.settings-widget-env-runtime-arrow {
opacity: 0.6;
}
.settings-widget-env-runtime-label {
font-style: italic;
opacity: 0.7;
}
.settings-widget-env-runtime-value {
font-weight: 600;
}

/* Radix switch (boolean) */
.settings-widget-boolean {
Expand Down
4 changes: 4 additions & 0 deletions admin/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,14 @@ export const App = () => {
}
if (settings.results === 'NOT_ALLOWED') {
console.log('Not allowed to view settings.json')
useStore.getState().setResolved(null);
return;
}
if (isJSONClean(settings.results)) setSettings(settings.results);
else alert(t('admin_settings.invalid_json'));
// The resolved field is optional — old servers won't send it,
// and the SPA degrades to today's behaviour when it's null.
useStore.getState().setResolved(settings.resolved ?? null);
useStore.getState().setShowLoading(false);
});

Expand Down
10 changes: 9 additions & 1 deletion admin/src/components/settings/JsoncNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { NumberInput } from './widgets/NumberInput';
import { BooleanToggle } from './widgets/BooleanToggle';
import { NullChip } from './widgets/NullChip';
import { EnvPill } from './widgets/EnvPill';
import { useResolvedAt } from '../../store/store';

type Props = {
/** The value node (not the property node). */
Expand Down Expand Up @@ -36,6 +37,7 @@ const renderLeaf = (
path: JSONPath,
text: string,
onEdit: (path: JSONPath, value: unknown) => void,
resolvedValue: unknown,
) => {
if (node.type === 'string') {
const raw = text.slice(node.offset, node.offset + node.length);
Expand All @@ -46,6 +48,7 @@ const renderLeaf = (
placeholder={env}
path={path}
onChange={(d) => onEdit(path, `\${${env.variable}:${d}}`)}
resolvedValue={resolvedValue}
/>
);
}
Expand Down Expand Up @@ -84,6 +87,11 @@ const renderLeaf = (
export const JsoncNode = ({ node, property, text, onEdit, suppressOwnHeader }: Props) => {
const path = getNodePath(node);
const key = propertyKey(property);
// useResolvedAt must be called unconditionally for every JsoncNode
// render (React hook rules). It's cheap: a shallow zustand selector +
// an object-walk that returns undefined when the resolved payload is
// absent (old server) — in which case EnvPill simply omits the chip.
const resolvedValue = useResolvedAt(path);

const anchor = property ?? node;
const fileComments = extractAdjacentComments(text, anchor.offset, node.offset, node.length);
Expand Down Expand Up @@ -163,7 +171,7 @@ export const JsoncNode = ({ node, property, text, onEdit, suppressOwnHeader }: P
{label}
</label>
<div className="settings-row-control">
{renderLeaf(node, path, text, onEdit)}
{renderLeaf(node, path, text, onEdit, resolvedValue)}
</div>
{help && (
<p className="settings-row-help" id={helpId}>{help}</p>
Expand Down
45 changes: 42 additions & 3 deletions admin/src/components/settings/widgets/EnvPill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,43 @@ import { useTranslation } from 'react-i18next';
import type { JSONPath } from 'jsonc-parser';
import type { EnvPlaceholder } from '../envPill';

const REDACTED = '[REDACTED]';

type Props = {
placeholder: EnvPlaceholder;
path: JSONPath;
onChange: (newDefault: string) => void;
resolvedValue?: unknown;
};

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

export const EnvPill = ({ placeholder, path, onChange }: Props) => {
const formatDisplay = (v: unknown): string => {
if (v === null) return 'null';
if (typeof v === 'string') return v;
return String(v);
};

export const EnvPill = ({ placeholder, path, onChange, resolvedValue }: Props) => {
const { t } = useTranslation();
const initial = placeholder.defaultValue ?? '';
const [draft, setDraft] = useState(initial);
const focused = useRef(false);

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

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

// Three runtime states:
// undefined → server didn't send resolved (old server, or path absent)
// '[REDACTED]' → secret hidden
// anything else → live runtime value
const hasResolved = resolvedValue !== undefined;
const isRedacted = resolvedValue === REDACTED;

return (
<span
className="settings-widget settings-widget-env"
Expand All @@ -52,6 +66,31 @@ export const EnvPill = ({ placeholder, path, onChange }: Props) => {
onChange(v);
}}
/>
{hasResolved && !isRedacted && (
<span
className="settings-widget-env-runtime"
data-testid={`env-runtime-${path.join('.')}`}
title={t('admin_settings.env_pill.runtime_tooltip', { variable: placeholder.variable })}
>
<span className="settings-widget-env-runtime-arrow" aria-hidden>→</span>
<span className="settings-widget-env-runtime-label" aria-hidden>
{t('admin_settings.env_pill.runtime_label')}
</span>
<span className="settings-widget-env-runtime-value">
{formatDisplay(resolvedValue)}
</span>
</span>
)}
{isRedacted && (
<span
className="settings-widget-env-runtime settings-widget-env-runtime-redacted"
data-testid={`env-runtime-redacted-${path.join('.')}`}
title={t('admin_settings.env_pill.redacted_tooltip', { variable: placeholder.variable })}
aria-label={t('admin_settings.env_pill.redacted_tooltip', { variable: placeholder.variable })}
>
<span aria-hidden>→ ••••••</span>
</span>
)}
</span>
);
};
86 changes: 86 additions & 0 deletions admin/src/components/settings/widgets/__tests__/EnvPill.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { I18nextProvider } from 'react-i18next';
import i18next from 'i18next';

import { EnvPill } from '../EnvPill.tsx';

i18next.init({
lng: 'en',
resources: {
en: {
translation: {
'admin_settings.env_pill.tooltip': 'env {{variable}}',
'admin_settings.env_pill.default_label': 'default',
'admin_settings.env_pill.input_aria': 'aria {{variable}}',
'admin_settings.env_pill.runtime_label': 'active value',
'admin_settings.env_pill.runtime_tooltip': 'using {{variable}}',
'admin_settings.env_pill.redacted_tooltip': 'hidden {{variable}}',
},
},
},
interpolation: { escapeValue: false },
});

const wrap = (el: React.ReactElement) =>
renderToStaticMarkup(
React.createElement(I18nextProvider, { i18n: i18next }, el),
);

test('omits runtime chip when resolvedValue is undefined', () => {
const html = wrap(React.createElement(EnvPill, {
placeholder: { variable: 'DB_TYPE', defaultValue: 'dirty' },
path: ['dbType'],
onChange: () => {},
}));
assert.ok(!html.includes('settings-widget-env-runtime'),
`runtime chip should be absent, got: ${html}`);
});

test('renders runtime chip with resolved value', () => {
const html = wrap(React.createElement(EnvPill, {
placeholder: { variable: 'DB_TYPE', defaultValue: 'dirty' },
path: ['dbType'],
onChange: () => {},
resolvedValue: 'sqlite',
} as any));
assert.ok(html.includes('settings-widget-env-runtime'),
`runtime chip class should appear, got: ${html}`);
assert.ok(html.includes('sqlite'),
`resolved value text should appear, got: ${html}`);
});

test('renders redacted chip when resolvedValue is [REDACTED]', () => {
const html = wrap(React.createElement(EnvPill, {
placeholder: { variable: 'DB_PASS', defaultValue: '' },
path: ['dbSettings', 'password'],
onChange: () => {},
resolvedValue: '[REDACTED]',
} as any));
assert.ok(html.includes('settings-widget-env-runtime-redacted'),
`redacted chip class should appear, got: ${html}`);
assert.ok(!html.includes('[REDACTED]'),
`literal sentinel must not be displayed to the user, got: ${html}`);
});

test('coerces non-string resolved values to display strings', () => {
const html = wrap(React.createElement(EnvPill, {
placeholder: { variable: 'TRUST_PROXY', defaultValue: 'false' },
path: ['trustProxy'],
onChange: () => {},
resolvedValue: true,
} as any));
assert.ok(html.includes('true'), `expected "true" in ${html}`);
});

test('renders null resolved value as the string null', () => {
const html = wrap(React.createElement(EnvPill, {
placeholder: { variable: 'IP', defaultValue: '' },
path: ['ip'],
onChange: () => {},
resolvedValue: null,
} as any));
assert.ok(html.includes('null'), `expected "null" in ${html}`);
});
12 changes: 12 additions & 0 deletions admin/src/store/store.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import {create} from "zustand";
import {Socket} from "socket.io-client";
import type {JSONPath} from "jsonc-parser";
import {PadSearchResult} from "../utils/PadSearch.ts";
import {AuthorSearchResult} from "../utils/AuthorSearch.ts";
import {InstalledPlugin} from "../pages/Plugin.ts";
import {resolveByPath} from "../utils/resolveByPath.ts";

export type Execution =
| {status: 'idle'}
Expand Down Expand Up @@ -66,6 +68,11 @@ type ToastState = {
type StoreState = {
settings: string|undefined,
setSettings: (settings: string) => void,
// Resolved runtime values for the /admin/settings page. The server
// emits this alongside the raw `settings` string so the SPA can show
// env-substituted values; secrets are redacted to "[REDACTED]".
resolved: unknown | null,
setResolved: (resolved: unknown | null) => void,
settingsSocket: Socket|undefined,
setSettingsSocket: (socket: Socket) => void,
showLoading: boolean,
Expand All @@ -92,6 +99,8 @@ type StoreState = {
export const useStore = create<StoreState>()((set) => ({
settings: undefined,
setSettings: (settings: string) => set({settings}),
resolved: null,
setResolved: (resolved) => set({resolved}),
settingsSocket: undefined,
setSettingsSocket: (socket: Socket) => set({settingsSocket: socket}),
showLoading: false,
Expand All @@ -118,3 +127,6 @@ export const useStore = create<StoreState>()((set) => ({
gdprAuthorErasureEnabled: false,
setGdprAuthorErasureEnabled: (gdprAuthorErasureEnabled)=>set({gdprAuthorErasureEnabled}),
}));

export const useResolvedAt = (path: JSONPath): unknown =>
useStore(s => resolveByPath(s.resolved, path));
41 changes: 41 additions & 0 deletions admin/src/utils/__tests__/resolveByPath.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { resolveByPath } from '../resolveByPath.ts';

test('returns undefined for null/undefined root', () => {
assert.equal(resolveByPath(null, ['a']), undefined);
assert.equal(resolveByPath(undefined, ['a']), undefined);
});

test('walks nested object keys', () => {
assert.equal(resolveByPath({a: {b: {c: 42}}}, ['a', 'b', 'c']), 42);
});

test('walks arrays with numeric indices', () => {
assert.equal(resolveByPath({xs: [10, 20, 30]}, ['xs', 1]), 20);
});

test('walks mixed objects and arrays', () => {
assert.equal(
resolveByPath({sso: {clients: [{id: 'A'}, {id: 'B'}]}}, ['sso', 'clients', 1, 'id']),
'B',
);
});

test('returns undefined for missing keys', () => {
assert.equal(resolveByPath({a: 1}, ['b']), undefined);
assert.equal(resolveByPath({a: {b: 1}}, ['a', 'c']), undefined);
});

test('returns undefined when traversing into a primitive', () => {
assert.equal(resolveByPath({a: 1}, ['a', 'b']), undefined);
});

test('returns the root when path is empty', () => {
const obj = {a: 1};
assert.equal(resolveByPath(obj, []), obj);
});

test('handles string-form numeric indices for arrays', () => {
assert.equal(resolveByPath({xs: [10, 20]}, ['xs', '1']), 20);
});
17 changes: 17 additions & 0 deletions admin/src/utils/resolveByPath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { JSONPath } from 'jsonc-parser';

export const resolveByPath = (obj: unknown, path: JSONPath): unknown => {
let cur: unknown = obj;
for (const seg of path) {
if (cur === null || cur === undefined) return undefined;
if (typeof cur !== 'object') return undefined;
if (Array.isArray(cur)) {
const i = typeof seg === 'number' ? seg : Number(seg);
if (!Number.isInteger(i)) return undefined;
cur = cur[i];
} else {
cur = (cur as Record<string, unknown>)[String(seg)];
}
}
return cur;
};
Loading
Loading