From 819ee3ebd7b90937804c91ade14dda661eac7868 Mon Sep 17 00:00:00 2001 From: com55 <33087300+com55@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:35:35 +0700 Subject: [PATCH 1/4] feat: Public URL UX, accessible OAuth switch, and dashboard focus rings Show a Settings shortcut and a hint instead of falling back to the page origin for the MCP server URL. Persist Public URL check results, auto-check on change, and warn when OAuth is on with a missing or failed URL. Add a per-browser Access tokens base URL choice. Replace the Enable MCP OAuth checkbox with an accessible switch in the dashboard and editor sidebar, and use thin :focus-visible accent rings on dashboard nav, summaries, and buttons. --- CHANGELOG.md | 18 +++ docs/api/openapi.yaml | 29 ++++- src/plugin/editor-sidebar.ts | 51 +++++++- src/plugin/index.ts | 2 +- src/plugin/page-client-settings.ts | 55 +++++++- src/plugin/page-client.ts | 35 +++++- src/plugin/page-styles.ts | 40 +++++- src/plugin/page.ts | 11 +- src/plugin/settings-routes.ts | 162 +++++++++++++++--------- src/storage/settings-store.ts | 49 +++++++- test/plugin/ai-access-sidebar.test.ts | 37 +++++- test/plugin/dashboard-settings.test.ts | 166 ++++++++++++++++++++++++- test/plugin/page-navigation.test.ts | 18 +++ test/plugin/settings-api.test.ts | 30 +++++ test/storage/settings-store.test.ts | 20 +++ 15 files changed, 631 insertions(+), 92 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4376ba6..c57b3d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to ### Fixed +- Use thin theme-accent focus rings only for keyboard focus on dashboard + navigation, details summaries, and buttons. - Recover dashboard editor jumps when the last-focused editor tab has closed or stopped responding. Track editor presence, clear owned focus and presence on pagehide, and avoid reloading a live editor when acknowledgement times out. @@ -27,6 +29,22 @@ and this project adheres to prereleases in `release.yml` (#128). - Include a generic install URL and install command in prerelease notes (#128). +### Added + +- Require a configured Public URL for the MCP OAuth server URL, add a shortcut + to its Settings field, and show OAuth discovery warnings for missing URLs or + matching persisted failed checks in the dashboard and editor sidebar. +- Automatically check changed Public URLs using the existing guarded probe and + persist check metadata separately from editable settings. +- Add an Access tokens base URL choice for links and setup snippets, defaulting + to this page's URL. Store the preference only in browser localStorage and + disable Public URL selection until configured. + +### Changed + +- Make the dashboard and editor sidebar MCP OAuth controls accessible toggle + switches. + ## [0.2.0-beta.13] - 2026-11-10 - Label the "Opened from dashboard" card action Go to flow for flow, tab and diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 341c02a..a5f8c3c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -437,7 +437,12 @@ paths: post: tags: [Status] summary: Check the configured public URL - description: Probes the MCP endpoint at the supplied HTTPS origin without following redirects. + description: >- + Probes the MCP endpoint at the supplied HTTPS origin without following redirects. + Persists the latest result, including failures, as internal publicUrlCheck metadata. + PUT /settings also runs this check when publicBaseUrl changes. Stored results + apply only when their exact publicBaseUrl matches the current setting. + Clients cannot overwrite this metadata through PUT /settings. operationId: checkPublicUrl security: - AdminSession: [] @@ -463,6 +468,7 @@ paths: ok: { type: boolean } status: { type: [integer, 'null'] } url: { type: string } + publicUrlCheck: { $ref: '#/components/schemas/PublicUrlCheck' } error: { type: string } '400': $ref: '#/components/responses/BadRequest' @@ -1945,6 +1951,22 @@ components: format: uri additionalProperties: false + PublicUrlCheck: + type: object + description: Internal persisted check metadata, not an editable or settings.js-lockable setting. + required: [publicBaseUrl, status, ok, checkedAt] + properties: + publicBaseUrl: + type: string + description: Exact Public URL value checked. Ignore the result for other URL values. + status: + type: [integer, 'null'] + description: HTTP status, or null for network, timeout, or validation failures. + ok: + type: boolean + checkedAt: + type: string + format: date-time AiAccessSettingsResponse: type: object required: @@ -1971,6 +1993,11 @@ components: type: string apiBase: type: string + publicUrlCheck: + oneOf: + - $ref: '#/components/schemas/PublicUrlCheck' + - type: 'null' + description: Latest persisted check, or null before any check; only applies to the matching current Public URL. settings: type: object required: diff --git a/src/plugin/editor-sidebar.ts b/src/plugin/editor-sidebar.ts index c68526e..46427bc 100644 --- a/src/plugin/editor-sidebar.ts +++ b/src/plugin/editor-sidebar.ts @@ -19,6 +19,36 @@ function editorSidebarStyles(): string { flex-direction: column; box-sizing: border-box; } + +#red-ui-sidebar-ai-access #ai-access-oauth-enabled { + appearance: none; + box-sizing: border-box; + display: inline-block; + position: relative; + width: 2.4rem; + height: 1.35rem; + flex: 0 0 auto; + border: 1px solid var(--red-ui-form-input-border-color, #888); + border-radius: 1rem; + background: var(--red-ui-form-input-border-color, #888); + cursor: pointer; + vertical-align: middle; +} +#red-ui-sidebar-ai-access #ai-access-oauth-enabled::before { + content: ''; + position: absolute; + top: 0.15rem; + left: 0.15rem; + width: 0.9rem; + height: 0.9rem; + border-radius: 50%; + background: var(--red-ui-primary-background, #fff); +} +#red-ui-sidebar-ai-access #ai-access-oauth-enabled:checked { background: var(--red-ui-text-color-link, #4682b4); border-color: var(--red-ui-text-color-link, #4682b4); } +#red-ui-sidebar-ai-access #ai-access-oauth-enabled:checked::before { left: calc(100% - 1.05rem); } +#red-ui-sidebar-ai-access #ai-access-oauth-enabled:focus-visible { outline: 2px solid var(--red-ui-form-input-border-selected-color, #4682b4); outline-offset: 3px; } +#red-ui-sidebar-ai-access #ai-access-oauth-enabled:disabled { opacity: 0.5; cursor: not-allowed; } + #red-ui-sidebar-ai-access .red-ui-sidebar-header { text-align: left; font-weight: 600; @@ -326,7 +356,13 @@ function editorSidebarScriptPartA(): string { $('#ai-access-local-row').toggle(!ADMIN_AUTH); var oauth = s.oauth || {}; + var check = data.publicUrlCheck; + var publicUrlFailed = check && check.publicBaseUrl === s.publicBaseUrl && check.ok === false; + $('#ai-access-oauth-public-url-warning') + .text(!s.publicBaseUrl ? 'Set a Public URL on the AI Access dashboard for OAuth discovery.' : 'The latest Public URL check failed. Check the Public URL on the AI Access dashboard.') + .toggle(!!oauth.enabled && (!s.publicBaseUrl || publicUrlFailed)); $('#ai-access-oauth-enabled').prop('checked', !!oauth.enabled) + .attr('aria-checked', String(!!oauth.enabled)) .prop('disabled', readonly || sources.oauth === 'settings.js'); $('#ai-access-readonly-note').toggle(readonly); $('#ai-access-lock-enabled').toggle(sources.enabled === 'settings.js'); @@ -396,6 +432,7 @@ function editorSidebarScriptPartA(): string { } function oauthPublicUrlWarning(value) { + if (LAST && LAST.publicUrlCheck && LAST.publicUrlCheck.publicBaseUrl === value && LAST.publicUrlCheck.ok === false) return 'The latest Public URL check failed. OAuth clients may not discover this server.'; if (!value) return 'No Public URL is configured. OAuth clients may not discover this server.'; try { var url = new URL(value); @@ -446,7 +483,7 @@ function editorSidebarScriptPartA(): string { if (warning) { openConfirmDialog(warning + '\\n\\nEnable OAuth anyway? (Set the Public URL on the AI Access dashboard.)').then(function (accepted) { if (accepted) persistSettings(payload); - else $('#ai-access-oauth-enabled').prop('checked', false); + else $('#ai-access-oauth-enabled').prop('checked', false).attr('aria-checked', 'false'); }); return; } @@ -530,13 +567,14 @@ function editorSidebarScriptPartB(): string { // (2) Enable MCP OAuth. row = $('
').appendTo(body); - $('').appendTo(row); + $('').appendTo(row); $('') .html('

Enable MCP OAuth

MCP clients (ChatGPT, Claude, …) sign in at <Public URL>/mcp.') .appendTo(row); $('') .text('🔒 settings.js').appendTo(row); + $('').appendTo(body); // (3) OAuth route conflict warning — only rendered when conflicts exist. $('').appendTo(body); @@ -545,6 +583,15 @@ function editorSidebarScriptPartB(): string { saveSettings ); + $('#ai-access-oauth-enabled').on('change', function () { + $('#ai-access-oauth-enabled').attr('aria-checked', String($('#ai-access-oauth-enabled').is(':checked'))); + }).on('keydown', function (ev) { + if (ev.key !== 'Enter') return; + ev.preventDefault(); + var control = $('#ai-access-oauth-enabled'); + if (!control.prop('disabled') && !ev.repeat) control[0].click(); + }); + // (4) Open the dashboard. row = $('
').appendTo(body); $('') diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 77efbf8..c1edc28 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -499,7 +499,7 @@ export function registerRoutes( requireAdminSession(RED, 'api'), requireStarPermission(RED), requireJsonContentType('rest'), - asyncRoute(checkPublicUrlHandler(RED)) + asyncRoute(checkPublicUrlHandler(RED, services.settingsStore)) ); const linkGuards = [ diff --git a/src/plugin/page-client-settings.ts b/src/plugin/page-client-settings.ts index de4fec9..a7c5a67 100644 --- a/src/plugin/page-client-settings.ts +++ b/src/plugin/page-client-settings.ts @@ -52,7 +52,8 @@ function settingsPartForm(): string { function mcpServerUrl() { var s = (settingsData && settingsData.settings) || {}; - var base = s.publicBaseUrl ? String(s.publicBaseUrl).replace(/\\/$/, '') : location.origin; + var base = s.publicBaseUrl ? String(s.publicBaseUrl).replace(/\\/$/, '') : ''; + if (!base) return ''; return base + mcpRpc.replace(/\\/rpc$/, ''); } @@ -93,6 +94,7 @@ function settingsPartForm(): string { var o = s.oauth || {}; var oauthLocked = fieldLocked('oauth'); $('oauth-enabled').checked = !!o.enabled; + syncOAuthSwitch(); setDurationInputs('oauth-access-ttl', o.accessTokenTtlSec || 3600, OAUTH_ACCESS_UNITS); setDurationInputs('oauth-refresh-ttl', o.refreshIdleTtlSec || 2592000, OAUTH_REFRESH_UNITS); $('oauth-refresh-max').value = String(Math.round((typeof o.refreshMaxLifetimeSec === 'number' ? o.refreshMaxLifetimeSec : 7776000) / 86400)); @@ -100,7 +102,19 @@ function settingsPartForm(): string { ['oauth-enabled', 'oauth-access-ttl', 'oauth-access-ttl-unit', 'oauth-refresh-ttl', 'oauth-refresh-ttl-unit', 'oauth-refresh-max', 'oauth-dcr-cap'].forEach(function (id) { $(id).disabled = ro || oauthLocked; }); $('lock-oauth').hidden = !oauthLocked; - $('oauth-server-url').textContent = mcpServerUrl(); + var serverUrl = mcpServerUrl(); + $('oauth-server-url').textContent = serverUrl || 'Set a Public URL to get the MCP server URL'; + $('oauth-copy-url').disabled = !serverUrl; + var check = data.publicUrlCheck; + if (check && check.publicBaseUrl === s.publicBaseUrl) renderPublicUrlCheck(check); + else setCheckResult('', ''); + var failed = check && check.publicBaseUrl === s.publicBaseUrl && check.ok === false; + var warning = !!o.enabled && (!s.publicBaseUrl || failed); + $('oauth-public-url-warning').textContent = !s.publicBaseUrl + ? 'Set a Public URL so OAuth clients can discover this server.' + : 'The latest Public URL check failed. OAuth clients may not discover this server. Check the Public URL in Settings.'; + $('oauth-public-url-warning').hidden = !warning; + if (typeof updateTokenUrlBase === 'function') updateTokenUrlBase(); } function oauthEnableWarning(value) { @@ -293,11 +307,12 @@ function settingsPartSave(): string { var warning = oauthEnableWarning(url); if (warning) { $('oauth-enabled').checked = false; + syncOAuthSwitch(); openConfirmDialog({ title: 'Enable MCP OAuth?', message: warning + ' Enable OAuth anyway?', confirmLabel: 'Enable anyway', - onConfirm: function () { $('oauth-enabled').checked = true; persistSettings(payload); } + onConfirm: function () { $('oauth-enabled').checked = true; syncOAuthSwitch(); persistSettings(payload); } }); return; } @@ -331,15 +346,22 @@ function settingsPartSave(): string { el.setAttribute('role', state === 'fail' ? 'alert' : 'status'); } + function renderPublicUrlCheck(r) { + setCheckResult(r.ok ? 'ok' : 'fail', (r.ok ? '✓ Reachable: ' : '✗ Failed: ') + + (r.message || r.error || (r.ok ? 'Public URL is reachable.' : 'Public URL check failed.')) + + (r.status ? ' (HTTP ' + r.status + ')' : '')); + } + async function checkPublicUrl() { setCheckResult('pending', 'Checking…'); try { var r = await api('/settings/check-public-url', { method: 'POST', body: JSON.stringify({ publicBaseUrl: $('set-public-url').value }) }); - setCheckResult(r.ok ? 'ok' : 'fail', (r.ok ? '✓ Reachable: ' : '✗ Failed: ') + (r.message || r.error || '') + - (r.status ? ' (HTTP ' + r.status + ')' : '')); + await loadSettings(); + renderPublicUrlCheck(r); } catch (e) { + await loadSettings(); setCheckResult('fail', '✗ ' + (e.message || String(e))); } } @@ -479,7 +501,17 @@ function settingsPartWire(): string { loadOAuthClients(); } + function syncOAuthSwitch() { + $('oauth-enabled').setAttribute('aria-checked', String($('oauth-enabled').checked)); + } + (function wireSettings() { + $('oauth-enabled').addEventListener('change', syncOAuthSwitch); + $('oauth-enabled').addEventListener('keydown', function (ev) { + if (ev.key !== 'Enter') return; + ev.preventDefault(); + if (!$('oauth-enabled').disabled && !ev.repeat) $('oauth-enabled').click(); + }); var sec = $('section-settings'); var oauthSec = $('section-oauth'); [sec, oauthSec].forEach(function (host) { @@ -500,8 +532,19 @@ function settingsPartWire(): string { }); $('set-secret-reset').addEventListener('click', resetSecretPatterns); $('oauth-refresh').addEventListener('click', function () { loadOAuthStatus(); loadOAuthClients(); }); + $('oauth-public-url-settings').addEventListener('click', function () { + selectTab('settings'); + var focusPublicUrl = function () { + $('set-public-url').focus(); + $('set-public-url').scrollIntoView({ block: 'center', behavior: 'smooth' }); + }; + if (window.requestAnimationFrame) window.requestAnimationFrame(focusPublicUrl); + else focusPublicUrl(); + }); $('oauth-copy-url').addEventListener('click', function () { - copyTextToClipboard($('oauth-server-url').textContent || ''); + var url = mcpServerUrl(); + if (!url) return; + copyTextToClipboard(url); showToast('Server URL copied.', true); }); })(); diff --git a/src/plugin/page-client.ts b/src/plugin/page-client.ts index 0f18686..9f612e1 100644 --- a/src/plugin/page-client.ts +++ b/src/plugin/page-client.ts @@ -853,7 +853,7 @@ function pageClientScriptPartATabs(): string { function loadTabData(target) { if (target === 'settings') loadSettings(); if (target === 'oauth') loadOAuthTab(); - if (target === 'links') loadLinks(); + if (target === 'links') { loadLinks(); loadSettings(); } if (target === 'proposals') loadProposals(); if (target === 'logs') { // Load proposals so older audit rows can resolve titles / flow labels. @@ -1234,8 +1234,36 @@ function pageClientScriptPartB(): string { }; } + var TOKEN_URL_BASE_KEY = 'ai-access.token-url-base'; + function tokenUrlBaseChoice() { + try { return localStorage.getItem(TOKEN_URL_BASE_KEY) === 'public' ? 'public' : 'page'; } + catch (e) { return 'page'; } + } + function tokenPublicBase() { + return settingsData && settingsData.settings && settingsData.settings.publicBaseUrl || ''; + } + function updateTokenUrlBase() { + var select = $('token-url-base'); + if (!select) return; + var available = !!tokenPublicBase(); + $('token-url-base-public').disabled = !available; + $('token-url-base-hint').hidden = available; + var choice = available ? tokenUrlBaseChoice() : 'page'; + select.value = choice; + if (!available) { + try { localStorage.setItem(TOKEN_URL_BASE_KEY, 'page'); } catch (e) { /* storage unavailable */ } + } + select.onchange = function () { + try { localStorage.setItem(TOKEN_URL_BASE_KEY, select.value === 'public' && tokenPublicBase() ? 'public' : 'page'); } + catch (e) { /* storage unavailable */ } + renderTokenUsage(); + }; + renderTokenUsage(); + } + function linkCopyMaterial(kind, token, url) { var origin = (typeof location !== 'undefined' && location.origin) ? location.origin : ''; + if (tokenUrlBaseChoice() === 'public' && tokenPublicBase()) origin = tokenPublicBase().replace(/\\/$/, ''); var base = origin + apiBase; var rpc = origin + mcpRpc; var k = normalizeLinkKind(kind); @@ -1281,9 +1309,9 @@ function pageClientScriptPartB(): string { mcpSnippets: snippets }; } - var getLink = url || (tok + var getLink = tok ? (origin + (apiBase.replace(/\\/mcp\\/api\\/v1\\/?$/, '/mcp/a/') + tok)) - : ''); + : (url ? origin + new URL(url, location.origin).pathname : ''); return { kind: 'get_link', label: 'GET link (chat paste)', @@ -3776,6 +3804,7 @@ function pageClientScriptPartDBoot(): string { var initialTab = initialTabFromHash(location.hash || ''); selectTab(initialTab, { skipHash: !!readProposalHashId(), focusHeading: !!location.hash }); if (initialTab === 'settings') loadSettings(); + if (initialTab === 'links') loadSettings(); if (initialTab === 'oauth') loadOAuthTab(); if (aiOn) { if (canManage) loadLinks(); diff --git a/src/plugin/page-styles.ts b/src/plugin/page-styles.ts index a2a955a..41f4a97 100644 --- a/src/plugin/page-styles.ts +++ b/src/plugin/page-styles.ts @@ -131,7 +131,9 @@ nav.tabs button { } nav.tabs button svg { flex: 0 0 16px; } nav.tabs button:hover { background: var(--soft); } -nav.tabs button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +/* Suppress pointer-focus defaults; retain a consistent keyboard indicator. */ +.settings-layout :is(button, summary, [role="button"]):focus { outline: none; } +.settings-layout :is(button, summary, [role="button"]):focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } nav.tabs button[aria-current="page"] { background: var(--accent-soft); font-weight: 600; } nav.tabs button[aria-current="page"]::before { content: ""; @@ -869,11 +871,7 @@ button.id-copy:focus .id-short { text-decoration: underline; text-underline-offset: 2px; } -button.id-copy:focus { - outline: 2px solid var(--accent); - outline-offset: 2px; - border-radius: 3px; -} +button.id-copy:focus-visible { border-radius: 3px; } .filter-chip { border: 1px solid var(--line); background: var(--card); @@ -1268,6 +1266,36 @@ section.panel { scroll-margin-top: 1rem; } .hint { display: block; color: var(--muted); font-size: 0.88rem; font-weight: 400; margin: 0.25rem 0 0; line-height: 1.4; } .warn-text { color: #8a5a00; } .toggle-row { display: flex; align-items: flex-start; gap: 0.5rem; justify-content: space-between; } + +#oauth-enabled { + appearance: none; + box-sizing: border-box; + display: inline-block; + position: relative; + width: 2.4rem; + height: 1.35rem; + flex: 0 0 auto; + border: 1px solid var(--line); + border-radius: 1rem; + background: var(--line); + cursor: pointer; + vertical-align: middle; +} +#oauth-enabled::before { + content: ''; + position: absolute; + top: 0.15rem; + left: 0.15rem; + width: 0.9rem; + height: 0.9rem; + border-radius: 50%; + background: var(--card); +} +#oauth-enabled:checked { background: var(--accent); border-color: var(--accent); } +#oauth-enabled:checked::before { left: calc(100% - 1.05rem); } +#oauth-enabled:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; } +#oauth-enabled:disabled { opacity: 0.5; cursor: not-allowed; } + .switch-label { display: flex; gap: 0.6rem; align-items: flex-start; cursor: pointer; } .switch-label input { margin-top: 0.25rem; width: 1.05rem; height: 1.05rem; flex: 0 0 auto; } .lock-badge { diff --git a/src/plugin/page.ts b/src/plugin/page.ts index 697ff71..2e83eab 100644 --- a/src/plugin/page.ts +++ b/src/plugin/page.ts @@ -198,6 +198,11 @@ function renderMcpPageLinksSection(): string { a REST Bearer, or an MCP token. It can read flows and submit proposals — it cannot deploy anything until you approve.

+
+ + + Set a Public URL in Settings to use it here. +

Create access token

Access tokens

@@ -353,12 +358,13 @@ function renderMcpPageOAuthSection(): string {

MCP OAuth

MCP clients (ChatGPT, Claude, …) can request read or propose access through OAuth.

+

Status

-
How to connect with OAuth diff --git a/src/plugin/settings-routes.ts b/src/plugin/settings-routes.ts index 608134f..285701b 100644 --- a/src/plugin/settings-routes.ts +++ b/src/plugin/settings-routes.ts @@ -63,7 +63,14 @@ function settingsResponse(RED: NodeRedPluginAPI, store: SettingsStore, req: Auth apiBase: mcpApiBase(RED.settings.httpAdminRoot), // Add future settings to the shared effective type; sources also drives locks. settings, - oauthPublicUrlWarning: oauthPublicUrlWarning(settings.publicBaseUrl), + publicUrlCheck: store.getPublicUrlCheck(), + oauthPublicUrlWarning: + settings.oauth.enabled && + (!settings.publicBaseUrl || + (store.getPublicUrlCheck()?.publicBaseUrl === settings.publicBaseUrl && + store.getPublicUrlCheck()?.ok === false)) + ? 'OAuth discovery may fail. Set a reachable Public URL and check it.' + : oauthPublicUrlWarning(settings.publicBaseUrl), sources, disk: store.getDisk(), trustProxy: normalizeTrustProxy(req.app.get('trust proxy')), @@ -159,7 +166,7 @@ export function putSettingsHandler( store: SettingsStore, linkStore: AccessLinkStore ) { - return (req: AuthenticatedRequest, res: Response): void => { + return async (req: AuthenticatedRequest, res: Response): Promise => { const raw: unknown = req.body; if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { res @@ -249,12 +256,16 @@ export function putSettingsHandler( } try { + const previousUrl = store.getEffective().publicBaseUrl; const effective = store.patch(patch, reset); bindPublicBaseUrl(effective.publicBaseUrl); linkStore.configureDefaults({ defaultTtlSeconds: effective.defaultLinkTtlSeconds, maxActiveLinks: effective.maxActiveLinks, }); + if (effective.publicBaseUrl !== previousUrl) { + await runPublicUrlCheck(RED, store, effective.publicBaseUrl); + } const response = settingsResponse(RED, store, req); const publicUrl = effective.publicBaseUrl ? new URL(effective.publicBaseUrl) : undefined; const literal = publicUrl @@ -282,70 +293,97 @@ export function putSettingsHandler( } /** Star-only outbound probe; never follow redirects or fetch a caller-supplied path. */ -export function checkPublicUrlHandler(RED: NodeRedPluginAPI) { - return async (req: AuthenticatedRequest, res: Response): Promise => { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 5000); - let url: string | undefined; +async function runPublicUrlCheck(RED: NodeRedPluginAPI, store: SettingsStore, value: unknown) { + const publicBaseUrl = typeof value === 'string' ? value : ''; + const finish = ( + result: { + ok: boolean; + status: number | null; + url?: string | undefined; + message?: string; + error?: string; + code?: string; + }, + httpStatus = 200 + ) => { + const check = { + publicBaseUrl, + status: result.status, + ok: result.ok, + checkedAt: new Date().toISOString(), + }; + store.recordPublicUrlCheck(check); + return { httpStatus, body: { ...result, publicUrlCheck: check } }; + }; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 5000); + let url: string | undefined; + try { + let target; try { - let target; - try { - const settings = RED.settings as { uiPort?: number }; - const server = ( - RED as NodeRedPluginAPI & { server?: { address(): string | { port: number } | null } } - ).server; - const address = server?.address(); - const value = (req.body as { publicBaseUrl?: unknown } | undefined)?.publicBaseUrl; - const host = typeof value === 'string' ? new URL(value.trim()).hostname : ''; - const loopback = - host - .toLowerCase() - .replace(/^\[|\]$/g, '') - .replace(/\.$/, '') === 'localhost' || - classifyPublicUrlAddress(host.replace(/^\[|\]$/g, '')) === 'loopback'; - if (loopback && typeof address === 'string') { - throw new Error( - 'Public URL Check does not support loopback URLs when Node-RED listens on a Unix socket' - ); - } - const port = - address && typeof address === 'object' ? address.port : (settings.uiPort ?? 1880); - target = await preparePublicUrlCheck( - (req.body as { publicBaseUrl?: unknown } | undefined)?.publicBaseUrl, - port, - controller.signal + const settings = RED.settings as { uiPort?: number }; + const server = ( + RED as NodeRedPluginAPI & { server?: { address(): string | { port: number } | null } } + ).server; + const address = server?.address(); + const host = typeof value === 'string' ? new URL(value.trim()).hostname : ''; + const loopback = + host + .toLowerCase() + .replace(/^\[|\]$/g, '') + .replace(/\.$/, '') === 'localhost' || + classifyPublicUrlAddress(host.replace(/^\[|\]$/g, '')) === 'loopback'; + if (loopback && typeof address === 'string') { + throw new Error( + 'Public URL Check does not support loopback URLs when Node-RED listens on a Unix socket' ); - } catch (error) { - if (controller.signal.aborted) throw error; - res.status(400).json({ error: (error as Error).message, code: 'invalid_body' }); - return; } - url = target.origin + mcpApiBase(RED.settings.httpAdminRoot); - const status = await requestPublicUrlCheck( - target, - mcpApiBase(RED.settings.httpAdminRoot), - controller.signal + const port = + address && typeof address === 'object' ? address.port : (settings.uiPort ?? 1880); + target = await preparePublicUrlCheck(value, port, controller.signal); + } catch (error) { + if (controller.signal.aborted) throw error; + return finish( + { ok: false, status: null, error: (error as Error).message, code: 'invalid_body' }, + 400 ); - const result = { status, ok: status >= 200 && status < 300 }; - res.json({ - ok: result.ok, - status: result.status, - url, - message: result.ok - ? 'Discovery endpoint is reachable' - : `Discovery endpoint returned HTTP ${result.status}`, - }); - } catch { - res.json({ - ok: false, - status: null, - url, - error: controller.signal.aborted - ? 'Public URL check timed out after 5 seconds' - : 'Could not reach the discovery endpoint', - }); - } finally { - clearTimeout(timer); } + url = target.origin + mcpApiBase(RED.settings.httpAdminRoot); + const status = await requestPublicUrlCheck( + target, + mcpApiBase(RED.settings.httpAdminRoot), + controller.signal + ); + const result = { status, ok: status >= 200 && status < 300 }; + return finish({ + ok: result.ok, + status: result.status, + url, + message: result.ok + ? 'Discovery endpoint is reachable' + : `Discovery endpoint returned HTTP ${result.status}`, + }); + } catch { + return finish({ + ok: false, + status: null, + url, + error: controller.signal.aborted + ? 'Public URL check timed out after 5 seconds' + : 'Could not reach the discovery endpoint', + }); + } finally { + clearTimeout(timer); + } +} + +export function checkPublicUrlHandler(RED: NodeRedPluginAPI, store: SettingsStore) { + return async (req: AuthenticatedRequest, res: Response): Promise => { + const result = await runPublicUrlCheck( + RED, + store, + (req.body as { publicBaseUrl?: unknown } | undefined)?.publicBaseUrl + ); + res.status(result.httpStatus).json(result.body); }; } diff --git a/src/storage/settings-store.ts b/src/storage/settings-store.ts index 2a1046d..d94a89d 100644 --- a/src/storage/settings-store.ts +++ b/src/storage/settings-store.ts @@ -31,6 +31,13 @@ import { /** Max size for config.json (pretty-printed). */ export const MAX_SETTINGS_JSON_BYTES = 16_384; +export interface PublicUrlCheck { + publicBaseUrl: string; + status: number | null; + ok: boolean; + checkedAt: string; +} + export interface PersistedAiAccessSettings { /** When false, AI features are off even if adminAuth is enabled. Default true. */ enabled?: boolean; @@ -281,6 +288,7 @@ export function settingsJsPartial( } export class SettingsStore { + private publicUrlCheck: PublicUrlCheck | null = null; private readonly filePath: string; private disk: PersistedAiAccessSettings; private settingsJs: PersistedAiAccessSettings; @@ -298,6 +306,33 @@ export class SettingsStore { warn ); this.cached = mergeAiAccessSettings(this.settingsJs, this.disk); + try { + const raw = JSON.parse(readFileSync(this.filePath, 'utf8')) as { + publicUrlCheck?: PublicUrlCheck; + }; + const check = raw.publicUrlCheck; + if ( + check && + typeof check.publicBaseUrl === 'string' && + typeof check.ok === 'boolean' && + (check.status === null || Number.isInteger(check.status)) && + typeof check.checkedAt === 'string' && + Number.isFinite(Date.parse(check.checkedAt)) + ) { + this.publicUrlCheck = { ...check }; + } + } catch { + /* No valid internal metadata yet. */ + } + } + + getPublicUrlCheck(): PublicUrlCheck | null { + return this.publicUrlCheck ? { ...this.publicUrlCheck } : null; + } + + recordPublicUrlCheck(check: PublicUrlCheck): void { + this.persist(this.disk, check); + this.publicUrlCheck = { ...check }; } getEffective(): EffectiveAiAccessSettingsWithMeta { @@ -355,11 +390,15 @@ export class SettingsStore { return this.update(next); } - private persist(next: PersistedAiAccessSettings): void { - const payload: TrustedStoragePayload = encodeTrustedJson(next, MAX_SETTINGS_JSON_BYTES, { - kind: 'settings', - pretty: true, - }); + private persist(next: PersistedAiAccessSettings, check = this.publicUrlCheck): void { + const payload: TrustedStoragePayload = encodeTrustedJson( + { ...next, ...(check ? { publicUrlCheck: check } : {}) }, + MAX_SETTINGS_JSON_BYTES, + { + kind: 'settings', + pretty: true, + } + ); writeTrustedAtomic(this.paths.root, this.filePath, payload); } } diff --git a/test/plugin/ai-access-sidebar.test.ts b/test/plugin/ai-access-sidebar.test.ts index 277208b..5ed8d18 100644 --- a/test/plugin/ai-access-sidebar.test.ts +++ b/test/plugin/ai-access-sidebar.test.ts @@ -369,11 +369,23 @@ describe('ai-access-sidebar.js served tab', () => { expect(js, moved).not.toContain(`id="${moved}"`); } expect(js).toContain('id="ai-access-enabled"'); - expect(js).toContain('id="ai-access-oauth-enabled"'); + expect(js).toContain( + 'id="ai-access-oauth-enabled" type="checkbox" role="switch" aria-checked="false"' + ); + expect(js).toContain('for="ai-access-oauth-enabled"'); expect(js).toContain('id="ai-access-oauth-route-warning"'); expect(js).toContain('id="ai-access-open-mcp"'); }); + it('uses a theme link color for the checked OAuth switch track', () => { + const js = renderEditorSidebarJs(); + const checkedStyle = /#ai-access-oauth-enabled:checked \{([^}]+)\}/.exec(js)?.[1]; + expect(checkedStyle).toBeDefined(); + expect(checkedStyle).toContain('background: var(--red-ui-text-color-link, #4682b4)'); + expect(checkedStyle).toContain('border-color: var(--red-ui-text-color-link, #4682b4)'); + expect(checkedStyle).not.toMatch(/--red-ui-form-input-border-(?:selected-)?color/); + }); + function runSidebar( fetcher: (url: string, options?: { method?: string; body?: string }) => unknown ) { @@ -501,6 +513,14 @@ describe('ai-access-sidebar.js served tab', () => { checked: true, disabled: true, }); + expect($('#ai-access-oauth-enabled').elements[0]!.attrs['aria-checked']).toBe('true'); + const click = vi.fn(); + Object.assign($('#ai-access-oauth-enabled').elements[0]!, { click }); + $('#ai-access-oauth-enabled').elements[0]!.handlers.keydown![0]!({ + key: 'Enter', + preventDefault: vi.fn(), + }); + expect(click).not.toHaveBeenCalled(); expect($('#ai-access-oauth-lock').elements[0]!.props.visible).toBe(true); expect($('#ai-access-readonly-note').elements[0]!.props.visible).toBe(!canEdit); // No conflicts → warning hidden. @@ -605,14 +625,25 @@ describe('ai-access-sidebar.js served tab', () => { expect(html).toEqual([]); expect(links).toHaveLength(2); - $('#ai-access-oauth-enabled').elements[0]!.props.checked = true; - $('#ai-access-oauth-enabled').elements[0]!.handlers.change![0]!(); + const oauthSwitch = $('#ai-access-oauth-enabled').elements[0]!; + const click = vi.fn(() => { + oauthSwitch.props.checked = !oauthSwitch.props.checked; + oauthSwitch.handlers.change!.forEach(handler => handler()); + }); + Object.assign(oauthSwitch, { click }); + const preventDefault = vi.fn(); + oauthSwitch.handlers.keydown![0]!({ key: ' ', preventDefault }); + expect(preventDefault).not.toHaveBeenCalled(); + oauthSwitch.handlers.keydown![0]!({ key: 'Enter', preventDefault }); + expect(click).toHaveBeenCalledOnce(); + expect(oauthSwitch.attrs['aria-checked']).toBe('true'); await vi.advanceTimersByTimeAsync(0); expect($('#ai-access-dialog-message').elements[0]!.textContent).toContain('No Public URL'); $('#ai-access-dialog-cancel').elements[0]!.handlers.click![0]!(); await vi.advanceTimersByTimeAsync(0); expect(fetcher.mock.calls.some(call => call[1]?.method === 'PUT')).toBe(false); expect($('#ai-access-oauth-enabled').elements[0]!.props.checked).toBe(false); + expect($('#ai-access-oauth-enabled').elements[0]!.attrs['aria-checked']).toBe('false'); $('#ai-access-oauth-enabled').elements[0]!.props.checked = true; $('#ai-access-oauth-enabled').elements[0]!.handlers.change![0]!(); await vi.advanceTimersByTimeAsync(0); diff --git a/test/plugin/dashboard-settings.test.ts b/test/plugin/dashboard-settings.test.ts index be820e3..47d1af3 100644 --- a/test/plugin/dashboard-settings.test.ts +++ b/test/plugin/dashboard-settings.test.ts @@ -261,6 +261,8 @@ function loadSettingsClient( return result ?? {}; }, location: { origin: 'http://nr.local:1880' }, + window: { requestAnimationFrame: (fn: () => void) => fn() }, + selectTab: vi.fn(), document: { querySelector: (selector: string) => selector === 'nav.tabs button[aria-current="page"]' @@ -299,7 +301,7 @@ function loadSettingsClient( })();`, ctx ); - return { dom, calls, out: ctx.out, confirm, toasts }; + return { dom, calls, out: ctx.out, confirm, toasts, selectTab: ctx.selectTab }; } describe('light theme only', () => { @@ -576,6 +578,32 @@ describe('dashboard settings client', () => { expect(body.oauth).not.toHaveProperty('refreshMaxLifetimeSec'); }); + it('exposes an associated OAuth switch and handles Enter while preserving native Space', () => { + const html = renderMcpPage({ + apiBase: '/mcp/api/v1', + editorRoot: '/', + authTokenKey: 'auth-tokens', + }); + expect(html).toContain('for="oauth-enabled"'); + expect(html).toContain('id="oauth-enabled" role="switch" aria-checked="false"'); + const { dom } = loadSettingsClient({}); + const control = dom.get('oauth-enabled'); + const click = vi.fn(() => { + control.checked = !control.checked; + control.listeners.change!.forEach(fn => fn({})); + }); + Object.assign(control, { click }); + const preventDefault = vi.fn(); + control.listeners.keydown![0]!({ key: 'Enter', preventDefault }); + expect(click).toHaveBeenCalledOnce(); + expect(control.attrs['aria-checked']).toBe('true'); + control.disabled = true; + control.listeners.keydown![0]!({ key: 'Enter', preventDefault }); + expect(click).toHaveBeenCalledOnce(); + control.listeners.keydown![0]!({ key: ' ', preventDefault }); + expect(preventDefault).toHaveBeenCalledTimes(2); + }); + it('read-only users cannot save', () => { const { out, calls } = loadSettingsClient({}); out.applySettings!({ canEdit: false, adminAuthEnabled: true, settings: {}, sources: {} }); @@ -602,7 +630,11 @@ describe('dashboard settings client', () => { out.saveSettingsNow!(); expect(confirm.opts).toBeDefined(); expect(dom.get('oauth-enabled').checked).toBe(false); + expect(dom.get('oauth-enabled').attrs['aria-checked']).toBe('false'); expect(calls.some(c => c.options?.method === 'PUT')).toBe(false); + confirm.opts!.onConfirm!(); + expect(dom.get('oauth-enabled').attrs['aria-checked']).toBe('true'); + expect(calls.some(c => c.options?.method === 'PUT')).toBe(true); }); describe('secret name patterns', () => { @@ -701,3 +733,135 @@ describe('dashboard settings client', () => { }); }); }); + +describe('OAuth Public URL state', () => { + it('shows matching persisted checks and clears missing or stale results', () => { + const { dom, out } = loadSettingsClient({}); + const apply = (check?: unknown) => + out.applySettings!({ + settings: { publicBaseUrl: 'https://example.com' }, + publicUrlCheck: check, + }); + const result = dom.get('set-check-result'); + for (const ok of [true, false]) { + apply({ publicBaseUrl: 'https://example.com', ok, status: ok ? 200 : 503 }); + expect(result.hidden).toBe(false); + expect(result.className).toBe('check-result is-' + (ok ? 'ok' : 'fail')); + expect(result.textContent).toContain(ok ? '✓ Reachable:' : '✗ Failed:'); + expect(result.textContent).toContain(ok ? 'HTTP 200' : 'HTTP 503'); + apply({ publicBaseUrl: 'https://other.example', ok }); + expect(result.hidden).toBe(true); + expect(result.textContent).toBe(''); + } + apply(); + expect(result.hidden).toBe(true); + expect(result.className).toBe('check-result'); + }); + + it('shows the auto-check result returned when saving a changed Public URL', async () => { + const { dom, out, calls } = loadSettingsClient({ + '/settings': { + settings: { publicBaseUrl: 'https://new.example' }, + publicUrlCheck: { publicBaseUrl: 'https://new.example', ok: false, status: 502 }, + }, + }); + out.applySettings!({ settings: { publicBaseUrl: 'https://old.example' } }); + await out.persistSettings!({ publicBaseUrl: 'https://new.example' }); + expect(dom.get('set-check-result').hidden).toBe(false); + expect(dom.get('set-check-result').textContent).toContain('✗ Failed:'); + expect(dom.get('set-check-result').textContent).toContain('HTTP 502'); + expect(calls.some(call => call.path === '/settings/check-public-url')).toBe(false); + }); + + it('opens Settings through tab routing and focuses and scrolls to its Public URL field', () => { + const { dom, selectTab } = loadSettingsClient({}); + const focus = vi.fn(); + const scroll = vi.fn(); + dom.get('set-public-url').focus = focus; + dom.get('set-public-url').scrollIntoView = scroll; + dom.get('oauth-public-url-settings').listeners.click[0](); + expect(selectTab).toHaveBeenCalledWith('settings'); + expect(focus).toHaveBeenCalledOnce(); + expect(scroll).toHaveBeenCalledWith({ block: 'center', behavior: 'smooth' }); + }); + + it('requires a configured URL for copying and warns only for matching failures while enabled', () => { + const { dom, out } = loadSettingsClient({}); + const apply = (url: string, enabled: boolean, check?: unknown) => + out.applySettings({ + settings: { publicBaseUrl: url, oauth: { enabled } }, + publicUrlCheck: check, + }); + apply('', true); + expect(dom.get('oauth-server-url').textContent).toBe( + 'Set a Public URL to get the MCP server URL' + ); + expect(dom.get('oauth-copy-url').disabled).toBe(true); + expect(dom.get('oauth-public-url-warning').hidden).toBe(false); + apply('https://example.com', true); + expect(dom.get('oauth-server-url').textContent).toBe('https://example.com/mcp'); + expect(dom.get('oauth-copy-url').disabled).toBe(false); + expect(dom.get('oauth-public-url-warning').hidden).toBe(true); + apply('https://example.com', true, { publicBaseUrl: 'https://other.example', ok: false }); + expect(dom.get('oauth-public-url-warning').hidden).toBe(true); + apply('https://example.com', true, { publicBaseUrl: 'https://example.com', ok: false }); + expect(dom.get('oauth-public-url-warning').hidden).toBe(false); + apply('https://example.com', false, { publicBaseUrl: 'https://example.com', ok: false }); + expect(dom.get('oauth-public-url-warning').hidden).toBe(true); + }); + + it('uses a browser preference for all token material and disables Public URL when empty', () => { + const script = pageClientScript(); + const start = script.indexOf(' var TOKEN_URL_BASE_KEY'); + const end = script.indexOf(' // Kept for page.test', start); + const dom = fakeDom(); + const storage = new Map(); + const context = { + localStorage: { + getItem: (key: string) => storage.get(key), + setItem: (key: string, value: string) => storage.set(key, value), + }, + location: { origin: 'http://page.example' }, + URL, + settingsData: { settings: { publicBaseUrl: '' } }, + $: (id: string) => dom.get(id), + renderTokenUsage: () => { + // No-op: token usage rendering is outside the scope of this test. + }, + apiBase: '/mcp/api/v1', + mcpRpc: '/mcp/rpc', + normalizeLinkKind: (kind: string) => kind, + restPromptForAi: (token: string, base: string) => base + ' ' + token, + mcpSetupSnippets: (_token: string, rpc: string) => ({ rpc }), + }; + const result = runInNewContext( + script.slice(start, end) + ';({ updateTokenUrlBase, linkCopyMaterial });', + context + ); + result.updateTokenUrlBase(); + expect(dom.get('token-url-base').value).toBe('page'); + expect(dom.get('token-url-base-public').disabled).toBe(true); + expect( + result.linkCopyMaterial('get_link', 'abc', 'https://server.example/mcp/a/abc').primary + ).toBe('http://page.example/mcp/a/abc'); + context.settingsData.settings.publicBaseUrl = 'https://public.example'; + result.updateTokenUrlBase(); + expect(dom.get('token-url-base-public').disabled).toBe(false); + dom.get('token-url-base').value = 'public'; + (dom.get('token-url-base').onchange as () => void)(); + expect(storage.get('ai-access.token-url-base')).toBe('public'); + expect(result.linkCopyMaterial('rest', 'abc', '').primary).toContain( + 'https://public.example/mcp/api/v1' + ); + expect(result.linkCopyMaterial('mcp', 'abc', '').mcpSnippets.rpc).toBe( + 'https://public.example/mcp/rpc' + ); + expect(result.linkCopyMaterial('get_link', 'abc', '').primary).toBe( + 'https://public.example/mcp/a/abc' + ); + context.settingsData.settings.publicBaseUrl = ''; + result.updateTokenUrlBase(); + expect(dom.get('token-url-base').value).toBe('page'); + expect(storage.get('ai-access.token-url-base')).toBe('page'); + }); +}); diff --git a/test/plugin/page-navigation.test.ts b/test/plugin/page-navigation.test.ts index ad59fe8..3ef8d7c 100644 --- a/test/plugin/page-navigation.test.ts +++ b/test/plugin/page-navigation.test.ts @@ -59,6 +59,24 @@ describe('settings navigation', () => { expect(html).toContain('@media (max-width: 768px)'); }); + it('uses thin accent rings only for visible focus on dashboard controls', () => { + const html = renderMcpPage({ + apiBase: '/mcp/api/v1', + editorRoot: '/', + authTokenKey: 'auth-tokens', + }); + expect(html).toContain( + '.settings-layout :is(button, summary, [role="button"]):focus { outline: none; }' + ); + expect(html).toContain( + '.settings-layout :is(button, summary, [role="button"]):focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }' + ); + // The ID-copy button must not override the shared pointer-focus reset. + expect(html).not.toMatch(/button\.id-copy:focus\s*\{[^}]*outline:/); + expect(html).toContain('section.panel > h2:focus { outline: none; }'); + expect(html).not.toMatch(/section\.panel > h2:focus\s*\{[^}]*outline:\s*(?!none)[0-9]/); + }); + it('keeps form rows and filters left-aligned with responsive token fields', () => { const html = renderMcpPage({ apiBase: '/mcp/api/v1', diff --git a/test/plugin/settings-api.test.ts b/test/plugin/settings-api.test.ts index 7b3fff3..dd8d2ba 100644 --- a/test/plugin/settings-api.test.ts +++ b/test/plugin/settings-api.test.ts @@ -525,6 +525,36 @@ describe('GET/PUT /mcp/api/v1/settings', () => { expect(result.body.trustProxy).toBe('loopback'); }); + it('automatically checks changed URLs, persists failures, and ignores client metadata', async () => { + const probe = mockProbe(503); + const { app } = createApp({ userDir, authenticate: asUser('*') }); + const saved = await request(app) + .put('/mcp/api/v1/settings') + .send({ publicBaseUrl: 'https://example.com', oauth: { enabled: true } }); + expect(probe).toHaveBeenCalledTimes(1); + expect(saved.body.publicUrlCheck).toMatchObject({ + publicBaseUrl: 'https://example.com', + status: 503, + ok: false, + }); + expect(Number.isFinite(Date.parse(saved.body.publicUrlCheck.checkedAt))).toBe(true); + const unchanged = await request(app) + .put('/mcp/api/v1/settings') + .send({ publicBaseUrl: 'https://example.com', publicUrlCheck: { ok: true } }); + expect(probe).toHaveBeenCalledTimes(1); + expect(unchanged.body.publicUrlCheck).toEqual(saved.body.publicUrlCheck); + await request(app) + .post('/mcp/api/v1/settings/check-public-url') + .send({ publicBaseUrl: 'https://other.example' }); + const latest = await request(app).get('/mcp/api/v1/settings'); + expect(latest.body.publicUrlCheck.publicBaseUrl).toBe('https://other.example'); + expect(latest.body.oauthPublicUrlWarning).toBeNull(); + const restarted = createApp({ userDir, authenticate: asUser('*') }).app; + expect((await request(restarted).get('/mcp/api/v1/settings')).body.publicUrlCheck).toEqual( + latest.body.publicUrlCheck + ); + }); + it('restricts Check to star users and validates its URL before fetching', async () => { const fetcher = mockProbe(); const { app } = createApp({ userDir, authenticate: asUser('read') }); diff --git a/test/storage/settings-store.test.ts b/test/storage/settings-store.test.ts index ab52129..21d5820 100644 --- a/test/storage/settings-store.test.ts +++ b/test/storage/settings-store.test.ts @@ -52,6 +52,26 @@ describe('SettingsStore', () => { expect(store.getDisk()).toEqual({}); }); + it('persists internal URL checks across restarts and settings writes without accepting settings overrides', () => { + const paths = resolveAiAccessPaths(userDir); + ensureAiAccessDirs(paths); + const store = new SettingsStore(paths); + const check = { + publicBaseUrl: 'https://example.com', + status: 503, + ok: false, + checkedAt: new Date().toISOString(), + }; + store.recordPublicUrlCheck(check); + store.patch({ enabled: false, publicUrlCheck: { ...check, ok: true } } as Parameters< + SettingsStore['patch'] + >[0]); + const reloaded = new SettingsStore(paths); + expect(reloaded.getPublicUrlCheck()).toEqual(check); + expect(reloaded.getDisk()).not.toHaveProperty('publicUrlCheck'); + expect(reloaded.getEffective().sources).not.toHaveProperty('publicUrlCheck'); + }); + it('defaults the maximum registered OAuth clients to 5', () => { const paths = resolveAiAccessPaths(userDir); ensureAiAccessDirs(paths); From b250b6cb3b94899899abcb109bf4c61d2ff0e499 Mon Sep 17 00:00:00 2001 From: com55 <33087300+com55@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:53:10 +0700 Subject: [PATCH 2/4] fix: segmented token URL control and Public URL check races Replace the Access tokens base URL select with a right-aligned segmented control on the heading row. Persist a check only when it matches the current Public URL; do not store unsaved or invalid raw values. Clearing the URL skips the probe and stores null. PUT returns immediately and runs probes serially with latest-wins; the dashboard refetches when the result arrives. Use the same OAuth warning text in the dashboard and editor sidebar. --- CHANGELOG.md | 17 ++- docs/api/openapi.yaml | 8 +- src/plugin/editor-sidebar.ts | 2 +- src/plugin/page-client-settings.ts | 25 ++++ src/plugin/page-client.ts | 34 +++-- src/plugin/page-styles.ts | 19 ++- src/plugin/page.ts | 13 +- src/plugin/settings-routes.ts | 79 +++++++++-- src/storage/settings-store.ts | 6 +- test/integration/node-red-plugin.test.ts | 1 + test/plugin/dashboard-settings.test.ts | 87 ++++++++++-- test/plugin/page-navigation.test.ts | 6 +- test/plugin/settings-api.test.ts | 171 ++++++++++++++++++++++- test/storage/settings-store.test.ts | 2 +- 14 files changed, 411 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c57b3d8..b664e3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,17 @@ and this project adheres to ### Fixed +- Return immediately after saving a Public URL while serialized background + checks keep only the latest matching result. Refresh dashboard check results + and OAuth warnings when checks finish, and match persistent warning text in + the editor sidebar (#129). + +- Skip the Public URL probe when clearing the URL and clear persisted check + metadata so stale failures are removed (#129). +- Persist Public URL probe results only for the current validated settings + origin, preserving saved failures and OAuth warnings when other URLs are + checked (#129). + - Use thin theme-accent focus rings only for keyboard focus on dashboard navigation, details summaries, and buttons. - Recover dashboard editor jumps when the last-focused editor tab has closed or @@ -36,9 +47,9 @@ and this project adheres to matching persisted failed checks in the dashboard and editor sidebar. - Automatically check changed Public URLs using the existing guarded probe and persist check metadata separately from editable settings. -- Add an Access tokens base URL choice for links and setup snippets, defaulting - to this page's URL. Store the preference only in browser localStorage and - disable Public URL selection until configured. +- Add an Access tokens header segmented control for link and setup base URLs, + defaulting to this page's URL. Store the preference only in browser + localStorage and disable Public URL selection until configured. ### Changed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a5f8c3c..993090a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1996,8 +1996,14 @@ components: publicUrlCheck: oneOf: - $ref: '#/components/schemas/PublicUrlCheck' + - type: object + required: [publicBaseUrl, pending] + properties: + publicBaseUrl: { type: string } + pending: { type: boolean, const: true } - type: 'null' - description: Latest persisted check, or null before any check; only applies to the matching current Public URL. + description: >- + Latest persisted check, null before any check or when cleared, or {publicBaseUrl, pending: true} while an automatic check is queued or running. PUT persists and returns without waiting for the probe. Poll GET until a non-pending check matches the current Public URL. Checks run serially; superseded automatic checks are skipped and superseded results are ignored. settings: type: object required: diff --git a/src/plugin/editor-sidebar.ts b/src/plugin/editor-sidebar.ts index 46427bc..33d41b7 100644 --- a/src/plugin/editor-sidebar.ts +++ b/src/plugin/editor-sidebar.ts @@ -359,7 +359,7 @@ function editorSidebarScriptPartA(): string { var check = data.publicUrlCheck; var publicUrlFailed = check && check.publicBaseUrl === s.publicBaseUrl && check.ok === false; $('#ai-access-oauth-public-url-warning') - .text(!s.publicBaseUrl ? 'Set a Public URL on the AI Access dashboard for OAuth discovery.' : 'The latest Public URL check failed. Check the Public URL on the AI Access dashboard.') + .text(!s.publicBaseUrl ? 'Set a Public URL so OAuth clients can discover this server.' : 'The latest Public URL check failed. OAuth clients may not discover this server. Check the Public URL in Settings.') .toggle(!!oauth.enabled && (!s.publicBaseUrl || publicUrlFailed)); $('#ai-access-oauth-enabled').prop('checked', !!oauth.enabled) .attr('aria-checked', String(!!oauth.enabled)) diff --git a/src/plugin/page-client-settings.ts b/src/plugin/page-client-settings.ts index a7c5a67..7f84f75 100644 --- a/src/plugin/page-client-settings.ts +++ b/src/plugin/page-client-settings.ts @@ -264,11 +264,35 @@ function settingsPartSave(): string { else showFlash($(target), msg, false); } + var publicUrlPollGeneration = 0; + async function pollPublicUrlCheck(url, generation) { + for (var i = 0; i < 40; i++) { + await new Promise(function (resolve) { setTimeout(resolve, 250); }); + if (generation !== publicUrlPollGeneration) return; + try { + var data = await api('/settings'); + if (generation !== publicUrlPollGeneration || !settingsData || settingsData.settings.publicBaseUrl !== url) return; + if (data.settings.publicBaseUrl !== url) return; + var check = data.publicUrlCheck; + if (check && check.publicBaseUrl === url && !check.pending) { + settingsData.publicUrlCheck = check; + renderPublicUrlCheck(check); + $('oauth-public-url-warning').hidden = !(settingsData.settings.oauth || {}).enabled || check.ok !== false; + return; + } + } catch (e) { /* Retry transient failures until timeout. */ } + } + } + async function persistSettings(payload) { + var pollGeneration = ++publicUrlPollGeneration; settingsSaving = true; try { var data = await api('/settings', { method: 'PUT', body: JSON.stringify(payload) }); applySettings(data); + if (data.publicUrlCheck && data.publicUrlCheck.pending) { + void pollPublicUrlCheck(data.settings.publicBaseUrl, pollGeneration); + } var warn = data.warnings && data.warnings.length ? data.warnings.join(' ') : ''; $('set-public-url-warning').textContent = warn; $('set-public-url-warning').hidden = !warn; @@ -347,6 +371,7 @@ function settingsPartSave(): string { } function renderPublicUrlCheck(r) { + if (r.pending) { setCheckResult('pending', 'Checking…'); return; } setCheckResult(r.ok ? 'ok' : 'fail', (r.ok ? '✓ Reachable: ' : '✗ Failed: ') + (r.message || r.error || (r.ok ? 'Public URL is reachable.' : 'Public URL check failed.')) + (r.status ? ' (HTTP ' + r.status + ')' : '')); diff --git a/src/plugin/page-client.ts b/src/plugin/page-client.ts index 9f612e1..755b4fa 100644 --- a/src/plugin/page-client.ts +++ b/src/plugin/page-client.ts @@ -1243,21 +1243,35 @@ function pageClientScriptPartB(): string { return settingsData && settingsData.settings && settingsData.settings.publicBaseUrl || ''; } function updateTokenUrlBase() { - var select = $('token-url-base'); - if (!select) return; + if (!$('token-url-base')) return; var available = !!tokenPublicBase(); - $('token-url-base-public').disabled = !available; - $('token-url-base-hint').hidden = available; + var page = $('token-url-base-page'); + var publicButton = $('token-url-base-public'); + publicButton.disabled = !available; + publicButton.setAttribute('aria-disabled', String(!available)); + publicButton.title = available ? '' : 'Set a Public URL in Settings to use it here.'; var choice = available ? tokenUrlBaseChoice() : 'page'; - select.value = choice; if (!available) { try { localStorage.setItem(TOKEN_URL_BASE_KEY, 'page'); } catch (e) { /* storage unavailable */ } } - select.onchange = function () { - try { localStorage.setItem(TOKEN_URL_BASE_KEY, select.value === 'public' && tokenPublicBase() ? 'public' : 'page'); } - catch (e) { /* storage unavailable */ } - renderTokenUsage(); - }; + [page, publicButton].forEach(function (button, index) { + var value = index === 0 ? 'page' : 'public'; + button.setAttribute('aria-checked', String(choice === value)); + button.tabIndex = choice === value ? 0 : -1; + button.onclick = function () { + if (button.disabled) return; + try { localStorage.setItem(TOKEN_URL_BASE_KEY, value); } + catch (e) { /* storage unavailable */ } + updateTokenUrlBase(); + }; + button.onkeydown = function (event) { + if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].indexOf(event.key) === -1) return; + event.preventDefault(); + var next = available && button === page ? publicButton : page; + next.click(); + next.focus(); + }; + }); renderTokenUsage(); } diff --git a/src/plugin/page-styles.ts b/src/plugin/page-styles.ts index 41f4a97..bd753b4 100644 --- a/src/plugin/page-styles.ts +++ b/src/plugin/page-styles.ts @@ -146,7 +146,7 @@ nav.tabs button[aria-current="page"]::before { background: var(--accent); } .settings-layout > section.panel { grid-column: 2; grid-row: 1; min-width: 0; } -section.panel > h2 { +section.panel h2 { padding-bottom: 0.65rem; border-bottom: 1px solid var(--line); font-size: 1.65rem; @@ -190,12 +190,23 @@ section.panel { padding: 1.15rem 1.25rem 1.35rem; } section.panel.active { display: block; } -section.panel > h2 { +section.panel h2 { margin: 0 0 0.75rem; font-size: 1.65rem; } -section.panel > h2:focus { outline: none; } -section.panel > h2:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +section.panel h2:focus { outline: none; } +section.panel h2:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.panel-header { display: flex; flex-wrap: wrap; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem; } +section.panel .panel-header h2 { margin: 0; } +.segmented-control { display: inline-flex; margin-left: auto; } +.segmented-control button { font: inherit; font-size: 0.85rem; padding: 0.4rem 0.65rem; border: 1px solid var(--line); background: var(--card); color: var(--ink); cursor: pointer; } +.segmented-control button + button { border-left: 0; } +.segmented-control button:first-child { border-radius: 7px 0 0 7px; } +.segmented-control button:last-child { border-radius: 0 7px 7px 0; } +.segmented-control button[aria-checked="true"] { background: var(--accent); color: #fff; } +.segmented-control button:disabled { opacity: 0.5; cursor: not-allowed; } +.segmented-control button:focus-visible { position: relative; outline: 2px solid var(--accent); outline-offset: 2px; } + .warning { background: var(--warn-bg); border: 1px solid var(--warn-border); diff --git a/src/plugin/page.ts b/src/plugin/page.ts index 2e83eab..9bbfcd8 100644 --- a/src/plugin/page.ts +++ b/src/plugin/page.ts @@ -192,17 +192,18 @@ function renderMcpPageStatusSection(): string { function renderMcpPageLinksSection(): string { return `