- 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>
310 lines
15 KiB
TypeScript
310 lines
15 KiB
TypeScript
/**
|
||
* 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 & 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();
|