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
@@ -61,6 +61,21 @@ Eigene Icons können in Authentik hochgeladen werden, sonst nimmt das Portal sei
|
|||||||
document.querySelector('hi-topbar')?.append(btn);
|
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):
|
**Content-Security-Policy des Tools erweitern** (sonst blockiert der Browser die Leiste):
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
+62
-6
@@ -10,7 +10,13 @@
|
|||||||
* Layouts mit height: calc(100vh - var(--hi-topbar-height, 0px)).
|
* Layouts mit height: calc(100vh - var(--hi-topbar-height, 0px)).
|
||||||
*
|
*
|
||||||
* Eigene Knöpfe des Tools erscheinen rechts vor dem Avatar, wenn sie als Kind
|
* 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
|
* Voraussetzung in der CSP des Tools: script-src, connect-src und img-src
|
||||||
* müssen https://portal.handy-insel.de erlauben.
|
* müssen https://portal.handy-insel.de erlauben.
|
||||||
@@ -34,6 +40,7 @@ interface Navigation {
|
|||||||
const HEIGHT = 44;
|
const HEIGHT = 44;
|
||||||
const CACHE_KEY = 'hi-topbar-nav';
|
const CACHE_KEY = 'hi-topbar-nav';
|
||||||
const CACHE_MS = 5 * 60 * 1000;
|
const CACHE_MS = 5 * 60 * 1000;
|
||||||
|
const LOGOUT_WAIT_MS = 3000;
|
||||||
|
|
||||||
const script = document.currentScript as HTMLScriptElement | null;
|
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(/\/$/, '');
|
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 { padding: 12px 16px; border-bottom: 1px solid var(--p-border); }
|
||||||
.user .who div:first-child { font-weight: 600; }
|
.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; }
|
.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 { display: block; padding: 10px 16px; font-size: 14px; }
|
||||||
.user a:hover { background: var(--p-hover); }
|
.user a:hover { background: var(--p-hover); }
|
||||||
@media (max-width: 480px) { .brand span { display: none; } }
|
@media (max-width: 480px) { .brand span { display: none; } }
|
||||||
@@ -90,6 +99,8 @@ a { color: inherit; text-decoration: none; }
|
|||||||
|
|
||||||
const WAFFLE =
|
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>';
|
'<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 {
|
function esc(s: string): string {
|
||||||
return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!);
|
return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!);
|
||||||
@@ -121,6 +132,14 @@ class HiTopbar extends HTMLElement {
|
|||||||
private open: 'apps' | 'user' | null = null;
|
private open: 'apps' | 'user' | null = null;
|
||||||
|
|
||||||
private themeObserver?: MutationObserver;
|
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)');
|
private darkQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
|
||||||
/** Folgt dem Farbschema des Tools: data-theme="dark" (VVL, Portal), Klasse "dark" (Tailwind) oder System. */
|
/** 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 = new MutationObserver(this.syncTheme);
|
||||||
this.themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class'] });
|
this.themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class'] });
|
||||||
this.darkQuery.addEventListener('change', this.syncTheme);
|
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.nav = readCache();
|
||||||
this.render();
|
this.render();
|
||||||
void this.load();
|
void this.load();
|
||||||
@@ -147,6 +169,8 @@ class HiTopbar extends HTMLElement {
|
|||||||
|
|
||||||
disconnectedCallback(): void {
|
disconnectedCallback(): void {
|
||||||
this.themeObserver?.disconnect();
|
this.themeObserver?.disconnect();
|
||||||
|
this.childObserver.disconnect();
|
||||||
|
this.removeEventListener('click', this.onSlotClick);
|
||||||
this.darkQuery.removeEventListener('change', this.syncTheme);
|
this.darkQuery.removeEventListener('change', this.syncTheme);
|
||||||
document.removeEventListener('mousedown', this.onOutside);
|
document.removeEventListener('mousedown', this.onOutside);
|
||||||
document.removeEventListener('keydown', this.onKey);
|
document.removeEventListener('keydown', this.onKey);
|
||||||
@@ -177,11 +201,36 @@ class HiTopbar extends HTMLElement {
|
|||||||
if (e.key === 'Escape' && this.open) this.toggle(null);
|
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 {
|
private toggle(which: 'apps' | 'user' | null): void {
|
||||||
this.open = this.open === which ? null : which;
|
this.open = this.open === which ? null : which;
|
||||||
this.render();
|
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 {
|
private render(): void {
|
||||||
const nav = this.nav;
|
const nav = this.nav;
|
||||||
const links = nav?.links ?? {
|
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>`;
|
: `<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;
|
const user = nav?.user;
|
||||||
|
this.renderedSlottedUserItems = this.hasSlottedUserItems();
|
||||||
|
const hasUserMenu = !!user || this.renderedSlottedUserItems;
|
||||||
this.root.innerHTML = `<style>${css}</style>
|
this.root.innerHTML = `<style>${css}</style>
|
||||||
<nav class="bar" aria-label="Handy-Insel Navigation">
|
<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>
|
<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>
|
<span class="spacer"></span>
|
||||||
<slot name="aktionen"></slot>
|
<slot name="aktionen"></slot>
|
||||||
${
|
${
|
||||||
user
|
hasUserMenu
|
||||||
? `<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>`
|
? `<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>
|
</nav>
|
||||||
<div class="panel apps" lang="de" ${this.open === 'apps' ? '' : 'hidden'}>${appsPanel}</div>
|
<div class="panel apps" lang="de" ${this.open === 'apps' ? '' : 'hidden'}>${appsPanel}</div>
|
||||||
${
|
${
|
||||||
user
|
hasUserMenu
|
||||||
? `<div class="panel user" ${this.open === 'user' ? '' : 'hidden'}>
|
? `<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>
|
<a href="${esc(links.portal)}">Portal</a>
|
||||||
${links.account ? `<a href="${esc(links.account)}">Konto & Passwort</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>`
|
</div>`
|
||||||
: ''
|
: ''
|
||||||
}`;
|
}`;
|
||||||
@@ -237,6 +289,10 @@ class HiTopbar extends HTMLElement {
|
|||||||
this.root.querySelectorAll<HTMLButtonElement>('[data-toggle]').forEach((btn) =>
|
this.root.querySelectorAll<HTMLButtonElement>('[data-toggle]').forEach((btn) =>
|
||||||
btn.addEventListener('click', () => this.toggle(btn.dataset.toggle as 'apps' | 'user')),
|
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