fix(ui): show the server's reason when an add-on request is refused

A 403 or 400 answer showed a generic message and hid the reason in the
response body, so a refusal by the browser guard could not be told from
a missing permission. Show error.message from the body as the server
sent it, and keep the generic text when the body has none.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto committed 2026-10-20 20:54:44 +00:00
1 parent 66e5407c88
commit 01a3ce41ff
5 files changed
+33 -8

No files matched your search

@@ -267,7 +267,7 @@ function AppCard({ app, t, language, actions, onStartStop }) {
}
function ActionResult({ outcome, t }) {
const text = describeOutcome(outcome.outcome, t)
const text = describeOutcome(outcome.outcome, t, outcome.error)
if (!text) return null
return <p className="ad-card__note" data-tone="error" role="alert">{text}</p>
}
@@ -86,7 +86,7 @@ export default function BrowseView({ api, adapter }) {
</div>
{pending && <UnknownNotice addonId={entry.id} actions={actions} />}
{!pending && st && !working && st.outcome !== 'completed' && (
<p className="ad-entry__note" data-tone="error" role="alert">{describeOutcome(st.outcome, t)}</p>
<p className="ad-entry__note" data-tone="error" role="alert">{describeOutcome(st.outcome, t, st.error)}</p>
)}
{st && st.outcome === 'completed' && <p className="ad-entry__note" data-tone="ok" role="status">{t('browse.installedNotEnabled')}</p>}
</div>
@@ -194,7 +194,7 @@ function latest(actions, addon, verbs) {
function outcomeText(result, t) {
const type = result && result.error && result.error.type
if (type && UPDATE_TYPES.has(type)) return t(`updates.errors.${type}`)
return describeOutcome(result.outcome, t)
return describeOutcome(result.outcome, t, result.error)
}
function UpdateFact({ view, t }) {
+14 -5
View File
@@ -1,11 +1,20 @@
// The reason the server gave for a refusal, as sent. AddonsError falls back to
// the kind when the body had no message, and that is not a reason.
function serverReason(error) {
const m = error && typeof error.message === 'string' ? error.message.trim() : ''
return m && m !== error.kind ? m : ''
}
// One message per kind of answer, the same on every page. The server's words
// are never shown as success and never hidden behind a generic "failed".
// are never shown as success and never hidden behind a generic "failed". A
// refusal (403 or 400) shows the server's own reason when the body has one,
// untranslated, and the generic text only when it has none.
export function describeError(error, t) {
const kind = error && error.kind
switch (kind) {
case 'auth': return t('errors.auth')
case 'denied': return t('errors.denied')
case 'invalid': return t('errors.invalid')
case 'denied': return serverReason(error) || t('errors.denied')
case 'invalid': return serverReason(error) || t('errors.invalid')
case 'unknown_outcome': return t('errors.unknownOutcome')
case 'network': return t('errors.network')
default: return t('errors.unavailable')
@@ -14,13 +23,13 @@ export function describeError(error, t) {
// The words for the outcome of an action the user started. `completed` has
// none: the list on the page shows the new state.
export function describeOutcome(outcome, t) {
export function describeOutcome(outcome, t, error) {
switch (outcome) {
case 'completed': return ''
case 'failed': return t('actions.failed')
case 'unknown': return t('actions.unknown')
case 'not_applied': return t('actions.notApplied')
case 'blocked': return t('actions.blocked')
default: return describeError({ kind: outcome }, t)
default: return describeError({ kind: outcome, message: error && error.message }, t)
}
}
@@ -12,6 +12,7 @@ import {
import { createAddonsApi, createAddonsCatalogAdapter, AddonsError } from '../../src/utils/addonsApi.js'
import { runLifecycle, settleOperation } from '../../src/components/addons/lifecycleFlow.js'
import { createActionBook, effectOutcome } from '../../src/components/addons/actionBook.js'
import { describeError, describeOutcome } from '../../src/components/addons/errors.js'
const json = (status, body) => ({ ok: status >= 200 && status < 300, status, json: async () => body })
@@ -262,3 +263,18 @@ test('a completed status that left the add-on unchanged is not reported as done'
assert.equal(effectOutcome('failed', false), 'failed')
assert.equal(effectOutcome('unknown', false), 'unknown')
})
test('a refusal shows the server reason and falls back to the generic text', () => {
const t = key => `T:${key}`
const withBody = new AddonsError(classifyResponse(403, { error: { type: 'addon_error', message: 'Same-origin browser request required' } }))
assert.equal(describeError(withBody, t), 'Same-origin browser request required')
assert.equal(describeOutcome('denied', t, withBody), 'Same-origin browser request required')
const noBody = new AddonsError(classifyResponse(403, null))
assert.equal(describeError(noBody, t), 'T:errors.denied')
assert.equal(describeOutcome('denied', t, noBody), 'T:errors.denied')
assert.equal(describeOutcome('denied', t), 'T:errors.denied')
const invalid = new AddonsError(classifyResponse(400, { error: { message: 'Bad id' } }))
assert.equal(describeError(invalid, t), 'Bad id')
// Other kinds keep their own words.
assert.equal(describeError(new AddonsError(classifyResponse(401, { error: { message: 'x' } })), t), 'T:errors.auth')
})