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:
1 parent
a3c974c219
commit
2f7184a8fd
2 files changed
+77
-6
No files matched your search
+62
-6
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 & 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user