Skip to content

Commit 92af7f8

Browse files
authored
Merge pull request #6842 from christianbeeznest/storm-22993
Internal: Unified 403 handling: same access-restriction message across vue & twig - refs BT#22993
2 parents 1b1d07d + ea6e0b2 commit 92af7f8

File tree

5 files changed

+324
-47
lines changed

5 files changed

+324
-47
lines changed

assets/vue/App.vue

Lines changed: 42 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,30 @@
44
v-if="!platformConfigurationStore.isLoading"
55
:show-breadcrumb="route.meta.showBreadcrumb"
66
>
7-
<slot />
7+
<!-- 403 banner shown INSIDE the layout -->
88
<div
9-
id="legacy_content"
10-
ref="legacyContainer"
11-
/>
12-
<ConfirmDialog />
13-
14-
<AccessUrlChooser v-if="!showAccessUrlChosserLayout" />
15-
<DockedChat v-if="showGlobalChat" />
9+
v-if="forbiddenMsg"
10+
class="forbidden-banner container max-w-2xl mx-auto mt-6"
11+
role="alert"
12+
aria-live="polite"
13+
>
14+
<div class="flex items-center gap-4 rounded-2xl p-6 bg-warning text-white/80 shadow">
15+
<i class="mdi mdi-alert-outline text-4xl text-white"></i>
16+
<p class="font-extrabold text-xl text-white" v-text="forbiddenMsg" />
17+
</div>
18+
</div>
19+
20+
<!-- Page content; optionally dim/disable when forbidden -->
21+
<div :class="{ 'opacity-50 pointer-events-none': !!forbiddenMsg }">
22+
<slot />
23+
<div id="legacy_content" ref="legacyContainer" />
24+
<ConfirmDialog />
25+
<AccessUrlChooser v-if="!showAccessUrlChosserLayout" />
26+
<DockedChat v-if="showGlobalChat" />
27+
</div>
1628
</component>
29+
30+
<!-- Toasts -->
1731
<Toast position="top-center">
1832
<template #message="slotProps">
1933
<span
@@ -64,6 +78,10 @@ import apolloClient from "./config/apolloClient"
6478
import { useAccessUrlChooser } from "./composables/accessurl/accessUrlChooser"
6579
import AccessUrlChooser from "./components/accessurl/AccessUrlChooser.vue"
6680
import { setLocale } from "./i18n"
81+
import { useStore } from "vuex"
82+
83+
const vuex = useStore()
84+
const forbiddenMsg = computed(() => vuex.state.ux?.forbiddenMessage)
6785
6886
provide(DefaultApolloClient, apolloClient)
6987
@@ -159,17 +177,13 @@ onUpdated(() => {
159177
})
160178
161179
axios.interceptors.response.use(
162-
undefined,
163-
(error) =>
164-
new Promise(() => {
165-
if (401 === error.response?.status) {
166-
notification.showWarningNotification(error.response.data?.error)
167-
} else if (500 === error.response?.status) {
168-
notification.showWarningNotification(error.response.data?.detail)
169-
}
170-
171-
throw error
172-
}),
180+
(r) => r,
181+
(error) => {
182+
const s = error?.response?.status
183+
if (s === 401) notification.showWarningNotification(error.response?.data?.error || "Unauthorized")
184+
else if (s === 500) notification.showWarningNotification(error.response?.data?.detail || "Server error")
185+
return Promise.reject(error)
186+
}
173187
)
174188
175189
platformConfigurationStore.initialize()
@@ -219,4 +233,13 @@ const showGlobalChat = computed(() => {
219233
console.log("[CHAT] showGlobalChat=", visible, "| isAuthenticated=", securityStore.isAuthenticated, "| allowGlobalChat=", allowGlobalChat.value)
220234
return visible
221235
})
236+
237+
watch(forbiddenMsg, (msg) => {
238+
if (msg) {
239+
const legacy = document.getElementById('legacy_content')
240+
if (legacy) legacy.innerHTML = ''
241+
const section = document.getElementById('sectionMainContent')
242+
if (section) section.innerHTML = ''
243+
}
244+
})
222245
</script>

assets/vue/main.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import sessionService from "./services/session"
2626
import socialPostService from "./services/socialpost"
2727

2828
import makeCrudModule from "./store/modules/crud"
29+
import installHttpErrors from "./plugins/httpErrors"
30+
import uxModule from "./store/modules/ux"
2931

3032
import VueFlatPickr from "vue-flatpickr-component"
3133
import "flatpickr/dist/flatpickr.css"
@@ -171,6 +173,8 @@ store.registerModule(
171173
}),
172174
)
173175

176+
store.registerModule("ux", uxModule)
177+
174178
// Vue setup.
175179
const app = createApp(App)
176180

@@ -234,4 +238,12 @@ try {
234238
}
235239
} catch {}
236240

241+
installHttpErrors({
242+
store,
243+
t: (key, params) => i18n.global.t(key, params),
244+
on401: (err) => console.warn("Unauthorized", err?.response?.data?.error || "Unauthorized"),
245+
on403: (msg) => console.info("Forbidden shown:", msg),
246+
on500: (err) => console.error("Server error", err?.response?.data?.detail || "Server error"),
247+
})
248+
237249
app.mount("#app")

assets/vue/plugins/httpErrors.js

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
export default function installHttpErrors({ store, on401, on403, on500, t } = {}) {
2+
const getDefault403 = () => t("You are not allowed to see this page. Either your connection has expired or you are trying to access a page for which you do not have the sufficient privileges.")
3+
4+
const resolve403Text = (msg) => (msg && typeof msg === "string" ? msg : getDefault403())
5+
6+
const setForbidden = (msg) => {
7+
const text = resolve403Text(msg)
8+
try {
9+
console.log("[httpErrors] 403 captured ->", text)
10+
store?.dispatch?.("ux/showForbidden", text)
11+
} catch (e) {
12+
console.warn("[httpErrors] cannot dispatch ux/showForbidden:", e)
13+
}
14+
try {
15+
on403?.(text)
16+
} catch {}
17+
}
18+
19+
const isHtmlResponse = (res) => {
20+
try {
21+
const ct = res?.headers?.get?.("content-type") || res?.headers?.["content-type"] || ""
22+
return typeof ct === "string" && ct.toLowerCase().includes("text/html")
23+
} catch {
24+
return false
25+
}
26+
}
27+
28+
const isErrorPageHeader = (res) => {
29+
try {
30+
const v = res?.headers?.get?.("x-error-page") || res?.headers?.["x-error-page"] || ""
31+
return String(v) === "1"
32+
} catch {
33+
return false
34+
}
35+
}
36+
37+
const replaceWholeDocument = (html) => {
38+
// Replace the entire DOM with the Twig/HTML page returned by the backend
39+
document.open()
40+
document.write(html)
41+
document.close()
42+
}
43+
44+
const extractMsgFromJson = (json) => json?.error || json?.message || json?.detail || null
45+
46+
// ---- 1) Axios (default + any instances created later) ----
47+
try {
48+
const axios = require("axios").default || require("axios")
49+
50+
const axiosReject = async (err) => {
51+
const res = err?.response
52+
const s = res?.status
53+
54+
if (s === 403) {
55+
const looksHtml = isHtmlResponse(res)
56+
const flagged = isErrorPageHeader(res)
57+
const body = res?.data
58+
59+
if (looksHtml && flagged && typeof body === "string") {
60+
// Backend returned the full error page (HTML/Twig): replace the document
61+
replaceWholeDocument(body)
62+
return Promise.reject(err)
63+
}
64+
65+
// Fallback to JSON -> show Vue banner
66+
setForbidden(extractMsgFromJson(body))
67+
return Promise.reject(err)
68+
}
69+
70+
if (s === 401) {
71+
try {
72+
on401?.(err)
73+
} catch {}
74+
} else if (s === 500) {
75+
try {
76+
on500?.(err)
77+
} catch {}
78+
}
79+
return Promise.reject(err)
80+
}
81+
82+
axios.interceptors.response.use((r) => r, axiosReject)
83+
84+
const originalCreate = axios.create.bind(axios)
85+
axios.create = function patchedCreate(...args) {
86+
const instance = originalCreate(...args)
87+
instance.interceptors.response.use((r) => r, axiosReject)
88+
return instance
89+
}
90+
91+
// Soft hint header in case the backend wants to send HTML for XHR errors
92+
try {
93+
axios.defaults.headers.common["X-Prefer-HTML-Errors"] = "1"
94+
} catch {}
95+
console.log("[httpErrors] axios patched")
96+
} catch (e) {
97+
console.warn("[httpErrors] axios not available (skipping)", e?.message)
98+
}
99+
100+
// ---- 2) fetch ----
101+
try {
102+
if (window.fetch && !window.fetch.__httpErrorsPatched) {
103+
const _fetch = window.fetch.bind(window)
104+
window.fetch = async (...args) => {
105+
const res = await _fetch(...args)
106+
if (res?.status === 403) {
107+
if (isHtmlResponse(res) && isErrorPageHeader(res)) {
108+
const html = await res
109+
.clone()
110+
.text()
111+
.catch(() => "")
112+
if (html) replaceWholeDocument(html)
113+
} else {
114+
const ct = res.headers.get("content-type") || ""
115+
if (ct.includes("json")) {
116+
const data = await res
117+
.clone()
118+
.json()
119+
.catch(() => ({}))
120+
setForbidden(extractMsgFromJson(data))
121+
} else {
122+
setForbidden(null) // translated default
123+
}
124+
}
125+
}
126+
if (res?.status === 401) {
127+
try {
128+
on401?.(res)
129+
} catch {}
130+
}
131+
if (res?.status === 500) {
132+
try {
133+
on500?.(res)
134+
} catch {}
135+
}
136+
return res
137+
}
138+
window.fetch.__httpErrorsPatched = true
139+
console.log("[httpErrors] fetch patched")
140+
}
141+
} catch (e) {
142+
console.warn("[httpErrors] fetch patch failed:", e?.message)
143+
}
144+
145+
// ---- 3) XMLHttpRequest (covers manual XHR usage) ----
146+
try {
147+
if (window.XMLHttpRequest && !window.XMLHttpRequest.__httpErrorsPatched) {
148+
const _open = XMLHttpRequest.prototype.open
149+
const _send = XMLHttpRequest.prototype.send
150+
151+
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
152+
this.__httpErrorsUrl = url
153+
this.__httpErrorsMethod = method
154+
return _open.call(this, method, url, ...rest)
155+
}
156+
157+
XMLHttpRequest.prototype.send = function (...args) {
158+
this.addEventListener("readystatechange", function () {
159+
if (this.readyState === 4) {
160+
const s = this.status
161+
if (s === 403) {
162+
const ct = (this.getResponseHeader("content-type") || "").toLowerCase()
163+
const flagged = this.getResponseHeader("x-error-page") === "1"
164+
165+
if (ct.includes("text/html") && flagged) {
166+
replaceWholeDocument(this.responseText || "")
167+
return
168+
}
169+
170+
let msg = null
171+
if (ct.includes("json")) {
172+
try {
173+
const parsed = JSON.parse(this.responseText || "{}")
174+
msg = extractMsgFromJson(parsed) // may be null -> we’ll translate later
175+
} catch {}
176+
}
177+
setForbidden(msg) // translated default if null
178+
} else if (s === 401) {
179+
try {
180+
on401?.(this)
181+
} catch {}
182+
} else if (s === 500) {
183+
try {
184+
on500?.(this)
185+
} catch {}
186+
}
187+
}
188+
})
189+
return _send.apply(this, args)
190+
}
191+
192+
window.XMLHttpRequest.__httpErrorsPatched = true
193+
console.log("[httpErrors] xhr patched")
194+
}
195+
} catch (e) {
196+
console.warn("[httpErrors] xhr patch failed:", e?.message)
197+
}
198+
}

assets/vue/store/modules/ux.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
export default {
2+
namespaced: true,
3+
state: () => ({
4+
forbiddenMessage: "",
5+
}),
6+
mutations: {
7+
SET_FORBIDDEN(state, msg) {
8+
state.forbiddenMessage = msg || ""
9+
},
10+
CLEAR_FORBIDDEN(state) {
11+
state.forbiddenMessage = ""
12+
},
13+
},
14+
actions: {
15+
showForbidden({ commit }, msg) {
16+
commit("SET_FORBIDDEN", msg)
17+
},
18+
clearForbidden({ commit }) {
19+
commit("CLEAR_FORBIDDEN")
20+
},
21+
},
22+
getters: {
23+
forbiddenMessage: (s) => s.forbiddenMessage,
24+
},
25+
}

0 commit comments

Comments
 (0)