Compare commits

..
3 Commits
Author SHA1 Message Date
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 119 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) * `/health` ist der Endpunkt für Health-Checks (antwortet **ohne** Anmeldung)
* Websockets werden **nicht** gebraucht * 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 ### Bondrucker
Vollständige Einrichtung: **`deploy/bondrucker/README.md`**. Hier nur das 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
**🚪 Abmelden** meldet nur dieses Gerät ab. Laufende Timer bleiben davon Ganz oben steht die Handy-Insel-Leiste. Über den Avatar rechts öffnet sich das
unberührt und laufen weiter. 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) => { 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-Frame-Options "SAMEORIGIN"
X-Content-Type-Options "nosniff" X-Content-Type-Options "nosniff"
Referrer-Policy "no-referrer" 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 { 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-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" 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 # CSV-Export darf auch bei vielen Einträgen dauern
client_max_body_size 1m; client_max_body_size 1m;
+50 -7
View File
@@ -161,28 +161,71 @@ async function enterApp() {
startLoops(); 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() { function renderCurrentUser() {
const el = document.getElementById('currentUser');
if (!el) return;
const u = loggedInUser; 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'; el.title = u && u.email ? u.email : 'Angemeldet';
renderTopbarSession(name);
} }
async function logout() { function confirmLogout() {
const running = state.timers.filter(t => t.isRunning).length; 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` + `${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?` `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(); stopLoops();
state.timers = []; state.timers = [];
// GET-Navigation: beendet lokale Sitzung und – bei Authentik-Login – auch die SSO-Sitzung // GET-Navigation: beendet lokale Sitzung und – bei Authentik-Login – auch die SSO-Sitzung
window.location.href = '/auth/logout'; 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. Die Sitzungsanzeige wird einmal
// eingehaengt, sobald die Anmeldung steht, danach aendert sich nur ihr Text.
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 topbarSessionName = null; // null = (noch) nicht angemeldet
function renderTopbarSession(name) {
topbarSessionName = name || 'Angemeldet';
topbarSession.lastChild.textContent = topbarSessionName;
if (!topbarSession.isConnected) document.querySelector('hi-topbar')?.append(topbarSession);
}
document.addEventListener('DOMContentLoaded', () => {
if (topbarSessionName !== null) renderTopbarSession(topbarSessionName);
});
// "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 // Timer-Synchronisation
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+24 -3
View File
@@ -1,10 +1,14 @@
<!DOCTYPE html> <!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> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Timer - Zeiterfassung</title> <title>Timer - Zeiterfassung</title>
<link rel="icon" type="image/jpeg" href="/favicon.jpg"> <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> <style>
/* Bewusst KEIN Google-Fonts-Import: kein externer Abruf zur Laufzeit. /* Bewusst KEIN Google-Fonts-Import: kein externer Abruf zur Laufzeit.
Der System-Font-Stack sieht praktisch identisch aus (Segoe UI / Der System-Font-Stack sieht praktisch identisch aus (Segoe UI /
@@ -784,6 +788,23 @@
.history-item-right { justify-content: center; } .history-item-right { justify-content: center; }
.filter-field, .filter-field input { width: 100%; } .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; } }
/* Benutzername und Abmelden sitzen im Benutzer-Menue der Leiste. Die
eigenen bleiben nur als Rueckfall, wenn topbar.js nicht laedt. */
hi-topbar ~ .container #currentUser,
hi-topbar ~ .container #logoutBtn { 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> </style>
</head> </head>
<body> <body>
@@ -795,7 +816,7 @@
<button class="btn btn-secondary" onclick="showTimer()">⏱️ Timer</button> <button class="btn btn-secondary" onclick="showTimer()">⏱️ Timer</button>
<button class="btn btn-secondary" onclick="showHistory()">📊 Historie</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="showSettings()">⚙️ Einstellungen</button>
<button class="btn btn-secondary" onclick="logout()">🚪 Abmelden</button> <button id="logoutBtn" class="btn btn-secondary" onclick="logout()">🚪 Abmelden</button>
</div> </div>
</header> </header>
@@ -1008,6 +1029,6 @@
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script src="/app.js?v=20260912-devices"></script> <script src="/app.js?v=20261025-topbar"></script>
</body> </body>
</html> </html>