From 2f7184a8fd4421c0c900be69d236732b32c2c560 Mon Sep 17 00:00:00 2001 From: Kerim Atilgan Date: Sun, 25 Oct 2026 11:03:37 +0100 Subject: [PATCH] =?UTF-8?q?Kopfleiste:=20Steckplatz=20"benutzer"=20im=20Be?= =?UTF-8?q?nutzer-Men=C3=BC=20und=20Ereignis=20hi-abmelden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- README.md | 15 ++++++++++ topbar/src/topbar.ts | 68 ++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 0903030..10fb8d2 100644 --- a/README.md +++ b/README.md @@ -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): ``` diff --git a/topbar/src/topbar.ts b/topbar/src/topbar.ts index 46ba7cf..452ca96 100644 --- a/topbar/src/topbar.ts +++ b/topbar/src/topbar.ts @@ -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 stehen. + * mit slot="aktionen" in 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 = ''; +const PERSON = + ''; function esc(s: string): string { return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 { + const pending: Promise[] = []; + const event = new CustomEvent('hi-abmelden', { + bubbles: true, + composed: true, + cancelable: true, + detail: { warten: (p: Promise) => 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 { : `

Deine Anwendungen erscheinen hier, sobald du im Portal angemeldet bist.
Jetzt anmelden

`; const user = nav?.user; + this.renderedSlottedUserItems = this.hasSlottedUserItems(); + const hasUserMenu = !!user || this.renderedSlottedUserItems; this.root.innerHTML = `
${appsPanel}
${ - user + hasUserMenu ? `
-
${esc(user.name)}
${esc(user.email)}
+ ${user ? `
${esc(user.name)}
${esc(user.email)}
` : ''} + Portal ${links.account ? `Konto & Passwort` : ''} - Abmelden + Abmelden
` : '' }`; @@ -237,6 +289,10 @@ class HiTopbar extends HTMLElement { this.root.querySelectorAll('[data-toggle]').forEach((btn) => btn.addEventListener('click', () => this.toggle(btn.dataset.toggle as 'apps' | 'user')), ); + this.root.querySelector('[data-logout]')?.addEventListener('click', (e) => { + e.preventDefault(); + void this.logout(user ? links.logout : null); + }); } }