Kopfleiste: Steckplatz "benutzer" im Benutzer-Menü und Ereignis hi-abmelden

Tools können eigene Einträge ins Benutzer-Menü stellen (auch ohne
Portal-Sitzung) und beim Abmelden ihre eigene Sitzung mit beenden.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kerim AtilganandClaude Opus 5.5 committed 2026-10-25 11:03:37 +01:00
1 parent a3c974c219
commit 2f7184a8fd
2 files changed
+77 -6

No files matched your search

+15
View File
@@ -61,6 +61,21 @@ Eigene Icons können in Authentik hochgeladen werden, sonst nimmt das Portal sei
document.querySelector('hi-topbar')?.append(btn);
```
- **Eigene Einträge im Benutzer-Menü** (z.B. „Meine Einstellungen“, Sitzungsanzeige) mit `slot="benutzer"`. Sie stehen zwischen Name/E-Mail und den Links. Stellt ein Tool dort etwas ein, erscheint das Benutzer-Menü auch ohne Portal-Sitzung (dann mit Personen-Symbol statt Initialen), etwa nach einer Anmeldung im Tool ohne SSO. Klickt man auf einen Link darin, schließt sich das Menü.
### Abmelden aus dem Tool heraus
Vor dem Abmelden feuert die Leiste das Ereignis `hi-abmelden` (bubbles, composed, cancelable). Das Tool beendet darin seine eigene Sitzung, so gibt es genau ein „Abmelden“:
```js
document.addEventListener('hi-abmelden', (e) => {
// Leiste wartet auf das Promise, höchstens 3 Sekunden
e.detail.warten(fetch('/api/logout', { method: 'POST', credentials: 'include' }));
});
```
Danach geht es zum Abmelden bei Portal und Authentik. Ohne Portal-Sitzung lädt die Leiste stattdessen die Seite neu, dann zeigt das Tool seine eigene Anmeldung. Mit `e.preventDefault()` übernimmt das Tool die Weiterleitung komplett selbst.
**Content-Security-Policy des Tools erweitern** (sonst blockiert der Browser die Leiste):
```
+62 -6
View File
@@ -10,7 +10,13 @@
* Layouts mit height: calc(100vh - var(--hi-topbar-height, 0px)).
*
* Eigene Knöpfe des Tools erscheinen rechts vor dem Avatar, wenn sie als Kind
* mit slot="aktionen" in <hi-topbar> stehen.
* mit slot="aktionen" in <hi-topbar> stehen. Kinder mit slot="benutzer" landen
* im Benutzer-Menü zwischen Name und Links.
*
* Vor dem Abmelden feuert die Leiste "hi-abmelden" (bubbles, composed). Das Tool
* beendet darin seine eigene Sitzung; mit e.detail.warten(promise) wartet die
* Leiste darauf (höchstens 3 s), mit e.preventDefault() übernimmt das Tool die
* Weiterleitung selbst.
*
* Voraussetzung in der CSP des Tools: script-src, connect-src und img-src
* müssen https://portal.handy-insel.de erlauben.
@@ -34,6 +40,7 @@ interface Navigation {
const HEIGHT = 44;
const CACHE_KEY = 'hi-topbar-nav';
const CACHE_MS = 5 * 60 * 1000;
const LOGOUT_WAIT_MS = 3000;
const script = document.currentScript as HTMLScriptElement | null;
const PORTAL = (script?.dataset.portal || (script?.src ? new URL(script.src).origin : 'https://portal.handy-insel.de')).replace(/\/$/, '');
@@ -83,6 +90,8 @@ a { color: inherit; text-decoration: none; }
.user .who { padding: 12px 16px; border-bottom: 1px solid var(--p-border); }
.user .who div:first-child { font-weight: 600; }
.user .who div:last-child { font-size: 13px; color: var(--p-text-3); overflow: hidden; text-overflow: ellipsis; }
slot[name="benutzer"] { display: block; border-bottom: 1px solid var(--p-border); }
.avatar .person { width: 20px; height: 20px; }
.user a { display: block; padding: 10px 16px; font-size: 14px; }
.user a:hover { background: var(--p-hover); }
@media (max-width: 480px) { .brand span { display: none; } }
@@ -90,6 +99,8 @@ a { color: inherit; text-decoration: none; }
const WAFFLE =
'<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4 4h4v4H4zm6 0h4v4h-4zm6 0h4v4h-4zM4 10h4v4H4zm6 0h4v4h-4zm6 0h4v4h-4zM4 16h4v4H4zm6 0h4v4h-4zm6 0h4v4h-4z"/></svg>';
const PERSON =
'<svg class="person" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 12a4.5 4.5 0 1 0 0-9 4.5 4.5 0 0 0 0 9zm0 2c-4.4 0-8 2.2-8 5v2h16v-2c0-2.8-3.6-5-8-5z"/></svg>';
function esc(s: string): string {
return s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]!);
@@ -121,6 +132,14 @@ class HiTopbar extends HTMLElement {
private open: 'apps' | 'user' | null = null;
private themeObserver?: MutationObserver;
private childObserver = new MutationObserver(() => {
if (this.hasSlottedUserItems() !== this.renderedSlottedUserItems) this.render();
});
private renderedSlottedUserItems = false;
private hasSlottedUserItems(): boolean {
return !!this.querySelector(':scope > [slot="benutzer"]');
}
private darkQuery = window.matchMedia('(prefers-color-scheme: dark)');
/** Folgt dem Farbschema des Tools: data-theme="dark" (VVL, Portal), Klasse "dark" (Tailwind) oder System. */
@@ -138,6 +157,9 @@ class HiTopbar extends HTMLElement {
this.themeObserver = new MutationObserver(this.syncTheme);
this.themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class'] });
this.darkQuery.addEventListener('change', this.syncTheme);
// Das Benutzer-Menü gibt es auch ohne Portal-Sitzung, sobald das Tool etwas in slot="benutzer" stellt.
this.childObserver.observe(this, { childList: true });
this.addEventListener('click', this.onSlotClick);
this.nav = readCache();
this.render();
void this.load();
@@ -147,6 +169,8 @@ class HiTopbar extends HTMLElement {
disconnectedCallback(): void {
this.themeObserver?.disconnect();
this.childObserver.disconnect();
this.removeEventListener('click', this.onSlotClick);
this.darkQuery.removeEventListener('change', this.syncTheme);
document.removeEventListener('mousedown', this.onOutside);
document.removeEventListener('keydown', this.onKey);
@@ -177,11 +201,36 @@ class HiTopbar extends HTMLElement {
if (e.key === 'Escape' && this.open) this.toggle(null);
};
/** Ein Link im Benutzer-Steckplatz schließt das Menü, auch wenn das Tool clientseitig navigiert. */
private onSlotClick = (e: MouseEvent) => {
const link = (e.target as Element | null)?.closest?.('a');
if (this.open === 'user' && link?.closest('[slot="benutzer"]')) this.toggle(null);
};
private toggle(which: 'apps' | 'user' | null): void {
this.open = this.open === which ? null : which;
this.render();
}
/** Erst das Tool abmelden lassen (Ereignis "hi-abmelden"), dann Portal und Authentik. */
private async logout(href: string | null): Promise<void> {
const pending: Promise<unknown>[] = [];
const event = new CustomEvent('hi-abmelden', {
bubbles: true,
composed: true,
cancelable: true,
detail: { warten: (p: Promise<unknown>) => void pending.push(p) },
});
this.dispatchEvent(event);
if (pending.length) {
await Promise.race([Promise.allSettled(pending), new Promise((r) => setTimeout(r, LOGOUT_WAIT_MS))]);
}
if (event.defaultPrevented) return;
// Ohne Portal-Sitzung gibt es dort nichts abzumelden; neu laden zeigt die Anmeldung des Tools.
if (href) location.href = href;
else location.reload();
}
private render(): void {
const nav = this.nav;
const links = nav?.links ?? {
@@ -209,6 +258,8 @@ class HiTopbar extends HTMLElement {
: `<p class="hint">Deine Anwendungen erscheinen hier, sobald du im Portal angemeldet bist.<br><a href="${esc(loginUrl)}">Jetzt anmelden</a></p>`;
const user = nav?.user;
this.renderedSlottedUserItems = this.hasSlottedUserItems();
const hasUserMenu = !!user || this.renderedSlottedUserItems;
this.root.innerHTML = `<style>${css}</style>
<nav class="bar" aria-label="Handy-Insel Navigation">
<button class="icon-btn" data-toggle="apps" aria-haspopup="true" aria-expanded="${this.open === 'apps'}" title="Anwendungen">${WAFFLE}</button>
@@ -217,19 +268,20 @@ class HiTopbar extends HTMLElement {
<span class="spacer"></span>
<slot name="aktionen"></slot>
${
user
? `<button class="icon-btn" data-toggle="user" aria-haspopup="true" aria-expanded="${this.open === 'user'}" title="${esc(user.name)}"><span class="avatar">${esc(user.initials)}</span></button>`
hasUserMenu
? `<button class="icon-btn" data-toggle="user" aria-haspopup="true" aria-expanded="${this.open === 'user'}" title="${esc(user?.name ?? 'Benutzer')}"><span class="avatar">${user ? esc(user.initials) : PERSON}</span></button>`
: ''
}
</nav>
<div class="panel apps" lang="de" ${this.open === 'apps' ? '' : 'hidden'}>${appsPanel}</div>
${
user
hasUserMenu
? `<div class="panel user" ${this.open === 'user' ? '' : 'hidden'}>
<div class="who"><div>${esc(user.name)}</div><div>${esc(user.email)}</div></div>
${user ? `<div class="who"><div>${esc(user.name)}</div><div>${esc(user.email)}</div></div>` : ''}
<slot name="benutzer"></slot>
<a href="${esc(links.portal)}">Portal</a>
${links.account ? `<a href="${esc(links.account)}">Konto &amp; Passwort</a>` : ''}
<a href="${esc(links.logout)}">Abmelden</a>
<a href="${esc(links.logout)}" data-logout>Abmelden</a>
</div>`
: ''
}`;
@@ -237,6 +289,10 @@ class HiTopbar extends HTMLElement {
this.root.querySelectorAll<HTMLButtonElement>('[data-toggle]').forEach((btn) =>
btn.addEventListener('click', () => this.toggle(btn.dataset.toggle as 'apps' | 'user')),
);
this.root.querySelector<HTMLAnchorElement>('[data-logout]')?.addEventListener('click', (e) => {
e.preventDefault();
void this.logout(user ? links.logout : null);
});
}
}