Frontend: Render auth error toasts in the current UI locale #5699 #5682

This commit is contained in:
Michael Mayer 2026-06-27 11:34:54 +00:00
parent a2e8592dd5
commit 0575659c08
7 changed files with 86 additions and 25 deletions

View file

@ -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 }};

View file

@ -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…"));
},

View file

@ -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];
}

View file

@ -38,6 +38,8 @@ const defaultLegacyClearKeys = new Set([
"session.provider",
"session.scope",
"session.error",
"session.messageId",
"session.messageParams",
"clipboard",
"clipboard.photos",
"clipboard.albums",

View file

@ -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() {

View file

@ -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();
});

View file

@ -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