diff --git a/assets/templates/auth.gohtml b/assets/templates/auth.gohtml index 51b3cad56..8ac4ef709 100644 --- a/assets/templates/auth.gohtml +++ b/assets/templates/auth.gohtml @@ -30,6 +30,8 @@ "session.provider", "session.scope", "session.error", + "session.messageId", + "session.messageParams", ]; const storages = [localStorage, sessionStorage].filter(Boolean); const preferredStorage = @@ -48,27 +50,23 @@ const setItem = (key, value) => { preferredStorage.setItem(namespacedKey(key), value); }; - const removeItem = (key) => { - storages.forEach((storage) => storage.removeItem(namespacedKey(key))); - }; + {{/* clearSessionData() removes every session.* key (including session.error, + session.messageId, and session.messageParams), so each branch only sets + the keys it wants to persist. */}} {{ if eq .status "success" }} clearSessionData(); setItem("session.id", {{ .session_id }}); setItem("session.token", {{ .access_token }}); setItem("session.provider", {{ .provider }}); setItem("session.user", JSON.stringify({{ .user }})); - removeItem("session.error"); {{ else if eq .status "failed" }} clearSessionData(); setItem("session.error", {{ .error }}); + {{ if .messageId }}setItem("session.messageId", {{ .messageId }});{{ end }} + {{ if .messageParams }}setItem("session.messageParams", JSON.stringify({{ .messageParams }}));{{ end }} {{ else }} clearSessionData(); - removeItem("session.id"); - removeItem("session.token"); - removeItem("session.provider"); - removeItem("session.user"); - removeItem("session.error"); {{ end }} })(); window.location.href = {{ .config.LoginUri }}; diff --git a/frontend/src/common/notify.js b/frontend/src/common/notify.js index 9f7ebe5bc..10692d7b5 100644 --- a/frontend/src/common/notify.js +++ b/frontend/src/common/notify.js @@ -30,26 +30,34 @@ let ajaxPending = 0; let ajaxCallbacks = []; const $notify = { - info: function (message) { - $event.publish("notify.info", { message }); + // info publishes an info notification. + info: function (message, messageId, messageParams) { + $event.publish("notify.info", { message, messageId, messageParams }); }, - warn: function (message) { - $event.publish("notify.warning", { message }); + // warn publishes a warning notification. + warn: function (message, messageId, messageParams) { + $event.publish("notify.warning", { message, messageId, messageParams }); }, - error: function (message) { - $event.publish("notify.error", { message }); + // error publishes an error notification; the optional messageId/messageParams + // let notify.vue render it in the current UI locale via Tp. + error: function (message, messageId, messageParams) { + $event.publish("notify.error", { message, messageId, messageParams }); }, - success: function (message) { - $event.publish("notify.success", { message }); + // success publishes a success notification. + success: function (message, messageId, messageParams) { + $event.publish("notify.success", { message, messageId, messageParams }); }, - logout: function (message) { - $event.publish("notify.error", { message }); - $event.publish("session.logout", { message }); + // logout publishes an error notification and triggers a session.logout event. + logout: function (message, messageId, messageParams) { + $event.publish("notify.error", { message, messageId, messageParams }); + $event.publish("session.logout", { message, messageId, messageParams }); }, + // ajaxStart marks an AJAX request as started and emits ajax.start. ajaxStart: function () { ajaxPending++; $event.publish("ajax.start"); }, + // ajaxEnd marks an AJAX request as finished, emits ajax.end, and runs queued idle callbacks. ajaxEnd: function () { ajaxPending--; $event.publish("ajax.end"); @@ -60,6 +68,7 @@ const $notify = { callbacks.forEach((cb) => cb()); } }, + // ajaxBusy reports whether any AJAX requests are still pending. ajaxBusy: function () { if (ajaxPending < 0) { ajaxPending = 0; @@ -67,6 +76,7 @@ const $notify = { return ajaxPending > 0; }, + // ajaxWait resolves once no AJAX requests are pending or the timeout elapses. ajaxWait: function (idleDelay = 64, timeout = 8000) { return new Promise((resolve) => { const start = Date.now(); @@ -94,6 +104,7 @@ const $notify = { settle(); }); }, + // blockUI shows the busy overlay, optionally setting its CSS class. blockUI: function (className) { const el = document.getElementById("busy-overlay"); @@ -104,6 +115,7 @@ const $notify = { } } }, + // unblockUI hides the busy overlay. unblockUI: function () { const el = document.getElementById("busy-overlay"); @@ -112,9 +124,11 @@ const $notify = { el.className = ""; } }, + // wait shows a "Please wait" info notification. wait: function () { this.info($gettext("Please wait…")); }, + // busy shows a "Busy, please wait" warning notification. busy: function () { this.warn($gettext("Busy, please wait…")); }, diff --git a/frontend/src/common/session.js b/frontend/src/common/session.js index f4a881f66..7eb06a4b5 100644 --- a/frontend/src/common/session.js +++ b/frontend/src/common/session.js @@ -351,6 +351,8 @@ export default class Session { this.clearLegacyKey("session.scope"); this.clearLegacyKey("authError"); this.clearLegacyKey("session.error"); + this.clearLegacyKey("session.messageId"); + this.clearLegacyKey("session.messageParams"); delete $api.defaults.headers.common[RequestHeader]; } diff --git a/frontend/src/common/storage.js b/frontend/src/common/storage.js index 1334779a0..d29909bdc 100644 --- a/frontend/src/common/storage.js +++ b/frontend/src/common/storage.js @@ -38,6 +38,8 @@ const defaultLegacyClearKeys = new Set([ "session.provider", "session.scope", "session.error", + "session.messageId", + "session.messageParams", "clipboard", "clipboard.photos", "clipboard.albums", diff --git a/frontend/src/page/auth/login.vue b/frontend/src/page/auth/login.vue index bd4466cc9..c2481e865 100644 --- a/frontend/src/page/auth/login.vue +++ b/frontend/src/page/auth/login.vue @@ -274,10 +274,24 @@ export default { // stashed by the auth bridge before its full-page redirect. In mounted() so // the notification component has already subscribed (its created() runs // before any mounted()). + const messageId = getAppStorage().getItem("session.messageId"); const authError = getAppStorage().getItem("session.error"); - if (authError) { + if (messageId || authError) { + let messageParams = []; + const rawParams = getAppStorage().getItem("session.messageParams"); + if (rawParams) { + try { + messageParams = JSON.parse(rawParams); + } catch (e) { + messageParams = []; + } + } getAppStorage().removeItem("session.error"); - this.$notify.error(authError); + getAppStorage().removeItem("session.messageId"); + getAppStorage().removeItem("session.messageParams"); + // Render in the current UI locale via the message key (notify.vue applies + // Tp); the server-rendered string stays as the fallback. + this.$notify.error(authError, messageId, messageParams); } }, unmounted() { diff --git a/frontend/tests/vitest/common/notify.test.js b/frontend/tests/vitest/common/notify.test.js index a80079dc9..056a7ff2a 100644 --- a/frontend/tests/vitest/common/notify.test.js +++ b/frontend/tests/vitest/common/notify.test.js @@ -1,6 +1,7 @@ -import { describe, it } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import "../fixtures"; import $notify from "common/notify"; +import $event from "common/event"; describe("common/alert", () => { it("should call alert.info", () => { @@ -19,6 +20,20 @@ describe("common/alert", () => { $notify.success("message"); }); + it("error forwards an optional message id and params for UI-locale rendering", async () => { + const handler = vi.fn(); + const token = $event.subscribe("notify.error", handler); + $notify.error("fallback", "Registration disabled", ["x"]); + // pubsub.js delivers asynchronously, so wait a tick before asserting. + await new Promise((resolve) => setTimeout(resolve, 0)); + $event.unsubscribe(token); + expect(handler).toHaveBeenCalledWith("notify.error", { + message: "fallback", + messageId: "Registration disabled", + messageParams: ["x"], + }); + }); + it("should call wait", () => { $notify.wait(); }); diff --git a/frontend/tests/vitest/page/auth/login.test.js b/frontend/tests/vitest/page/auth/login.test.js index 70a6637df..ebd8b5d5a 100644 --- a/frontend/tests/vitest/page/auth/login.test.js +++ b/frontend/tests/vitest/page/auth/login.test.js @@ -173,9 +173,25 @@ describe("page/auth/login", () => { const { wrapper } = mountLogin({ oidcEnabled: true }); - // Consumed on mount so it does not reappear on the next reload. + // Consumed on mount so it does not reappear on the next reload. With no + // messageId, the server-rendered fallback string is surfaced as-is. expect(window.localStorage.getItem(`${storagePrefix}session.error`)).toBeNull(); - expect(wrapper.vm.$notify.error).toHaveBeenCalledWith("You do not have access to this instance."); + expect(wrapper.vm.$notify.error).toHaveBeenCalledWith("You do not have access to this instance.", null, []); + }); + + it("forwards a stored OIDC error by message key so it renders in the current UI locale", () => { + window.localStorage.setItem(`${storagePrefix}session.error`, "Registration disabled"); + window.localStorage.setItem(`${storagePrefix}session.messageId`, "Registration disabled"); + window.localStorage.setItem(`${storagePrefix}session.messageParams`, JSON.stringify([])); + + const { wrapper } = mountLogin({ oidcEnabled: true }); + + // All three keys are consumed on mount so the toast does not reappear. + expect(window.localStorage.getItem(`${storagePrefix}session.error`)).toBeNull(); + expect(window.localStorage.getItem(`${storagePrefix}session.messageId`)).toBeNull(); + expect(window.localStorage.getItem(`${storagePrefix}session.messageParams`)).toBeNull(); + // The message key is forwarded so notify.vue translates it via Tp. + expect(wrapper.vm.$notify.error).toHaveBeenCalledWith("Registration disabled", "Registration disabled", []); }); // Auto-OIDC redirect for unauthenticated visitors now lives in the