Bearbeitung hinter ein Passwort legen, Ansehen für das Team freigeben

Mit EDIT_PASSWORD sehen Besucher nur den Kalender: keine E-Mails, Notizen,
Abwesenheiten oder Nextcloud-Details, keine schreibenden Aktionen. Anmelden
in der Kopfzeile setzt ein signiertes Cookie für 30 Tage; nach fünf
Fehlversuchen wartet der Server eine Minute. Ohne EDIT_PASSWORD bleibt
alles offen wie bisher.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ferdi2goandClaude Fable 5.1 committed 2026-09-16 12:36:23 +00:00
1 parent 9ed59bcf56
commit 33a8adef0e
8 files changed
+288 -13

No files matched your search

+62 -11
View File
@@ -43,10 +43,51 @@ async function api(path, { method = 'GET', body, raw } = {}) {
const res = await fetch(path, opts);
if (res.status === 204) return null;
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
if (!res.ok) {
if (data.authRequired) { openLogin(); throw new Error('Zum Bearbeiten bitte anmelden.'); }
throw new Error(data.error || `HTTP ${res.status}`);
}
return data;
}
// ---------- Anmeldung zum Bearbeiten ----------
const EDITOR_VIEWS = ['personen', 'abwesenheiten', 'einstellungen'];
function isEditor() { return !state.data || state.data.auth?.editor !== false; }
function applyAuthUI() {
const a = state.data?.auth || { protected: false, editor: true };
document.body.classList.toggle('viewer', !a.editor);
const btn = $('#auth-button');
btn.hidden = !a.protected;
btn.textContent = a.editor ? 'Abmelden' : 'Anmelden';
for (const link of $$('.topbar nav a')) link.hidden = !a.editor && EDITOR_VIEWS.includes(link.dataset.view);
$('#open-plan').hidden = !a.editor;
$('#open-nextcloud').hidden = !a.editor;
if (!a.editor && EDITOR_VIEWS.includes(location.hash.slice(1))) location.hash = '#kalender';
}
function openLogin() {
const dlg = $('#login-dialog');
if (dlg.open) return;
$('#login-result').hidden = true;
$('#login-password').value = '';
dlg.showModal();
}
$('#auth-button').addEventListener('click', async () => {
if (isEditor()) {
try { await api('api/auth/logout', { method: 'POST' }); await load(); toast('Abgemeldet. Du siehst jetzt die Ansicht für das Team.'); } catch (err) { toast(err.message, true); }
} else openLogin();
});
$('#login-close').addEventListener('click', () => $('#login-dialog').close());
$('#login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const out = $('#login-result');
try {
await api('api/auth/login', { method: 'POST', body: { password: $('#login-password').value } });
$('#login-dialog').close();
await load();
toast('Angemeldet. Bearbeiten ist freigeschaltet.');
} catch (err) { out.textContent = err.message; out.hidden = false; }
});
async function load() {
state.data = await api('api/state');
renderAll();
@@ -100,6 +141,7 @@ function avatarEl(p, cls = '') {
function showView(name) {
const known = ['kalender', 'personen', 'abwesenheiten', 'einstellungen'];
if (!known.includes(name)) name = 'kalender';
if (!isEditor() && EDITOR_VIEWS.includes(name)) name = 'kalender';
if (name === 'abwesenheiten' && state.data) prepareAbsenceImport();
for (const v of $$('.view')) v.hidden = v.id !== `view-${name}`;
for (const a of $$('.topbar nav a')) {
@@ -128,9 +170,10 @@ function renderCalendar() {
if (cur.getDay() === 0 || cur.getDay() === 6) continue;
const date = iso(cur);
const outside = cur.getMonth() !== d.getMonth();
const cell = document.createElement(outside ? 'div' : 'button');
const editable = !outside && isEditor();
const cell = document.createElement(editable ? 'button' : 'div');
cell.className = 'day';
if (!outside) { cell.type = 'button'; cell.dataset.date = date; }
if (editable) { cell.type = 'button'; cell.dataset.date = date; }
if (outside) cell.classList.add('outside');
if (date === today) cell.classList.add('today');
if (date < today) cell.classList.add('past');
@@ -584,7 +627,7 @@ function imageFromClipboard(data) {
}
// Strg+V überall in der Personenansicht: Bild aus der Zwischenablage landet im Formular
document.addEventListener('paste', (e) => {
if ($('#view-personen').hidden || !state.data) return;
if ($('#view-personen').hidden || !state.data || !isEditor()) return;
const file = imageFromClipboard(e.clipboardData);
if (!file) return;
e.preventDefault();
@@ -920,13 +963,16 @@ function renderSettings() {
list.appendChild(li);
}
const protectedHint = state.data.auth?.protected
? '<p class="status-ok">Bearbeitung ist durch das Passwort aus <code>EDIT_PASSWORD</code> geschützt. Das Team sieht nur den Kalender.</p>'
: '<p class="status-warn">Kein Bearbeitungspasswort gesetzt: Jeder, der die Seite öffnet, kann auch bearbeiten. Zum Schützen <code>EDIT_PASSWORD</code> als Umgebungsvariable setzen.</p>';
const nc = state.data.nextcloud;
const box = $('#nextcloud-status');
box.innerHTML = nc.configured
box.innerHTML = protectedHint + (nc.configured
? `<p class="status-ok">Konfiguriert: <code>${escapeHtml(nc.baseUrl)}</code>, angemeldet als <code>${escapeHtml(nc.user)}</code>.</p>`
+ `<p>Zielkalender: <strong>${s.targetCalendarHref ? escapeHtml(s.targetCalendarName || s.targetCalendarHref) : `neuer Kalender „${escapeHtml(s.calendarDisplayName)}" (${escapeHtml(nc.calendar)})`}</strong> – wählbar im Dialog „Nextcloud …" im Kalender.</p>`
+ `<p>Quellkalender für Abwesenheiten: <strong>${s.absenceCalendarHref ? escapeHtml(s.absenceCalendarName || s.absenceCalendarHref) : 'noch keiner'}</strong> – wählbar unter „Abwesenheiten".</p>`
: `<p class="status-warn">Nicht konfiguriert.</p><p class="hint">Umgebungsvariablen setzen: <code>NEXTCLOUD_URL</code>, <code>NEXTCLOUD_USER</code>, <code>NEXTCLOUD_APP_PASSWORD</code> (App-Passwort aus Nextcloud → Einstellungen → Sicherheit), optional <code>NEXTCLOUD_CALENDAR</code>. Bis dahin steht der ICS-Download zur Verfügung.</p>`;
: `<p class="status-warn">Nicht konfiguriert.</p><p class="hint">Umgebungsvariablen setzen: <code>NEXTCLOUD_URL</code>, <code>NEXTCLOUD_USER</code>, <code>NEXTCLOUD_APP_PASSWORD</code> (App-Passwort aus Nextcloud → Einstellungen → Sicherheit), optional <code>NEXTCLOUD_CALENDAR</code>. Bis dahin steht der ICS-Download zur Verfügung.</p>`);
}
function escapeHtml(s) { return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
@@ -962,12 +1008,17 @@ $('#holiday-add').addEventListener('submit', async (e) => {
// ---------- Start ----------
function renderAll() {
applyAuthUI();
renderCalendar();
renderPersons();
renderPhotoField();
renderAbsences();
renderSettings();
if (!$('#view-abwesenheiten').hidden) prepareAbsenceImport();
if (isEditor()) {
renderPersons();
renderPhotoField();
renderAbsences();
renderSettings();
if (!$('#view-abwesenheiten').hidden) prepareAbsenceImport();
} else {
showView(location.hash.slice(1));
}
}
showView(location.hash.slice(1));
load().catch((err) => toast(`Laden fehlgeschlagen: ${err.message}`, true));
+16
View File
@@ -18,6 +18,7 @@
<a href="#abwesenheiten" data-view="abwesenheiten">Abwesenheiten</a>
<a href="#einstellungen" data-view="einstellungen">Einstellungen</a>
</nav>
<button type="button" id="auth-button" class="topbar-action" hidden>Anmelden</button>
</header>
<main id="main">
@@ -255,6 +256,21 @@
</form>
</dialog>
<!-- Dialog: Anmelden zum Bearbeiten -->
<dialog id="login-dialog">
<form method="dialog" class="dialog-form" id="login-form">
<h2>Zum Bearbeiten anmelden</h2>
<p class="hint">Ansehen kann jeder. Auslosen, Personen, Abwesenheiten und Nextcloud erfordern das Bearbeitungspasswort.</p>
<label>Passwort <input type="password" name="password" id="login-password" autocomplete="current-password" required></label>
<p id="login-result" class="result error" hidden></p>
<div class="form-actions">
<span class="spacer"></span>
<button type="button" class="ghost" id="login-close">Abbrechen</button>
<button type="submit" class="primary">Anmelden</button>
</div>
</form>
</dialog>
<div id="toast" class="toast" role="status" aria-live="polite" hidden></div>
<script type="module" src="app.js"></script>
</body>
+7
View File
@@ -59,12 +59,19 @@ svg.icon { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-w
.topbar nav a:hover { color: var(--white); }
.topbar nav a[aria-current="page"] { color: var(--white); border-bottom-color: var(--white); }
.topbar nav a:focus-visible { outline: 2px solid var(--white); outline-offset: -4px; }
.topbar-action { margin-left: auto; background: transparent; color: var(--white); border-color: rgba(255, 255, 255, .4); min-height: 34px; padding: 0 12px; font-size: 13px; }
.topbar-action:hover { background: rgba(255, 255, 255, .1); }
.topbar-action:focus-visible { outline: 2px solid var(--white); outline-offset: 2px; }
/* Betrachtermodus: Kalender ansehen, nichts bearbeiten */
.viewer .day { cursor: default; }
.viewer .day:hover:not(.outside) { background: var(--white); }
@media (max-width: 560px) {
.topbar { padding: 0 16px; gap: 0 16px; }
.topbar h1 { padding: 12px 0; }
.topbar nav { flex-wrap: nowrap; overflow-x: auto; width: 100%; margin: 0 -16px; padding: 0 8px; scrollbar-width: none; }
.topbar nav::-webkit-scrollbar { display: none; }
.topbar nav a { min-height: 44px; padding: 0 10px; font-size: 13px; white-space: nowrap; }
.topbar-action { margin-left: auto; margin-block: 8px; }
}
button:focus-visible, input:focus-visible, select:focus-visible, .button:focus-visible, .day:focus-visible, .avatar[tabindex]:focus-visible {
outline: 2px solid var(--tradeo-blue); outline-offset: 2px;