feat(frontend): Epic 4 — Login-Overlay, Bootstrap-Gate (offline≠401), Sync-Auth, Nutzerwechsel-Isolation, Admin-UI, Logout

This commit is contained in:
Kenearos 2026-07-07 23:12:02 +02:00
parent 66717014d0
commit 8d51b50f9a
5 changed files with 172 additions and 6 deletions

33
app.js
View file

@ -1228,17 +1228,42 @@ class DienstplanApp {
// Initialize app when DOM is ready
let app;
document.addEventListener('DOMContentLoaded', async () => {
// 1. Auth-Status. Offline-bewusst: 401 = ungültige Session → Login-Overlay;
// Netzwerkfehler (Server weg) = NICHT aussperren, App läuft lokal weiter (NFR-8).
let me = null;
try {
const res = await fetch('/api/auth/me', { credentials: 'include', cache: 'no-store' });
if (res.status === 401) {
if (window.AuthUI) window.AuthUI.showLogin();
return; // App nicht starten
}
if (res.ok) me = await res.json();
} catch (e) {
console.warn('auth/me offline — App läuft lokal weiter:', e.message);
}
// 2. Nutzerwechsel-Isolation: anderer Nutzer als zuletzt gemerkt → lokale Daten + Key leeren.
if (me && me.email) {
const stored = localStorage.getItem('dienstplan_current_user');
if (stored && stored !== me.email && window.AuthUI) window.AuthUI.clearLocalData();
localStorage.setItem('dienstplan_current_user', me.email);
}
// 3. App starten
if (window.AuthUI) window.AuthUI.hideLogin();
if (window.DataSync) {
try { await window.DataSync.boot(); }
catch (e) { console.error('Sync-Boot fehlgeschlagen, App laeuft lokal weiter:', e); }
}
app = new DienstplanApp();
window.app = app;
// Bild-Import hier erzeugen (nicht in einem eigenen DOMContentLoaded-Listener):
// window.app wird erst nach dem await oben gesetzt, ein paralleler Listener
// liefe da noch ohne app und wuerde den Importer nie anlegen.
if (window.ImageImporter && !window.imageImporter) {
window.imageImporter = new window.ImageImporter(app);
}
// 4. Konto/Admin-UI
if (window.AuthUI) {
window.AuthUI.wireLogout();
if (me && me.isAdmin) window.AuthUI.showAdminSection();
}
});

112
auth-ui.js Normal file
View file

@ -0,0 +1,112 @@
/**
* AuthUI Login-Overlay, Admin-Nutzerverwaltung, Logout, Nutzer-Isolation.
* Hält app.js schlank; alle fetch mit credentials.
*/
const AuthUI = {
// Bei Nutzerwechsel/Logout zu leerende Schlüssel: Daten + pending + gerätelokaler OpenRouter-Key.
KEYS_TO_CLEAR: [
'dienstplan_employees', 'dienstplan_duties', 'dienstplan_vacation',
'dienstplan_sync_pending', 'dienstplan_openrouter_key', 'dienstplan_openrouter_model',
],
clearLocalData() { this.KEYS_TO_CLEAR.forEach(k => localStorage.removeItem(k)); },
showLogin() {
const ov = document.getElementById('login-overlay');
const cont = document.querySelector('.container');
if (ov) ov.hidden = false;
if (cont) cont.style.display = 'none';
this.wireLoginForm();
},
hideLogin() {
const ov = document.getElementById('login-overlay');
const cont = document.querySelector('.container');
if (ov) ov.hidden = true;
if (cont) cont.style.display = '';
},
wireLoginForm() {
const form = document.getElementById('login-form');
if (!form || form._wired) return;
form._wired = true;
form.addEventListener('submit', async (e) => {
e.preventDefault();
const email = (document.getElementById('login-email').value || '').trim();
try {
await fetch('/api/auth/request', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
credentials: 'include', body: JSON.stringify({ email }),
});
} catch { /* neutral bleiben */ }
const msg = document.getElementById('login-message');
if (msg) msg.hidden = false;
const btn = form.querySelector('button');
if (btn) btn.disabled = true;
});
},
showAdminSection() {
const sec = document.getElementById('admin-section');
if (sec) sec.hidden = false;
this.loadUsers();
const addForm = document.getElementById('admin-add-form');
if (addForm && !addForm._wired) {
addForm._wired = true;
addForm.addEventListener('submit', async (e) => {
e.preventDefault();
const input = document.getElementById('admin-add-email');
const email = (input.value || '').trim();
await fetch('/api/admin/users', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
credentials: 'include', body: JSON.stringify({ email }),
});
input.value = '';
this.loadUsers();
});
}
},
async loadUsers() {
const list = document.getElementById('admin-user-list');
if (!list) return;
try {
const res = await fetch('/api/admin/users', { credentials: 'include' });
if (!res.ok) return;
const { users } = await res.json();
list.innerHTML = '';
users.forEach((u) => {
const row = document.createElement('div');
row.className = 'admin-user-row';
row.style.cssText = 'display:flex;justify-content:space-between;align-items:center;padding:4px 0;border-bottom:1px solid #eee';
const span = document.createElement('span');
span.textContent = u.email + (u.isAdmin ? ' (Admin)' : '');
row.appendChild(span);
const btn = document.createElement('button');
btn.className = 'btn btn-secondary';
btn.textContent = 'Entfernen';
btn.addEventListener('click', async () => {
if (!confirm(`Nutzer ${u.email} entfernen?`)) return;
const r = await fetch('/api/admin/users/' + u.id, { method: 'DELETE', credentials: 'include' });
if (!r.ok) { const j = await r.json().catch(() => ({})); alert(j.error || 'Fehler beim Entfernen'); }
this.loadUsers();
});
row.appendChild(btn);
list.appendChild(row);
});
} catch { /* ignorieren */ }
},
wireLogout() {
const btn = document.getElementById('logout-btn');
if (!btn || btn._wired) return;
btn._wired = true;
btn.addEventListener('click', async () => {
try { await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }); } catch { /* egal */ }
this.clearLocalData();
localStorage.removeItem('dienstplan_current_user');
location.reload();
});
},
};
if (typeof window !== 'undefined') window.AuthUI = AuthUI;

View file

@ -15,6 +15,17 @@
</script>
</head>
<body>
<div id="login-overlay" hidden style="position:fixed;inset:0;z-index:10000;background:linear-gradient(135deg,#667eea,#764ba2);display:flex;align-items:center;justify-content:center">
<div style="background:#fff;padding:2rem;border-radius:12px;box-shadow:0 10px 40px rgba(0,0,0,.2);max-width:360px;width:90%;text-align:center">
<h1 style="font-size:1.4rem;margin:0 0 .5rem">Dienstplan-Pro</h1>
<p style="color:#555;font-size:.9rem">Melde dich mit deiner freigeschalteten Arbeits-E-Mail an — wir schicken dir einen Login-Link.</p>
<form id="login-form" style="margin-top:1rem;display:flex;flex-direction:column;gap:.75rem">
<input type="email" id="login-email" placeholder="E-Mail" required style="padding:.75rem;border:1px solid #ccc;border-radius:8px;font-size:1rem">
<button type="submit" class="btn btn-primary">Login-Link anfordern</button>
</form>
<p id="login-message" hidden style="color:#1e7e34;margin-top:1rem;font-size:.9rem">Prüfe dein Postfach und klicke den Link.</p>
</div>
</div>
<div class="container">
<header>
<h1>Dienstplan Pro</h1>
@ -166,6 +177,19 @@
<div class="card">
<h2>Einstellungen & Daten</h2>
<div class="settings-section" id="account-section">
<h3>Konto & Team</h3>
<button id="logout-btn" class="btn btn-secondary">Abmelden</button>
<div id="admin-section" hidden style="margin-top:12px">
<h4>Nutzer verwalten (Admin)</h4>
<form id="admin-add-form" style="display:flex;gap:8px;margin:8px 0;flex-wrap:wrap">
<input type="email" id="admin-add-email" placeholder="E-Mail freischalten" required style="flex:1;min-width:180px;padding:.5rem;border:1px solid #ccc;border-radius:6px">
<button type="submit" class="btn btn-primary">Freischalten</button>
</form>
<div id="admin-user-list"></div>
</div>
</div>
<div class="settings-section">
<h3>Berechnungsregeln (NRW Psychiatrie 2011)</h3>
<div class="info-box">
@ -327,6 +351,7 @@
<script src="calculator.js"></script>
<script src="storage.js"></script>
<script src="sync.js"></script>
<script src="auth-ui.js"></script>
<script src="app.js"></script>
<script src="image-import.js"></script>
</body>

3
sw.js
View file

@ -1,4 +1,4 @@
const CACHE_NAME = 'dienstplan-pro-v7';
const CACHE_NAME = 'dienstplan-pro-v8';
const ASSETS = [
'./',
'./index.html',
@ -9,6 +9,7 @@ const ASSETS = [
'./holidays.js',
'./storage.js',
'./sync.js',
'./auth-ui.js',
'./image-import.js'
];

View file

@ -52,7 +52,8 @@ const DataSync = {
async boot() {
let server = null;
try {
const res = await fetch('/api/state', { cache: 'no-store' });
const res = await fetch('/api/state', { cache: 'no-store', credentials: 'include' });
if (res.status === 401) { if (window.AuthUI) window.AuthUI.showLogin(); this.online = false; return; }
if (res.ok) server = await res.json();
} catch { /* offline */ }
@ -78,8 +79,10 @@ const DataSync = {
const res = await fetch('/api/state', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(this._local()),
});
if (res.status === 401) { if (window.AuthUI) window.AuthUI.showLogin(); this.online = false; return; }
if (!res.ok) throw new Error('HTTP ' + res.status);
if (this._dirty === gen) localStorage.removeItem(this.KEY_PENDING); // nur wenn kein neuerer push() lief
this.online = true;