Files
portal/topbar/src/topbar.ts
T
Kerim AtilganandClaude Opus 5.5 edc64f159a Abmelden beendet auch die Authentik-Sitzung und springt zurück ins Tool
- Kopfleiste meldet immer über das Portal ab, auch ohne Portal-Sitzung,
  und gibt die aktuelle Seite als returnTo mit.
- Portal merkt sich den Rücksprung über den Umweg zu Authentik.
- Setup-Skript setzt den Abmelde-Ablauf des Providers 'Portal' auf
  default-invalidation-flow (der Provider-Ablauf meldet nur bei der App ab).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 09:55:42 +01:00

310 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Handy-Insel Kopfleiste (<hi-topbar>) – zentrale Navigation für alle Tools.
*
* Einbindung in ein Tool (eine Zeile, am besten im <head>):
* <script src="https://portal.handy-insel.de/topbar.js" data-app="vvl" defer></script>
*
* Das Skript fügt die Leiste automatisch oben in <body> ein. Wer sie selbst
* platzieren will, setzt <hi-topbar app="vvl"></hi-topbar> ins Markup.
* Die Höhe steht als CSS-Variable --hi-topbar-height bereit, z.B. für
* 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. 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.
*/
interface NavApp {
slug: string;
name: string;
url: string;
iconUrl: string;
newTab: boolean;
section: string;
}
interface Navigation {
user: { name: string; email: string; initials: string } | null;
apps: NavApp[];
links: { portal: string; account: string; logout: string; login: string };
}
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(/\/$/, '');
const css = `
:host { all: initial; display: block; position: relative; z-index: 2147483000; height: ${HEIGHT}px;
font-family: "IBM Plex Sans", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif; font-size: 14px; color: #fff;
--p-bg: #ffffff; --p-text: #211a38; --p-text-3: #8a83a0; --p-border: #e5e0f0; --p-hover: #ede7fa; --p-current: #e3d9f8;
--p-accent: #5e35b1; --p-tile: transparent; --p-shadow: 0 8px 40px rgba(45,27,105,.18); }
:host([theme="dark"]) { --p-bg: #1c1436; --p-text: #ece8f6; --p-text-3: #7b7396; --p-border: #312551; --p-hover: #2a2048;
--p-current: #2f2452; --p-accent: #a88beb; --p-tile: #f4f0fc; --p-shadow: 0 12px 48px rgba(0,0,0,.55); }
:host([sticky]) { position: sticky; top: 0; }
* { box-sizing: border-box; }
.bar { display: flex; align-items: center; gap: 6px; height: ${HEIGHT}px; padding: 0 8px 0 4px; background: #2d1b69;
box-shadow: 0 1px 0 rgba(0,0,0,.12); }
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }
a { color: inherit; text-decoration: none; }
.icon-btn { display: grid; place-items: center; width: 36px; height: 36px; border-radius: 8px; }
.icon-btn:hover, .icon-btn[aria-expanded="true"] { background: rgba(255,255,255,.14); }
.icon-btn:focus-visible, a:focus-visible { outline: 2px solid #cbbdf7; outline-offset: 1px; }
.brand { display: flex; align-items: center; gap: 8px; padding: 4px 8px 4px 2px; border-radius: 8px; white-space: nowrap; }
.brand:hover { background: rgba(255,255,255,.08); }
.brand img { width: 24px; height: 24px; border-radius: 50%; background: #fff; }
.brand b { font-weight: 700; }
.app-name { opacity: .85; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
.sep { width: 1px; height: 18px; background: rgba(255,255,255,.3); margin: 0 4px; }
.spacer { flex: 1; }
::slotted(*) { flex: none; margin: 0 2px; }
.avatar { width: 30px; height: 30px; border-radius: 50%; background: #fff; color: #2d1b69; font-size: 12px; font-weight: 700;
display: grid; place-items: center; }
.panel { position: absolute; top: ${HEIGHT + 6}px; background: var(--p-bg); color: var(--p-text); border-radius: 12px;
border: 1px solid var(--p-border); box-shadow: var(--p-shadow); overflow: hidden; }
.panel[hidden] { display: none; }
.apps { left: 6px; width: 344px; max-width: calc(100vw - 12px); max-height: calc(100vh - 60px); overflow-y: auto; padding: 12px; }
.apps h3 { margin: 4px 6px 8px; font-size: 12px; font-weight: 600; color: var(--p-text-3); text-transform: uppercase; letter-spacing: .04em; }
.grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; margin-bottom: 8px; }
.tile { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 10px 4px; border-radius: 10px;
text-align: center; font-size: 12.5px; font-weight: 500; line-height: 1.2; overflow-wrap: anywhere; hyphens: auto; }
.tile:hover { background: var(--p-hover); }
.tile[aria-current="page"] { background: var(--p-current); }
.tile img { width: 36px; height: 36px; padding: 2px; border-radius: 8px; background: var(--p-tile); }
.footer { display: block; margin: 4px -12px -12px; padding: 12px 18px; border-top: 1px solid var(--p-border); font-weight: 600; color: var(--p-accent); }
.footer:hover { background: var(--p-hover); }
.hint { margin: 6px; font-size: 13px; line-height: 1.45; color: var(--p-text); }
.hint a { color: var(--p-accent); font-weight: 600; }
.user { right: 6px; width: 260px; }
.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; } }
`;
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]!);
}
function readCache(): Navigation | null {
try {
const raw = sessionStorage.getItem(CACHE_KEY);
if (!raw) return null;
const { at, nav } = JSON.parse(raw) as { at: number; nav: Navigation };
return Date.now() - at < CACHE_MS ? nav : null;
} catch {
return null;
}
}
function writeCache(nav: Navigation | null): void {
try {
if (nav?.user) sessionStorage.setItem(CACHE_KEY, JSON.stringify({ at: Date.now(), nav }));
else sessionStorage.removeItem(CACHE_KEY);
} catch {
/* Speicher gesperrt – dann eben ohne Cache */
}
}
class HiTopbar extends HTMLElement {
private root = this.attachShadow({ mode: 'open' });
private nav: Navigation | null = null;
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. */
private syncTheme = () => {
const html = document.documentElement;
const attr = html.getAttribute('data-theme');
const dark = attr ? attr === 'dark' : html.classList.contains('dark') || this.darkQuery.matches;
if (dark) this.setAttribute('theme', 'dark');
else this.removeAttribute('theme');
};
connectedCallback(): void {
document.documentElement.style.setProperty('--hi-topbar-height', `${HEIGHT}px`);
this.syncTheme();
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();
document.addEventListener('mousedown', this.onOutside);
document.addEventListener('keydown', this.onKey);
}
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);
}
private get appSlug(): string {
return this.getAttribute('app') || script?.dataset.app || '';
}
private async load(): Promise<void> {
try {
const res = await fetch(`${PORTAL}/api/navigation`, { credentials: 'include' });
const nav = (await res.json()) as Navigation;
this.nav = nav;
writeCache(nav);
} catch {
// Portal nicht erreichbar: Leiste bleibt mit Link zum Portal nutzbar.
if (!this.nav) this.nav = null;
}
this.render();
}
private onOutside = (e: MouseEvent) => {
if (this.open && !e.composedPath().includes(this)) this.toggle(null);
};
private onKey = (e: KeyboardEvent) => {
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): 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;
// Immer über das Portal, auch ohne Portal-Sitzung: nur so endet auch die Authentik-Sitzung.
// Danach geht es zurück auf diese Seite, die dann die Anmeldung des Tools zeigt.
location.href = `${href}?returnTo=${encodeURIComponent(location.href)}`;
}
private render(): void {
const nav = this.nav;
const links = nav?.links ?? {
portal: `${PORTAL}/`,
login: `${PORTAL}/auth/login`,
logout: `${PORTAL}/auth/logout`,
account: '',
};
const current = nav?.apps.find((a) => a.slug === this.appSlug);
const appName = current?.name || this.getAttribute('app-name') || '';
const loginUrl = `${links.login}?returnTo=${encodeURIComponent(location.href)}`;
const tile = (a: NavApp) =>
`<a class="tile" href="${esc(a.url)}"${a.newTab ? ' target="_blank" rel="noopener"' : ''}${
a.slug === this.appSlug ? ' aria-current="page"' : ''
}><img src="${esc(a.iconUrl)}" alt="">${esc(a.name)}</a>`;
const sections = new Map<string, NavApp[]>();
for (const a of nav?.apps ?? []) sections.set(a.section, [...(sections.get(a.section) ?? []), a]);
const tiles = [...sections]
.map(([title, apps]) => `<h3>${esc(title)}</h3><div class="grid">${apps.map(tile).join('')}</div>`)
.join('');
const appsPanel = nav?.user
? `${tiles}<a class="footer" href="${esc(links.portal)}">Zum Portal →</a>`
: `<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>
<a class="brand" href="${esc(links.portal)}"><img src="${PORTAL}/insel-logo.png" alt=""><span>Handy-<b>Insel</b></span></a>
${appName ? `<span class="sep"></span><span class="app-name">${esc(appName)}</span>` : ''}
<span class="spacer"></span>
<slot name="aktionen"></slot>
${
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>
${
hasUserMenu
? `<div class="panel user" ${this.open === 'user' ? '' : 'hidden'}>
${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)}" data-logout>Abmelden</a>
</div>`
: ''
}`;
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(links.logout);
});
}
}
if (!customElements.get('hi-topbar')) customElements.define('hi-topbar', HiTopbar);
function autoInsert(): void {
if (script?.dataset.auto === 'false' || document.querySelector('hi-topbar')) return;
const bar = document.createElement('hi-topbar');
if (script?.dataset.sticky !== undefined) bar.setAttribute('sticky', '');
document.body.prepend(bar);
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', autoInsert);
else autoInsert();