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:
commit
6606420b4a
43 files changed
+5577
No files matched your search
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 & 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();
|
||||
Reference in new issue
Block a user