Handy-Insel Portal: Startseite, Kopfleiste und Authentik-Anbindung

- Portal mit Kacheln aus Authentik (pro Benutzer gefiltert) und "Aktuelles"
- Web-Component <hi-topbar> als zentraler App-Umschalter für alle Tools
- OIDC-Login über Authentik, zentrales Abmelden
- Setup-Skript für Authentik (Gruppen-Bindungen, Portal-App, Service-Account)
- Deployment auf den Portal-CT per systemd

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kerimatilganandClaude Opus 5.5 committed 2026-09-27 12:18:32 +02:00
commit 6606420b4a
43 files changed
+5577

No files matched your search

+226
View File
@@ -0,0 +1,226 @@
/**
* 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)).
*
* 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 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: "Segoe UI", system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif; font-size: 14px; color: #fff; }
: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: #2b0f71;
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; }
.avatar { width: 30px; height: 30px; border-radius: 50%; background: #fff; color: #2b0f71; font-size: 12px; font-weight: 700;
display: grid; place-items: center; }
.panel { position: absolute; top: ${HEIGHT + 6}px; background: #fff; color: #1f2a52; border-radius: 12px;
border: 1px solid #e2e8f0; box-shadow: 0 12px 32px rgba(15,23,42,.18); 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: #64748b; 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: #f3f0fd; }
.tile[aria-current="page"] { background: #e6defb; }
.tile img { width: 36px; height: 36px; }
.footer { display: block; margin: 4px -12px -12px; padding: 12px 18px; border-top: 1px solid #f1f5f9; font-weight: 600; color: #5a2fd6; }
.footer:hover { background: #f3f0fd; }
.hint { margin: 6px; font-size: 13px; line-height: 1.45; color: #475569; }
.hint a { color: #5a2fd6; font-weight: 600; }
.user { right: 6px; width: 260px; }
.user .who { padding: 12px 16px; border-bottom: 1px solid #f1f5f9; }
.user .who div:first-child { font-weight: 600; }
.user .who div:last-child { font-size: 13px; color: #64748b; overflow: hidden; text-overflow: ellipsis; }
.user a { display: block; padding: 10px 16px; font-size: 14px; }
.user a:hover { background: #f3f0fd; }
@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>';
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;
connectedCallback(): void {
document.documentElement.style.setProperty('--hi-topbar-height', `${HEIGHT}px`);
this.nav = readCache();
this.render();
void this.load();
document.addEventListener('mousedown', this.onOutside);
document.addEventListener('keydown', this.onKey);
}
disconnectedCallback(): void {
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);
};
private toggle(which: 'apps' | 'user' | null): void {
this.open = this.open === which ? null : which;
this.render();
}
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.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}/icon-192.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>
${
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>`
: ''
}
</nav>
<div class="panel apps" lang="de" ${this.open === 'apps' ? '' : 'hidden'}>${appsPanel}</div>
${
user
? `<div class="panel user" ${this.open === 'user' ? '' : 'hidden'}>
<div class="who"><div>${esc(user.name)}</div><div>${esc(user.email)}</div></div>
<a href="${esc(links.portal)}">Portal</a>
${links.account ? `<a href="${esc(links.account)}">Konto &amp; Passwort</a>` : ''}
<a href="${esc(links.logout)}">Abmelden</a>
</div>`
: ''
}`;
this.root.querySelectorAll<HTMLButtonElement>('[data-toggle]').forEach((btn) =>
btn.addEventListener('click', () => this.toggle(btn.dataset.toggle as 'apps' | 'user')),
);
}
}
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();