Kundenansicht im Dashboard: Kennung bei jeder Anfrage, Banner mit Beenden, Hinweis bei ersetzter oder abgelaufener Ansicht

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-01 19:36:55 +00:00
1 parent c5afd42a5f
commit c1fda8a717
6 files changed
+443 -5

No files matched your search

+26 -5
View File
@@ -634,6 +634,19 @@ function renderViewAsBanner(me) {
const banner = document.getElementById('viewas-banner')
if (!banner || !me.viewAs) return
banner.classList.remove('hidden')
// Kundenansicht aus dem Ticketsystem (mit Kennung): Beenden fuehrt zurueck auf die Kundenseite im Ticketsystem
if (me.viewId) {
banner.innerHTML = `
<span>Kundenansicht: <strong>${escapeHtml(me.customer?.name || 'Kunde')}</strong> · geöffnet von ${escapeHtml(me.admin?.name || 'Mitarbeiter')}</span>
<button type="button" id="exit-viewas-btn">Beenden</button>
`
document.getElementById('exit-viewas-btn')?.addEventListener('click', async () => {
const { redirect } = await api('/api/auth/kundenansicht/ende', { method: 'POST' }).catch(() => ({}))
window.location.href = redirect || 'https://ticket.webklar.com/customers'
})
return
}
// Alte Ansicht "Als Kunde ansehen" aus dem Admin-Panel (bis das Panel abgeschaltet ist)
banner.innerHTML = `
<span>Admin-Ansicht: Du siehst das Portal als <strong>${escapeHtml(me.customer?.name || 'Kunde')}</strong>.</span>
<button type="button" id="exit-viewas-btn">Zurück zur Admin-Übersicht</button>
@@ -765,6 +778,12 @@ function updateChatBadge(unread) {
}
}
// Nachrichten als gelesen markieren; in der Kundenansicht nicht (der Kunde hat sie ja nicht gelesen)
function markChatRead() {
if (chatState.viewAs) return Promise.resolve()
return api('/api/chat/read', { method: 'POST' }).catch(() => {})
}
async function pollChatMessages() {
if (document.hidden) return
try {
@@ -772,7 +791,7 @@ async function pollChatMessages() {
const data = await api(`/api/chat/messages${q}`)
appendChatMessages(data.messages || [])
if (isChatTabActive() && (data.unread || 0) > 0) {
await api('/api/chat/read', { method: 'POST' }).catch(() => {})
await markChatRead()
updateChatBadge(0)
}
} catch {
@@ -809,7 +828,7 @@ async function initChat() {
})
await pollChatMessages()
await api('/api/chat/read', { method: 'POST' }).catch(() => {})
await markChatRead()
updateChatBadge(0)
chatState.pollTimer = setInterval(() => {
if (isChatTabActive()) pollChatMessages()
@@ -2004,7 +2023,7 @@ function showView(name, { updateHash = true } = {}) {
if (box) box.innerHTML = `<p class="muted">${escapeHtml(err.message || 'Konnte nicht geladen werden.')}</p>`
})
if (name === 'chat') {
api('/api/chat/read', { method: 'POST' }).catch(() => {})
markChatRead()
updateChatBadge(0)
}
if (updateHash) {
@@ -2070,8 +2089,8 @@ async function initDashboardPage() {
if (!me.authenticated) {
const target = new URL(loginUrl, window.location.origin)
// Passwort auf einem anderen Gerät geändert: Hinweis auf der Anmeldeseite
if (me.reason === 'password_changed' && me.message) target.searchParams.set('error', me.message)
// Passwort auf einem anderen Gerät geändert oder Kundenansicht abgelaufen: Hinweis auf der Anmeldeseite
if ((me.reason === 'password_changed' || me.reason === 'view_expired') && me.message) target.searchParams.set('error', me.message)
window.location.href = target.pathname + target.search
return
}
@@ -2085,6 +2104,8 @@ async function initDashboardPage() {
}
const customer = me.customer
// Kundenansicht: ab jetzt traegt jede /api-Anfrage die Kennung dieser Ansicht (public/kundenansicht.js)
if (me.viewId) window.KbView?.setId(me.viewId)
chatState.viewAs = Boolean(me.viewAs)
takeoverState.customer = customer
renderViewAsBanner(me)
+2
View File
@@ -285,6 +285,8 @@
</main>
</div>
<!-- Kundenansicht: Kennung an jeder /api-Anfrage, Hinweis bei ersetzter/abgelaufener Ansicht (vor allen anderen Skripten) -->
<script src="/kundenansicht.js"></script>
<script src="/chat-composer.js"></script>
<script src="/app.js"></script>
<script>
+109
View File
@@ -0,0 +1,109 @@
/* Kundenansicht (Mitarbeiter oeffnet das Portal eines Kunden aus dem Ticketsystem).
Ein Browser haelt nur eine Portal-Sitzung: oeffnet jemand Kunde B, waehrend Kunde A noch in einem Tab offen ist,
darf der Tab von A keine Daten von B laden. Deshalb traegt jede eigene /api-Anfrage die Kennung der Ansicht
(X-Kundenansicht); der Server antwortet bei fremder Kennung mit 409 view_replaced, nach 2 Stunden mit
401 view_expired. Dann zeigt dieses Skript nur noch einen Hinweis. Klassisches Skript, vor app.js laden. */
(function () {
'use strict'
const HEADER = 'X-Kundenansicht'
let viewId = ''
function isOwnApi(url) {
try {
const u = new URL(String(url), window.location.href)
return u.origin === window.location.origin && u.pathname.startsWith('/api/')
} catch {
return false
}
}
function overlay(text) {
const existing = document.getElementById('kb-view-overlay-text')
if (existing) {
existing.textContent = text
return
}
const box = document.createElement('div')
box.id = 'kb-view-overlay'
box.className = 'kb-view-overlay'
box.setAttribute('role', 'alertdialog')
box.setAttribute('aria-modal', 'true')
const card = document.createElement('div')
card.className = 'kb-view-overlay-card'
const p = document.createElement('p')
p.id = 'kb-view-overlay-text'
p.textContent = text
const button = document.createElement('button')
button.type = 'button'
button.textContent = 'Tab schließen'
button.addEventListener('click', () => window.close())
card.appendChild(p)
card.appendChild(button)
box.appendChild(card)
document.body.appendChild(box)
}
function showReplaced(kunde) {
overlay(`Diese Ansicht wurde durch die Kundenansicht von ${kunde || 'einem anderen Kunden'} ersetzt.`)
}
function showExpired() {
overlay('Die Kundenansicht ist abgelaufen. Bitte im Ticketsystem erneut öffnen.')
}
function inspect(status, body) {
if (status === 409 && body && body.code === 'view_replaced') showReplaced(body.kunde)
else if (status === 401 && body && body.code === 'view_expired') showExpired()
}
const originalFetch = window.fetch.bind(window)
window.fetch = async function (input, init) {
const url = typeof input === 'string' ? input : (input && input.url) || String(input)
const own = isOwnApi(url)
let options = init || {}
if (viewId && own) {
const headers = new Headers(options.headers || (typeof input !== 'string' && input && input.headers) || {})
headers.set(HEADER, viewId)
options = { ...options, headers }
}
const response = await originalFetch(input, options)
if (own && (response.status === 409 || response.status === 401)) {
response.clone().json().then((body) => inspect(response.status, body)).catch(() => {})
}
return response
}
if (window.XMLHttpRequest) {
const proto = window.XMLHttpRequest.prototype
const open = proto.open
const send = proto.send
proto.open = function (method, url, ...rest) {
this.kbOwnApi = isOwnApi(url)
return open.call(this, method, url, ...rest)
}
proto.send = function (...args) {
if (this.kbOwnApi) {
if (viewId) this.setRequestHeader(HEADER, viewId)
this.addEventListener('load', () => {
if (this.status !== 409 && this.status !== 401) return
try {
inspect(this.status, JSON.parse(this.responseText))
} catch {
/* keine JSON-Antwort */
}
})
}
return send.apply(this, args)
}
}
window.KbView = {
setId(id) {
viewId = String(id || '')
},
id: () => viewId,
showReplaced,
showExpired,
}
})()
+12
View File
@@ -174,6 +174,18 @@
}
.viewas-banner button { background: transparent; color: #fff; border: 1px solid var(--kb-border-strong); border-radius: 999px; padding: 7px 14px; font: inherit; font-size: 13px; cursor: pointer; }
/* Kundenansicht ersetzt oder abgelaufen (public/kundenansicht.js): deckt die ganze Seite ab, damit nichts Veraltetes bedienbar bleibt */
.kb-view-overlay {
position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; padding: 16px;
background: rgba(0, 0, 0, 0.78); backdrop-filter: blur(4px);
}
.kb-view-overlay-card {
max-width: 420px; padding: 24px; border-radius: var(--kb-radius, 14px); text-align: center;
background: #121216; border: 1px solid rgba(251, 191, 36, 0.45); color: #fff;
}
.kb-view-overlay-card p { margin: 0 0 18px; line-height: 1.5; }
.kb-view-overlay-card button { background: var(--kb-purple, #9d3dfe); color: #fff; border: none; border-radius: 999px; padding: 9px 18px; font: inherit; cursor: pointer; }
/* Buttons */
.kb-btn-primary {
display: inline-block; padding: 12px 22px; border-radius: 999px; border: none; background: var(--kb-purple);