Compare commits

..
5 Commits
Author SHA1 Message Date
kerimatilgan ebf1b13f86 Merge pull request 'Kopfleiste: Timer, Historie und Einstellungen in die Leiste' (#2) from feature/portal-topbar into main
Deploy auf den Timer-Server / deploy (push) Successful in 15s
Reviewed-on: #2
2026-10-25 12:22:46 +00:00
Kerim AtilganandClaude Opus 5.5 046e523352 Kopfleiste: Timer, Historie und Einstellungen in die Leiste
- Die drei Knöpfe hängen als slot="aktionen" rechts in der Portal-Leiste,
  die aktuelle Ansicht ist hervorgehoben
- Unter 600 px nur die Symbole, der Name steht im title
- Der eigene Header (Logo + Knöpfe) entfällt, solange die Leiste da ist;
  lädt topbar.js nicht, bleibt er als Rückfall
- Nach Ablauf der Sitzung (Anmeldeseite) sind Knöpfe und
  Sitzungsanzeige ausgeblendet

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:18:40 +01:00
kerimatilgan e41e2b38e3 Merge pull request 'Feature/portal topbar' (#1) from feature/portal-topbar into main
Deploy auf den Timer-Server / deploy (push) Successful in 21s
Reviewed-on: #1
2026-10-25 12:15:21 +00:00
Kerim AtilganandClaude Opus 5.5 0ee9b708a1 Kopfleiste: Benutzer-Menü und gemeinsames Abmelden
- Benutzername und "Abmelden" im eigenen Header entfallen, nur noch
  Rückfall, wenn topbar.js nicht lädt
- Sitzungsanzeige als slot="benutzer" im Benutzer-Menü der Leiste
- hi-abmelden: POST /api/logout beendet die Timer-Sitzung, laufende
  Timer fragen vorher nach (Abbrechen stoppt das ganze Abmelden)
- html data-theme="dark": die Leiste ist dunkel wie die App, auch bei
  hellem System
- README/DEPLOY: Abmelden, Launch-URL /auth/login, CSP-Hinweis

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:12:20 +01:00
kerimatilganandClaude Opus 5.5 8f5517a409 Portal-Kopfleiste einbinden
Laedt die zentrale Handy-Insel-Kopfleiste (topbar.js, data-app="timer")
und blendet sie beim Drucken aus. In den Proxy-Beispielen ist dokumentiert,
welche Quellen eine spaetere Content-Security-Policy erlauben muss.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 12:21:35 +02:00
7 changed files with 168 additions and 14 deletions

No files matched your search

+17
View File
@@ -265,6 +265,23 @@ Wichtig in beiden Fällen:
* `/health` ist der Endpunkt für Health-Checks (antwortet **ohne** Anmeldung)
* Websockets werden **nicht** gebraucht
### Portal-Kopfleiste
`frontend/index.html` lädt die zentrale Handy-Insel-Kopfleiste
(`https://portal.handy-insel.de/topbar.js`, `data-app="timer"`). Sie bringt
App-Umschalter und Benutzer-Menü mit; Benutzername und „Abmelden“ der App
stehen deshalb dort, die eigenen im Header erscheinen nur, wenn das Skript
nicht lädt.
* **Abmelden** in der Leiste ruft vorher `POST /api/logout` auf (Ereignis
`hi-abmelden`) und meldet danach bei Portal und Authentik ab.
* **Launch-URL** der Kachel im Portal: `https://timer.handy-insel.de/auth/login`
(startet direkt die Anmeldung über Authentik).
* **Content-Security-Policy:** Die App setzt keine. Kommt im Proxy eine dazu,
muss sie `script-src`/`connect-src https://portal.handy-insel.de` und
`img-src https://portal.handy-insel.de https://login.handy-insel.de`
erlauben (Details in den Proxy-Beispielen).
### Bondrucker
Vollständige Einrichtung: **`deploy/bondrucker/README.md`**. Hier nur das
+7 -2
View File
@@ -153,5 +153,10 @@ der Bondrucker hängt am USB-Anschluss.
## Abmelden
**🚪 Abmelden** meldet nur dieses Gerät ab. Laufende Timer bleiben davon
unberührt und laufen weiter.
Ganz oben steht die Handy-Insel-Leiste. Über den Avatar rechts öffnet sich das
Benutzer-Menü, dort steht **Abmelden**. Das meldet dieses Gerät beim Timer, im
Portal und bei Authentik ab. Laufende Timer bleiben davon unberührt und laufen
weiter. Laufen noch welche, fragt die App vorher nach.
Ist das Portal einmal nicht erreichbar, fehlt die Leiste. Dann steht
**🚪 Abmelden** wie früher oben rechts in der App.
+7 -2
View File
@@ -487,9 +487,14 @@ app.get('/auth/logout', (req, res) => {
});
});
// Passwort-Logout (Fallback, wird per fetch aufgerufen)
// Logout per fetch: beendet nur die lokale Sitzung. Aufgerufen von der
// Portal-Kopfleiste (Ereignis "hi-abmelden"), die danach selbst bei Portal
// und Authentik abmeldet.
app.post('/api/logout', (req, res) => {
req.session.destroy(() => res.json({ success: true }));
req.session.destroy(() => {
res.clearCookie('connect.sid');
res.json({ success: true });
});
});
// ---------------------------------------------------------------------------
+7
View File
@@ -9,6 +9,13 @@ timer.firma.example {
X-Frame-Options "SAMEORIGIN"
X-Content-Type-Options "nosniff"
Referrer-Policy "no-referrer"
# Die Seite laedt die Portal-Kopfleiste von portal.handy-insel.de.
# Wer hier eine Content-Security-Policy setzt, muss diese Quellen erlauben
# (sonst blockiert der Browser die Leiste), jeweils zusaetzlich zu den
# vorhandenen Quellen (die App nutzt Inline-Styles und onclick-Handler):
# script-src ... https://portal.handy-insel.de
# connect-src ... https://portal.handy-insel.de
# img-src ... https://portal.handy-insel.de https://login.handy-insel.de
}
reverse_proxy 127.0.0.1:3003 {
+7
View File
@@ -22,6 +22,13 @@ server {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
# Die Seite laedt die Portal-Kopfleiste von portal.handy-insel.de.
# Wer hier eine Content-Security-Policy setzt, muss diese Quellen erlauben
# (sonst blockiert der Browser die Leiste), jeweils zusaetzlich zu den
# vorhandenen Quellen (die App nutzt Inline-Styles und onclick-Handler):
# script-src ... https://portal.handy-insel.de
# connect-src ... https://portal.handy-insel.de
# img-src ... https://portal.handy-insel.de https://login.handy-insel.de
# CSV-Export darf auch bei vielen Einträgen dauern
client_max_body_size 1m;
+73 -7
View File
@@ -103,10 +103,13 @@ async function api(url, options = {}) {
function switchView(id) {
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
document.getElementById(id).classList.add('active');
for (const btn of topbarNav.children) btn.classList.toggle('active', btn.dataset.view === id);
}
function showLogin() {
document.getElementById('header').style.display = 'none';
signedInName = null;
renderTopbar();
switchView('loginView');
applyLoginMode();
}
@@ -161,28 +164,91 @@ async function enterApp() {
startLoops();
}
// Zeigt den angemeldeten Authentik-Nutzer oben rechts im Header.
// Zeigt den angemeldeten Authentik-Nutzer: im Benutzer-Menue der
// Portal-Kopfleiste, ohne Leiste oben rechts im eigenen Header.
function renderCurrentUser() {
const el = document.getElementById('currentUser');
if (!el) return;
const u = loggedInUser;
el.textContent = u ? (u.displayName || u.username || '') : '';
const name = u ? (u.displayName || u.username || '') : '';
const el = document.getElementById('currentUser');
el.textContent = name;
el.title = u && u.email ? u.email : 'Angemeldet';
signedInName = name || 'Angemeldet';
renderTopbar();
}
async function logout() {
function confirmLogout() {
const running = state.timers.filter(t => t.isRunning).length;
if (running > 0 && !confirm(
return running === 0 || confirm(
`${running} Timer ${running === 1 ? 'läuft' : 'laufen'} noch.\n\n` +
`Sie laufen serverseitig weiter und sind nach dem nächsten Anmelden wieder da. Trotzdem abmelden?`
)) return;
);
}
// Eigener Abmelden-Knopf, nur sichtbar ohne Portal-Kopfleiste
async function logout() {
if (!confirmLogout()) return;
stopLoops();
state.timers = [];
// GET-Navigation: beendet lokale Sitzung und – bei Authentik-Login – auch die SSO-Sitzung
window.location.href = '/auth/logout';
}
// ---------------------------------------------------------------------------
// Portal-Kopfleiste
// ---------------------------------------------------------------------------
// topbar.js laeuft per defer erst nach diesem Skript, die Leiste steht also
// fruehestens ab DOMContentLoaded im DOM. Navigation (slot="aktionen") und
// Sitzungsanzeige (slot="benutzer") werden einmal eingehaengt, sobald die
// Anmeldung steht. Danach aendern sich nur Text und Sichtbarkeit. Ist die
// Leiste da, blendet das CSS den eigenen Header aus.
const topbarNav = document.createElement('div');
topbarNav.slot = 'aktionen';
topbarNav.className = 'topbar-nav';
for (const [view, icon, label, show] of [
['timerView', '⏱️', 'Timer', () => showTimer()],
['historyView', '📊', 'Historie', () => showHistory()],
['settingsView', '⚙️', 'Einstellungen', () => showSettings()],
]) {
const btn = document.createElement('button');
btn.type = 'button';
btn.dataset.view = view;
btn.title = label;
btn.append(icon, Object.assign(document.createElement('span'), { textContent: label }));
btn.addEventListener('click', show);
topbarNav.append(btn);
}
const topbarSession = document.createElement('div');
topbarSession.slot = 'benutzer';
topbarSession.className = 'topbar-session';
topbarSession.append(Object.assign(document.createElement('span'), { textContent: 'Timer – angemeldet als' }),
document.createElement('strong'));
let signedInName = null; // null = nicht angemeldet
function renderTopbar() {
const signedIn = signedInName !== null;
topbarNav.hidden = topbarSession.hidden = !signedIn;
if (!signedIn) return;
topbarSession.lastChild.textContent = signedInName;
if (!topbarSession.isConnected) document.querySelector('hi-topbar')?.append(topbarNav, topbarSession);
}
document.addEventListener('DOMContentLoaded', renderTopbar);
// "Abmelden" in der Leiste: erst die eigene Sitzung beenden, danach meldet
// die Leiste bei Portal und Authentik ab. Abbrechen bei laufenden Timern
// verhindert das ganze Abmelden.
document.addEventListener('hi-abmelden', (e) => {
if (!confirmLogout()) {
e.preventDefault();
return;
}
stopLoops();
state.timers = [];
e.detail.warten(fetch('/api/logout', { method: 'POST', credentials: 'same-origin' }));
});
// ---------------------------------------------------------------------------
// Timer-Synchronisation
// ---------------------------------------------------------------------------
+50 -3
View File
@@ -1,10 +1,14 @@
<!DOCTYPE html>
<html lang="de">
<!-- data-theme: die App ist immer dunkel, die Portal-Kopfleiste soll es auch sein -->
<html lang="de" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Timer - Zeiterfassung</title>
<link rel="icon" type="image/jpeg" href="/favicon.jpg">
<!-- Zentrale Handy-Insel-Kopfleiste (App-Umschalter + Benutzermenue).
Einzige externe Ressource der Seite; Details: portal.handy-insel.de -->
<script src="https://portal.handy-insel.de/topbar.js" data-app="timer" defer></script>
<style>
/* Bewusst KEIN Google-Fonts-Import: kein externer Abruf zur Laufzeit.
Der System-Font-Stack sieht praktisch identisch aus (Segoe UI /
@@ -784,6 +788,49 @@
.history-item-right { justify-content: center; }
.filter-field, .filter-field input { width: 100%; }
}
/* ---------- Portal-Kopfleiste ---------- */
/* Die Leiste steht im Fluss als erstes Kind von <body>; body hat nur
min-height: 100vh, dadurch entsteht kein Ueberlauf. Beim Drucken
soll sie nicht erscheinen. */
hi-topbar { flex-shrink: 0; }
@media print { hi-topbar { display: none !important; } }
/* Navigation, Benutzername und Abmelden sitzen in der Leiste. Der eigene
Header bleibt nur als Rueckfall, wenn topbar.js nicht laedt
(!important, weil app.js ihn per style.display einblendet). */
hi-topbar ~ .container #header { display: none !important; }
/* Navigation rechts in der Leiste (slot="aktionen"), Text erbt Weiss */
.topbar-nav { display: flex; gap: 2px; }
.topbar-nav[hidden] { display: none; }
.topbar-nav button {
display: inline-flex;
align-items: center;
gap: 0.35rem;
height: 32px;
padding: 0 0.7rem;
border: 0;
border-radius: 8px;
background: none;
color: inherit;
font: inherit;
font-size: 13.5px;
cursor: pointer;
}
.topbar-nav button:hover { background: rgba(255, 255, 255, 0.14); }
.topbar-nav button.active { background: rgba(255, 255, 255, 0.22); }
.topbar-nav button:focus-visible { outline: 2px solid #cbbdf7; outline-offset: 1px; }
/* Schmal: nur die Symbole, der Name steht im title */
@media (max-width: 600px) {
.topbar-nav button { padding: 0 0.55rem; font-size: 16px; }
.topbar-nav button span { display: none; }
}
/* Sitzungsanzeige im Benutzer-Menue (slot="benutzer"), Schriftfarbe kommt von der Leiste */
.topbar-session { padding: 10px 16px; font-size: 13px; line-height: 1.4; }
.topbar-session span { display: block; opacity: 0.7; }
</style>
</head>
<body>
@@ -795,7 +842,7 @@
<button class="btn btn-secondary" onclick="showTimer()">⏱️ Timer</button>
<button class="btn btn-secondary" onclick="showHistory()">📊 Historie</button>
<button class="btn btn-secondary" onclick="showSettings()">⚙️ Einstellungen</button>
<button class="btn btn-secondary" onclick="logout()">🚪 Abmelden</button>
<button id="logoutBtn" class="btn btn-secondary" onclick="logout()">🚪 Abmelden</button>
</div>
</header>
@@ -1008,6 +1055,6 @@
<div class="toast" id="toast"></div>
<script src="/app.js?v=20260912-devices"></script>
<script src="/app.js?v=20261025-topbar-nav"></script>
</body>
</html>