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. +MCP clients (ChatGPT, Claude, …) can request read or propose access through OAuth.
+Create a token for the channel your AI uses: a GET link to paste in chat, a REST Bearer, or an MCP token. It can read flows and submit proposals — it cannot deploy anything until you approve.
-