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