diff --git a/core/http/react-ui/src/components/addons/AppsView.jsx b/core/http/react-ui/src/components/addons/AppsView.jsx index 77d656f6a..f8bd5c793 100644 --- a/core/http/react-ui/src/components/addons/AppsView.jsx +++ b/core/http/react-ui/src/components/addons/AppsView.jsx @@ -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
{text}
} diff --git a/core/http/react-ui/src/components/addons/BrowseView.jsx b/core/http/react-ui/src/components/addons/BrowseView.jsx index f52373ea5..8f70f38fb 100644 --- a/core/http/react-ui/src/components/addons/BrowseView.jsx +++ b/core/http/react-ui/src/components/addons/BrowseView.jsx @@ -86,7 +86,7 @@ export default function BrowseView({ api, adapter }) { {pending &&{describeOutcome(st.outcome, t)}
+{describeOutcome(st.outcome, t, st.error)}
)} {st && st.outcome === 'completed' &&{t('browse.installedNotEnabled')}
} diff --git a/core/http/react-ui/src/components/addons/InstalledView.jsx b/core/http/react-ui/src/components/addons/InstalledView.jsx index 0e214342f..894755c9e 100644 --- a/core/http/react-ui/src/components/addons/InstalledView.jsx +++ b/core/http/react-ui/src/components/addons/InstalledView.jsx @@ -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 }) { diff --git a/core/http/react-ui/src/components/addons/errors.js b/core/http/react-ui/src/components/addons/errors.js index a3c23799a..6999e4a57 100644 --- a/core/http/react-ui/src/components/addons/errors.js +++ b/core/http/react-ui/src/components/addons/errors.js @@ -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) } } diff --git a/core/http/react-ui/tests/unit/addons.test.mjs b/core/http/react-ui/tests/unit/addons.test.mjs index 34f6b9bc4..455da2e96 100644 --- a/core/http/react-ui/tests/unit/addons.test.mjs +++ b/core/http/react-ui/tests/unit/addons.test.mjs @@ -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') +})