diff --git a/CHANGELOG.md b/CHANGELOG.md index a27838d3..9e5f8044 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `ALLOW_PRIVATE_IPS=true` as the setting that allows it. The advice was added in 1.6.0 but never appeared. +- A widget settings Fetch marks a blocked private address as a warning, the + same as a badge test, instead of a hard error. + ## [1.6.0] - 2026-08-13 ### Added diff --git a/ui/css/admin.css b/ui/css/admin.css index 044d8bc6..2e0bcbfa 100644 --- a/ui/css/admin.css +++ b/ui/css/admin.css @@ -210,6 +210,7 @@ html{background:var(--bg-color,var(--bg-outer))} .row-status{font-size:13px;margin-inline-end:8px;color:var(--dm)} .row-status.ok{color:var(--ok)} .row-status.err{color:var(--danger)} +.row-status.warn{color:var(--warning)} /* ── Textarea row (Add to URL / Add to Header) ── */ .kv-hdr{padding-bottom:4px} diff --git a/ui/css/tokens.css b/ui/css/tokens.css index b3c44b87..7242ec8a 100644 --- a/ui/css/tokens.css +++ b/ui/css/tokens.css @@ -88,6 +88,7 @@ --accent: var(--sy-teal); --accent-strong: var(--sy-teal-hi); --danger: var(--sy-red); + --warning: var(--sy-orange); --success: var(--sy-green); --sy-radius-lg:14px; diff --git a/ui/js/admin-app-form.js b/ui/js/admin-app-form.js index 91234027..9989b9b2 100644 --- a/ui/js/admin-app-form.js +++ b/ui/js/admin-app-form.js @@ -591,7 +591,7 @@ async function fetchBadge() { /* Branch on the error's `kind`, never on words inside its message. */ const advice = badgeErrorAdvice(e); if (st) { - st.style.cssText = 'margin-top:4px;color:' + (advice.tone === TONE.WARN ? '#ff9f0a' : '#ff453a'); + st.style.cssText = 'margin-top:4px;color:' + (advice.tone === TONE.WARN ? 'var(--warning)' : 'var(--danger)'); st.textContent = advice.tone === TONE.WARN ? advice.message : '✗ ' + advice.message; } if (advice.openAuth) { diff --git a/ui/js/admin-error.js b/ui/js/admin-error.js index f32d1d39..c821a881 100644 --- a/ui/js/admin-error.js +++ b/ui/js/admin-error.js @@ -79,10 +79,14 @@ export function badgeErrorAdvice(e) { }; } -export function optionsErrorText(e) { +/* Same shape as badgeErrorAdvice: a settings Fetch and a badge test report the + same failures and must not disagree about the tone. */ +export function optionsErrorAdvice(e) { const read = readError(e); const { kind, message } = read; - if (kind === KIND.INVALID && message) return message; - if (blockedForPrivateAddress(read)) return `${message} ${PRIVATE_ADDRESS_ADVICE}`; - return 'Fetch failed: ' + (message || 'Request failed.'); + if (blockedForPrivateAddress(read)) { + return { tone: TONE.WARN, message: `${message} ${PRIVATE_ADDRESS_ADVICE}` }; + } + if (kind === KIND.INVALID && message) return { tone: TONE.ERROR, message }; + return { tone: TONE.ERROR, message: 'Fetch failed: ' + (message || 'Request failed.') }; } diff --git a/ui/js/widget-config-form.js b/ui/js/widget-config-form.js index 0fa2b745..f00ff8aa 100644 --- a/ui/js/widget-config-form.js +++ b/ui/js/widget-config-form.js @@ -13,7 +13,7 @@ import { groupBounds, visibleFieldFlags, } from '/js/admin-logic.js?v=056a11e9'; -import { optionsErrorText } from '/js/admin-error.js?v=af729113'; +import { optionsErrorAdvice, TONE } from '/js/admin-error.js?v=af729113'; import { qi } from '/js/utils.js?v=84d58686'; const PE = @@ -269,8 +269,9 @@ function _picklist(field, value, ctx, size) { : 'No options found'; status.className = 'row-status ok'; } catch (e) { - status.textContent = optionsErrorText(e); - status.className = 'row-status err'; + const advice = optionsErrorAdvice(e); + status.textContent = advice.message; + status.className = 'row-status ' + (advice.tone === TONE.WARN ? 'warn' : 'err'); } finally { btn.disabled = false; } @@ -353,8 +354,9 @@ function _select(field, value, ctx, config = {}) { : 'No options found'; status.className = 'row-status ok'; } catch (e) { - status.textContent = optionsErrorText(e); - status.className = 'row-status err'; + const advice = optionsErrorAdvice(e); + status.textContent = advice.message; + status.className = 'row-status ' + (advice.tone === TONE.WARN ? 'warn' : 'err'); } finally { btn.disabled = false; } diff --git a/ui/test/admin-error.test.mjs b/ui/test/admin-error.test.mjs index 426f3fb9..73c15cab 100644 --- a/ui/test/admin-error.test.mjs +++ b/ui/test/admin-error.test.mjs @@ -12,7 +12,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createRequire } from 'node:module'; -import { badgeErrorAdvice, optionsErrorText, KIND, TONE } from '../js/admin-error.js'; +import { badgeErrorAdvice, optionsErrorAdvice, KIND, TONE } from '../js/admin-error.js'; /* api/src/api-error.js is CommonJS (the server half of the codebase is), so it needs createRequire rather than a plain import. */ @@ -84,8 +84,11 @@ test('a block without the reason code gets no private-address advice', () => { test('the widget options Fetch gives the same advice', () => { const blocked = { kind: KIND.BLOCKED, message: 'The request was blocked.', detail: { reason: 'private-address' } }; - assert.match(optionsErrorText(blocked), /ALLOW_PRIVATE_IPS=true/); - assert.match(optionsErrorText({ kind: KIND.BLOCKED, message: 'The request was blocked.' }), /^Fetch failed: /); + assert.match(optionsErrorAdvice(blocked).message, /ALLOW_PRIVATE_IPS=true/); + assert.equal(optionsErrorAdvice(blocked).tone, TONE.WARN, 'a fixable setting is not a hard error'); + const plain = optionsErrorAdvice({ kind: KIND.BLOCKED, message: 'The request was blocked.' }); + assert.match(plain.message, /^Fetch failed: /); + assert.equal(plain.tone, TONE.ERROR); }); test('other blocked reasons are still shown verbatim', () => { @@ -109,22 +112,22 @@ test('an unknown future kind degrades instead of crashing an older frontend', () assert.equal(a.message, 'Too many requests.'); }); -/* ── optionsErrorText (P11-2) ─────────────────────────────────────────────── */ +/* ── optionsErrorAdvice (P11-2) ───────────────────────────────────────────── */ test('the retype instruction is shown on its own, without a failure prefix', async () => { - const { optionsErrorText } = await import('../js/admin-error.js'); + const { optionsErrorAdvice } = await import('../js/admin-error.js'); const msg = 'This configuration has changed since it was saved, so the stored credential was not used. Enter the credential to test these settings.'; - assert.equal(optionsErrorText({ kind: KIND.INVALID, message: msg }), msg); + assert.equal(optionsErrorAdvice({ kind: KIND.INVALID, message: msg }).message, msg); }); test('any other failure keeps the Fetch failed prefix', async () => { - const { optionsErrorText } = await import('../js/admin-error.js'); - assert.equal(optionsErrorText({ kind: KIND.NETWORK, message: 'boom' }), 'Fetch failed: boom'); - assert.equal(optionsErrorText(new Error('boom')), 'Fetch failed: boom'); + const { optionsErrorAdvice } = await import('../js/admin-error.js'); + assert.equal(optionsErrorAdvice({ kind: KIND.NETWORK, message: 'boom' }).message, 'Fetch failed: boom'); + assert.equal(optionsErrorAdvice(new Error('boom')).message, 'Fetch failed: boom'); }); -test('optionsErrorText tolerates an error with no message', async () => { - const { optionsErrorText } = await import('../js/admin-error.js'); - assert.equal(optionsErrorText({ kind: KIND.INVALID }), 'Fetch failed: Request failed.'); +test('optionsErrorAdvice tolerates an error with no message', async () => { + const { optionsErrorAdvice } = await import('../js/admin-error.js'); + assert.equal(optionsErrorAdvice({ kind: KIND.INVALID }).message, 'Fetch failed: Request failed.'); });