From 6a1c0e355c14e576f7e1613e1752f266fed9ecb4 Mon Sep 17 00:00:00 2001 From: Dogfather Date: Fri, 31 Jul 2026 00:37:04 +0200 Subject: [PATCH] =?UTF-8?q?Vollst=C3=A4ndiges=20Zugangs-/Rollen-/Rechtesys?= =?UTF-8?q?tem=20gebaut=20(Anforderungsdokument):=20D1-Datenbank,=20AES-ve?= =?UTF-8?q?rschl=C3=BCsselte=20Codes=20nur=20f=C3=BCr=20Owner=20einsehbar,?= =?UTF-8?q?=20granulare=20Permissions,=20Session-Auth=20mit=20Lockout,=20Z?= =?UTF-8?q?ug=C3=A4nge-&-Teammitglieder-Verwaltungsseite,=20Bewerbungen=20?= =?UTF-8?q?mit=20Status/Notizen/Entw=C3=BCrfen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/js/admin-auth.js | 81 +++ assets/js/postfach.js | 216 ++++++++ assets/js/zugaenge.js | 510 +++++++++++++++++++ cloudflare-worker/migrations/0001_init.sql | 106 ++++ cloudflare-worker/src/lib/audit.js | 15 + cloudflare-worker/src/lib/auth.js | 161 ++++++ cloudflare-worker/src/lib/crypto.js | 78 +++ cloudflare-worker/src/lib/http.js | 19 + cloudflare-worker/src/lib/permissions.js | 96 ++++ cloudflare-worker/src/routes/applications.js | 218 ++++++++ cloudflare-worker/src/routes/audit.js | 20 + cloudflare-worker/src/routes/auth.js | 69 +++ cloudflare-worker/src/routes/roles.js | 95 ++++ cloudflare-worker/src/routes/users.js | 269 ++++++++++ cloudflare-worker/src/worker.js | 343 ++++--------- cloudflare-worker/wrangler.toml | 5 + postfach.html | 320 ++---------- zugaenge.html | 201 ++++++++ 18 files changed, 2283 insertions(+), 539 deletions(-) create mode 100644 assets/js/admin-auth.js create mode 100644 assets/js/postfach.js create mode 100644 assets/js/zugaenge.js create mode 100644 cloudflare-worker/migrations/0001_init.sql create mode 100644 cloudflare-worker/src/lib/audit.js create mode 100644 cloudflare-worker/src/lib/auth.js create mode 100644 cloudflare-worker/src/lib/crypto.js create mode 100644 cloudflare-worker/src/lib/http.js create mode 100644 cloudflare-worker/src/lib/permissions.js create mode 100644 cloudflare-worker/src/routes/applications.js create mode 100644 cloudflare-worker/src/routes/audit.js create mode 100644 cloudflare-worker/src/routes/auth.js create mode 100644 cloudflare-worker/src/routes/roles.js create mode 100644 cloudflare-worker/src/routes/users.js create mode 100644 zugaenge.html diff --git a/assets/js/admin-auth.js b/assets/js/admin-auth.js new file mode 100644 index 0000000..e6b361f --- /dev/null +++ b/assets/js/admin-auth.js @@ -0,0 +1,81 @@ +/* ===================================================================== + admin-auth.js — gemeinsame Session-Logik für postfach.html und + zugaenge.html. Session (Token + Rolle/Rechte) liegt in sessionStorage + (pro Tab, verschwindet beim Schließen) — der Server erzwingt zusätzlich + Inaktivitäts-Timeout (30 Min) und eine absolute Höchstdauer (12 Std). + ===================================================================== */ + +const SESSION_KEY = "dogfather_admin_session"; + +function getSession() { + try { + return JSON.parse(sessionStorage.getItem(SESSION_KEY) || "null"); + } catch { + return null; + } +} + +function setSession(data) { + sessionStorage.setItem(SESSION_KEY, JSON.stringify(data)); +} + +function clearSession() { + sessionStorage.removeItem(SESSION_KEY); +} + +/** Prüft, ob die aktuelle Session (Owner oder Rolle+Overrides) ein Recht hat. */ +function sessionHasPermission(session, key) { + if (!session) return false; + if (session.isOwner) return true; + const overrides = session.overrides || {}; + if (Object.prototype.hasOwnProperty.call(overrides, key)) return !!overrides[key]; + return Array.isArray(session.permissions) && session.permissions.includes(key); +} + +async function apiCall(apiBaseUrl, path, body, { auth = true } = {}) { + const session = getSession(); + const headers = { "Content-Type": "application/json" }; + if (auth && session?.token) headers["Authorization"] = `Bearer ${session.token}`; + + const res = await fetch(`${apiBaseUrl}${path}`, { + method: "POST", + headers, + body: JSON.stringify(body || {}), + }); + let data; + try { + data = await res.json(); + } catch { + data = { ok: false, error: "Ungültige Antwort vom Server." }; + } + if (res.status === 401 && auth) { + clearSession(); // Session ist abgelaufen/ungültig -> zurück zum Login zwingen + } + return { httpOk: res.ok, ...data }; +} + +async function doLogin(apiBaseUrl, code) { + const res = await apiCall(apiBaseUrl, "/auth/login", { code }, { auth: false }); + if (res.ok) { + setSession({ + token: res.token, + isOwner: res.isOwner, + name: res.name, + username: res.username, + roleName: res.roleName, + permissions: res.permissions || [], + overrides: res.overrides || {}, + }); + } + return res; +} + +async function doLogout(apiBaseUrl) { + await apiCall(apiBaseUrl, "/auth/logout", {}); + clearSession(); +} + +// Für Einbindung ohne Modul-Bundler global verfügbar machen. +if (typeof window !== "undefined") { + window.AdminAuth = { getSession, setSession, clearSession, sessionHasPermission, apiCall, doLogin, doLogout }; +} diff --git a/assets/js/postfach.js b/assets/js/postfach.js new file mode 100644 index 0000000..a406236 --- /dev/null +++ b/assets/js/postfach.js @@ -0,0 +1,216 @@ +/* ===================================================================== + postfach.js — Logik für postfach.html + ===================================================================== */ + +const API_BASE_URL = "https://dogfather-universe-postfach.dogfather1608.workers.dev"; + +const { getSession, sessionHasPermission, apiCall, doLogin, doLogout } = window.AdminAuth; + +const STATUS_OPTIONS = ["neu", "in_bearbeitung", "beantwortet"]; + +function $(id) { return document.getElementById(id); } + +function showApp(session) { + $("gate-container").style.display = "none"; + $("session-bar").style.display = "block"; + $("inbox-section").style.display = "block"; + $("role-badge").textContent = session.isOwner ? "Eingeloggt als: Dogi (Owner)" : `Eingeloggt als: ${session.name} (${session.roleName})`; + + const canSeeTeam = session.isOwner || sessionHasPermission(session, "TEAM_VIEW"); + $("team-link").style.display = canSeeTeam ? "inline-flex" : "none"; + + if (session.isOwner) { + $("live-toggle-section").style.display = "block"; + loadLiveStatus(); + } + + loadInbox(); +} + +function showGate() { + $("gate-container").style.display = "block"; + $("session-bar").style.display = "none"; + $("live-toggle-section").style.display = "none"; + $("inbox-section").style.display = "none"; +} + +/* ---------------- Bewerbungen ---------------- */ +async function loadInbox() { + const session = getSession(); + const includeArchived = $("show-archived").checked; + const res = await apiCall(API_BASE_URL, "/admin/applications/list", { includeArchived }); + if (!res.ok) { + if (!getSession()) showGate(); + return; + } + renderInbox(res.applications, session); +} + +function renderInbox(applications, session) { + const list = $("inbox-list"); + if (!applications.length) { + list.innerHTML = `

Keine Bewerbungen in dieser Ansicht.

`; + return; + } + + list.innerHTML = applications + .map((a) => { + const fields = a.data + ? Object.entries(a.data).map(([k, v]) => `

${k}: ${v}

`).join("") + : `

Keine Berechtigung, den vollen Inhalt zu öffnen.

`; + const date = new Date(a.createdAt).toLocaleString("de-DE"); + + const actions = []; + if (sessionHasPermission(session, "APPLICATIONS_TAKE_OVER")) { + actions.push(``); + } + if (sessionHasPermission(session, "APPLICATIONS_MARK_ANSWERED")) { + actions.push(``); + } + if (sessionHasPermission(session, "APPLICATIONS_ARCHIVE") && !a.archived) { + actions.push(``); + } + if (sessionHasPermission(session, "APPLICATIONS_RESTORE") && a.archived) { + actions.push(``); + } + if (sessionHasPermission(session, "APPLICATIONS_DELETE")) { + actions.push(``); + } + if (sessionHasPermission(session, "NOTES_READ")) { + actions.push(``); + } + + const statusSelect = sessionHasPermission(session, "APPLICATIONS_CHANGE_STATUS") + ? `` + : `${a.status}`; + + return ` +
+
+ ${a.type}${a.archived ? " · archiviert" : ""} + ${statusSelect} +
+

${date}

+
${fields}
+
${actions.join("")}
+
+

Lädt…

+ ${ + sessionHasPermission(session, "NOTES_WRITE") + ? `
+ + +
` + : "" + } +
+
`; + }) + .join(""); + + list.querySelectorAll(".take-btn").forEach((b) => b.addEventListener("click", () => appAction("/admin/applications/take-over", b.dataset.id))); + list.querySelectorAll(".answered-btn").forEach((b) => b.addEventListener("click", () => appAction("/admin/applications/mark-answered", b.dataset.id))); + list.querySelectorAll(".archive-btn").forEach((b) => b.addEventListener("click", () => appAction("/admin/applications/archive", b.dataset.id))); + list.querySelectorAll(".restore-btn").forEach((b) => b.addEventListener("click", () => appAction("/admin/applications/restore", b.dataset.id))); + list.querySelectorAll(".delete-btn").forEach((b) => + b.addEventListener("click", () => { + if (confirm("Diese Bewerbung wirklich endgültig löschen?")) appAction("/admin/applications/delete", b.dataset.id); + }) + ); + list.querySelectorAll(".status-select").forEach((sel) => + sel.addEventListener("change", () => appAction("/admin/applications/status", sel.dataset.id, { status: sel.value }, false)) + ); + list.querySelectorAll(".notes-toggle-btn").forEach((b) => + b.addEventListener("click", () => { + const box = $(`notes-${b.dataset.id}`); + box.classList.toggle("open"); + if (box.classList.contains("open")) loadNotes(b.dataset.id); + }) + ); + list.querySelectorAll(".add-note-btn").forEach((b) => + b.addEventListener("click", async () => { + const textarea = document.querySelector(`.note-input[data-id="${b.dataset.id}"]`); + const text = textarea.value.trim(); + if (!text) return; + const res = await apiCall(API_BASE_URL, "/admin/notes/add", { applicationId: b.dataset.id, text }); + if (res.ok) { + textarea.value = ""; + loadNotes(b.dataset.id); + } + }) + ); +} + +async function appAction(path, id, extra, reload = true) { + const res = await apiCall(API_BASE_URL, path, { id, ...(extra || {}) }); + if (res.ok && reload) loadInbox(); + else if (!res.ok) alert(res.error || "Fehler."); +} + +async function loadNotes(applicationId) { + const box = $(`notes-list-${applicationId}`); + const res = await apiCall(API_BASE_URL, "/admin/notes/list", { applicationId }); + if (!res.ok) { + box.innerHTML = `

Konnte Notizen nicht laden.

`; + return; + } + box.innerHTML = + res.notes + .map((n) => `
${n.author}: ${n.text}
${new Date(n.created_at).toLocaleString("de-DE")}
`) + .join("") || `

Noch keine Notizen.

`; +} + +/* ---------------- Live-Status ---------------- */ +function renderLiveStatus(live) { + $("admin-live-dot").classList.toggle("is-live", !!live); + $("admin-live-text").textContent = live ? "Live jetzt!" : "Aktuell offline"; +} + +async function loadLiveStatus() { + try { + const res = await fetch(`${API_BASE_URL}/live-status`); + const data = await res.json(); + if (res.ok && data.ok) renderLiveStatus(data.live); + } catch { + /* keine Blockade */ + } +} + +async function setLiveStatus(live) { + const res = await apiCall(API_BASE_URL, "/live-status/set", { live }); + if (res.ok) renderLiveStatus(res.live); +} + +/* ---------------- Login / Logout ---------------- */ +document.addEventListener("DOMContentLoaded", () => { + const existing = getSession(); + if (existing) showApp(existing); + else showGate(); + + $("code-gate").addEventListener("submit", async (e) => { + e.preventDefault(); + const status = $("gate-status"); + status.className = "form-status show"; + status.textContent = "Prüfe Code …"; + const res = await doLogin(API_BASE_URL, $("code-input").value); + if (res.ok) { + status.className = "form-status"; + showApp(getSession()); + } else { + status.textContent = res.error || "Falscher Code."; + status.className = "form-status show error"; + } + }); + + $("logout-btn").addEventListener("click", async () => { + await doLogout(API_BASE_URL); + showGate(); + }); + + $("refresh-btn").addEventListener("click", loadInbox); + $("show-archived").addEventListener("change", loadInbox); + $("go-live-btn").addEventListener("click", () => setLiveStatus(true)); + $("go-offline-btn").addEventListener("click", () => setLiveStatus(false)); +}); diff --git a/assets/js/zugaenge.js b/assets/js/zugaenge.js new file mode 100644 index 0000000..cc398d3 --- /dev/null +++ b/assets/js/zugaenge.js @@ -0,0 +1,510 @@ +/* ===================================================================== + zugaenge.js — Logik für zugaenge.html ("Zugänge und Teammitglieder") + + WICHTIG: Diese Permission-Liste muss exakt mit + cloudflare-worker/src/lib/permissions.js übereinstimmen (Anzeige only — + die eigentliche Durchsetzung passiert immer serverseitig im Worker). + ===================================================================== */ + +// Gleiche Worker-URL wie überall sonst (forms.js, postfach.html, index.html). +const API_BASE_URL = "https://dogfather-universe-postfach.dogfather1608.workers.dev"; + +const PERMISSION_GROUPS = [ + { label: "Bewerbungsbereiche", keys: ["APPLICATIONS_VIEW_CREATOR", "APPLICATIONS_VIEW_MANAGER_SCOUT", "APPLICATIONS_VIEW_ARCHIVED", "APPLICATIONS_OPEN_FULL"] }, + { label: "Bearbeitung", keys: ["APPLICATIONS_TAKE_OVER", "APPLICATIONS_ASSIGN", "APPLICATIONS_CHANGE_STATUS", "APPLICATIONS_ARCHIVE", "APPLICATIONS_RESTORE", "APPLICATIONS_DELETE"] }, + { label: "Antworten", keys: ["DRAFTS_CREATE", "DRAFTS_EDIT_OWN", "DRAFTS_EDIT_OTHERS", "REPLIES_COPY", "EMAILS_COPY", "APPLICATIONS_MARK_ANSWERED"] }, + { label: "Interne Notizen", keys: ["NOTES_READ", "NOTES_WRITE", "NOTES_EDIT_OWN", "NOTES_EDIT_OTHERS", "NOTES_DELETE"] }, + { label: "Teamverwaltung", keys: ["TEAM_VIEW", "TEAM_PREPARE_USER", "TEAM_LOCK_USER", "TEAM_ASSIGN_ROLE", "TEAM_CREATE_ROLE", "TEAM_EDIT_PERMISSIONS"] }, + { label: "Einstellungen", keys: ["SETTINGS_EDIT_FORMS", "SETTINGS_EDIT_STATUS_TYPES", "SETTINGS_VIEW_STATS", "SETTINGS_VIEW_ACTIVITY_LOG", "SETTINGS_EDIT_GENERAL"] }, +]; +const PERMISSION_LABELS = { + APPLICATIONS_VIEW_CREATOR: "Creator-Bewerbungen ansehen", + APPLICATIONS_VIEW_MANAGER_SCOUT: "Manager-/Scout-Bewerbungen ansehen", + APPLICATIONS_VIEW_ARCHIVED: "Archivierte Bewerbungen ansehen", + APPLICATIONS_OPEN_FULL: "Vollständige Bewerbungen öffnen", + APPLICATIONS_TAKE_OVER: "Bewerbungen übernehmen", + APPLICATIONS_ASSIGN: "Bewerbungen zuweisen", + APPLICATIONS_CHANGE_STATUS: "Status verändern", + APPLICATIONS_ARCHIVE: "Archivieren", + APPLICATIONS_RESTORE: "Wiederherstellen", + APPLICATIONS_DELETE: "Löschen", + DRAFTS_CREATE: "Antwortentwürfe erstellen", + DRAFTS_EDIT_OWN: "Eigene Entwürfe bearbeiten", + DRAFTS_EDIT_OTHERS: "Fremde Entwürfe bearbeiten", + REPLIES_COPY: "Antworten kopieren", + EMAILS_COPY: "E-Mail-Adressen kopieren", + APPLICATIONS_MARK_ANSWERED: "Als beantwortet markieren", + NOTES_READ: "Notizen lesen", + NOTES_WRITE: "Notizen schreiben", + NOTES_EDIT_OWN: "Eigene Notizen bearbeiten", + NOTES_EDIT_OTHERS: "Fremde Notizen bearbeiten", + NOTES_DELETE: "Notizen löschen", + TEAM_VIEW: "Teammitglieder ansehen", + TEAM_PREPARE_USER: "Neue Benutzer vorbereiten", + TEAM_LOCK_USER: "Benutzer sperren", + TEAM_ASSIGN_ROLE: "Rollen vergeben", + TEAM_CREATE_ROLE: "Rollen erstellen", + TEAM_EDIT_PERMISSIONS: "Berechtigungen verändern", + SETTINGS_EDIT_FORMS: "Bewerbungsformulare bearbeiten", + SETTINGS_EDIT_STATUS_TYPES: "Statusarten verändern", + SETTINGS_VIEW_STATS: "Statistiken ansehen", + SETTINGS_VIEW_ACTIVITY_LOG: "Aktivitätsverlauf ansehen", + SETTINGS_EDIT_GENERAL: "Allgemeine Einstellungen bearbeiten", +}; + +const { getSession, clearSession, sessionHasPermission, apiCall, doLogin, doLogout } = window.AdminAuth; + +let currentRoles = []; +let currentUsers = []; + +function $(id) { return document.getElementById(id); } + +function showApp(session) { + $("gate-container").style.display = "none"; + $("session-bar").style.display = "block"; + $("main-area").style.display = "block"; + $("role-badge").textContent = session.isOwner ? "Eingeloggt als: Dogi (Owner)" : `Eingeloggt als: ${session.name} (${session.roleName})`; + $("new-user-btn").style.display = session.isOwner || sessionHasPermission(session, "TEAM_PREPARE_USER") ? "inline-flex" : "none"; + $("new-role-btn").style.display = session.isOwner || sessionHasPermission(session, "TEAM_CREATE_ROLE") ? "inline-flex" : "none"; + const auditTabBtn = document.querySelector('[data-tab="tab-audit"]'); + const canViewAudit = session.isOwner || sessionHasPermission(session, "SETTINGS_VIEW_ACTIVITY_LOG"); + if (auditTabBtn) auditTabBtn.style.display = canViewAudit ? "" : "none"; + + loadRoles().then(loadUsers); + if (session.isOwner || sessionHasPermission(session, "SETTINGS_VIEW_ACTIVITY_LOG")) loadAudit(); +} + +function showGate() { + $("gate-container").style.display = "block"; + $("session-bar").style.display = "none"; + $("main-area").style.display = "none"; +} + +/* ---------------- Tabs ---------------- */ +function setupTabs() { + document.querySelectorAll(".tab-btn").forEach((btn) => { + btn.addEventListener("click", () => { + document.querySelectorAll(".tab-btn").forEach((b) => b.classList.remove("active")); + document.querySelectorAll(".tab-panel").forEach((p) => p.classList.remove("active")); + btn.classList.add("active"); + $(btn.dataset.tab).classList.add("active"); + }); + }); +} + +/* ---------------- Users ---------------- */ +async function loadUsers() { + const res = await apiCall(API_BASE_URL, "/admin/users/list", {}); + if (!res.ok) return; + currentUsers = res.users; + renderUsers(); +} + +function roleNameById(id) { + const r = currentRoles.find((x) => x.id === id); + return r ? r.name : "—"; +} + +function renderUsers() { + const session = getSession(); + const container = $("users-list"); + if (!currentUsers.length) { + container.innerHTML = `

Noch keine Zugänge angelegt.

`; + return; + } + container.innerHTML = currentUsers + .map((u) => { + const statusClass = u.status === "active" ? "active" : u.status === "pending" ? "pending" : "locked"; + const isOwner = session.isOwner; + const canLock = isOwner || sessionHasPermission(session, "TEAM_LOCK_USER"); + const canAssignRole = isOwner || sessionHasPermission(session, "TEAM_ASSIGN_ROLE"); + const canEditPerms = isOwner || sessionHasPermission(session, "TEAM_EDIT_PERMISSIONS"); + + const actions = []; + if (u.status === "pending" && isOwner) { + actions.push(``); + } + if (isOwner && u.status !== "pending") { + actions.push(``); + actions.push(``); + actions.push(``); + } + if (canLock && u.status !== "pending") { + actions.push( + u.status === "active" + ? `` + : `` + ); + } + if (canAssignRole) { + actions.push(``); + } + if (canEditPerms) { + actions.push(``); + } + if (isOwner) { + actions.push(``); + } + + return ` +
+
${u.name}
@${u.username}${u.email ? " · " + u.email : ""}
+
${roleNameById(u.roleId)}
+
${u.status}
+
${u.expiresAt ? new Date(u.expiresAt).toLocaleDateString("de-DE") : "dauerhaft"}
+
${u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleString("de-DE") : "nie"}
+
${actions.join("")}
+
`; + }) + .join(""); + + container.querySelectorAll(".lock-btn").forEach((b) => b.addEventListener("click", () => userAction("/admin/users/lock", b.dataset.id))); + container.querySelectorAll(".unlock-btn").forEach((b) => b.addEventListener("click", () => userAction("/admin/users/unlock", b.dataset.id))); + container.querySelectorAll(".delete-btn").forEach((b) => + b.addEventListener("click", () => { + if (confirm("Diesen Zugang wirklich vollständig löschen?")) userAction("/admin/users/delete", b.dataset.id); + }) + ); + container.querySelectorAll(".endsessions-btn").forEach((b) => b.addEventListener("click", () => userAction("/admin/users/end-sessions", b.dataset.id, "Alle Geräte wurden abgemeldet."))); + container.querySelectorAll(".reveal-btn").forEach((b) => b.addEventListener("click", () => openRevealModal(b.dataset.id))); + container.querySelectorAll(".newcode-btn").forEach((b) => b.addEventListener("click", () => newCodeForUser(b.dataset.id))); + container.querySelectorAll(".finalize-btn").forEach((b) => b.addEventListener("click", () => openUserModal({ pendingUserId: b.dataset.id }))); + container.querySelectorAll(".role-btn").forEach((b) => b.addEventListener("click", () => changeUserRole(b.dataset.id))); + container.querySelectorAll(".perms-btn").forEach((b) => b.addEventListener("click", () => openPermModal(b.dataset.id))); +} + +async function userAction(path, id, successMsg) { + const res = await apiCall(API_BASE_URL, path, { id }); + if (res.ok) { + if (successMsg) alert(successMsg); + loadUsers(); + } else { + alert(res.error || "Fehler."); + } +} + +async function newCodeForUser(id) { + if (!confirm("Neuen Code erstellen? Der alte Code funktioniert danach sofort nicht mehr.")) return; + const res = await apiCall(API_BASE_URL, "/admin/users/set-code", { id }); + if (res.ok) { + alert(`Neuer Code: ${res.code}\n\nJetzt sichern/weitergeben — wird nirgends mehr angezeigt!`); + } else { + alert(res.error || "Fehler."); + } +} + +function changeUserRole(id) { + const options = currentRoles.map((r) => `${r.name} (${r.id})`).join("\n"); + const pick = prompt(`Neue Rolle für diese Person — Rollenname eingeben:\n\nVerfügbar:\n${currentRoles.map((r) => r.name).join(", ")}`); + if (pick === null) return; + const role = currentRoles.find((r) => r.name.toLowerCase() === pick.trim().toLowerCase()); + if (!role) return alert("Rolle nicht gefunden."); + apiCall(API_BASE_URL, "/admin/users/set-role", { id, roleId: role.id }).then((res) => { + if (res.ok) loadUsers(); + else alert(res.error || "Fehler."); + }); +} + +/* ---------------- Neuer Zugang / Vorbereiten ---------------- */ +function openUserModal({ pendingUserId } = {}) { + const session = getSession(); + $("user-form").reset(); + $("uf-new-code").style.display = "none"; + $("uf-pending-id").value = pendingUserId || ""; + $("user-modal-title").textContent = pendingUserId ? "Zugang freigeben" : session.isOwner ? "Neuer Zugang" : "Neuen Benutzer vorbereiten"; + $("uf-submit-btn").textContent = pendingUserId ? "Freigeben & Code erstellen" : session.isOwner ? "Zugang erstellen" : "Zur Freigabe vorbereiten"; + $("uf-code-field").style.display = session.isOwner ? "block" : "none"; + + const roleSelect = $("uf-role"); + roleSelect.innerHTML = currentRoles.map((r) => ``).join(""); + + if (pendingUserId) { + const u = currentUsers.find((x) => x.id === pendingUserId); + if (u) { + $("uf-name").value = u.name; + $("uf-username").value = u.username; + $("uf-email").value = u.email || ""; + if (u.roleId) roleSelect.value = u.roleId; + } + } + + $("user-modal").classList.add("open"); +} + +$("user-modal-close").addEventListener("click", () => $("user-modal").classList.remove("open")); +$("new-user-btn").addEventListener("click", () => openUserModal()); + +document.querySelectorAll('input[name="uf-code-mode"]').forEach((el) => { + el.addEventListener("change", () => { + $("uf-code-manual").style.display = document.querySelector('input[name="uf-code-mode"]:checked').value === "manual" ? "block" : "none"; + }); +}); +$("uf-expiry").addEventListener("change", () => { + $("uf-expiry-date").style.display = $("uf-expiry").value === "date" ? "block" : "none"; +}); + +$("user-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const session = getSession(); + const pendingUserId = $("uf-pending-id").value || null; + const isOwner = session.isOwner; + + const payload = { + name: $("uf-name").value.trim(), + username: $("uf-username").value.trim(), + email: $("uf-email").value.trim(), + roleId: $("uf-role").value, + note: $("uf-note").value.trim(), + }; + + const statusEl = $("user-form-status"); + + if (!isOwner && !pendingUserId) { + const res = await apiCall(API_BASE_URL, "/admin/users/prepare", payload); + if (res.ok) { + statusEl.textContent = "Vorbereitet — Dogi muss den Zugang jetzt noch mit einem Code freigeben."; + statusEl.className = "form-status show success"; + loadUsers(); + setTimeout(() => $("user-modal").classList.remove("open"), 1800); + } else { + statusEl.textContent = res.error || "Fehler."; + statusEl.className = "form-status show error"; + } + return; + } + + if (pendingUserId) payload.pendingUserId = pendingUserId; + + const mode = document.querySelector('input[name="uf-code-mode"]:checked').value; + if (mode === "manual") payload.code = $("uf-code-manual").value.trim(); + + const expiryChoice = $("uf-expiry").value; + if (expiryChoice === "date") payload.expiresAt = $("uf-expiry-date").value; + else if (expiryChoice) payload.expiresInDays = Number(expiryChoice); + + const res = await apiCall(API_BASE_URL, "/admin/users/finalize", payload); + if (res.ok) { + $("uf-new-code").style.display = "block"; + $("uf-new-code").innerHTML = `Code: ${res.code}
Jetzt sichern/weitergeben — wird nirgends mehr angezeigt!`; + statusEl.textContent = ""; + statusEl.className = "form-status"; + loadUsers(); + } else { + statusEl.textContent = res.error || "Fehler."; + statusEl.className = "form-status show error"; + } +}); + +/* ---------------- Code anzeigen (Re-Auth) ---------------- */ +let revealTargetId = null; +function openRevealModal(id) { + revealTargetId = id; + $("reveal-form").reset(); + $("reveal-code-box").style.display = "none"; + $("reveal-status").className = "form-status"; + $("reveal-modal").classList.add("open"); +} +$("reveal-modal-close").addEventListener("click", () => $("reveal-modal").classList.remove("open")); +$("reveal-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const res = await apiCall(API_BASE_URL, "/admin/users/reveal-code", { + id: revealTargetId, + reAuthCode: $("reveal-reauth").value, + }); + if (res.ok) { + $("reveal-code-box").style.display = "block"; + $("reveal-code-box").textContent = res.code; + $("reveal-status").className = "form-status"; + } else { + $("reveal-status").textContent = res.error || "Fehler."; + $("reveal-status").className = "form-status show error"; + } +}); + +/* ---------------- Individuelle Rechte ---------------- */ +let permTargetId = null; +function openPermModal(id) { + permTargetId = id; + const user = currentUsers.find((u) => u.id === id); + const role = currentRoles.find((r) => r.id === user?.roleId); + const roleDefaults = role ? role.permissions : []; + + $("perm-matrix").innerHTML = PERMISSION_GROUPS.map( + (g) => ` +
${g.label}
+ + ${g.keys + .map((k) => { + const inRole = roleDefaults.includes(k); + return ` + + + + `; + }) + .join("")} +
${PERMISSION_LABELS[k]} ${inRole ? "(in Rolle enthalten)" : ""} + +
` + ).join(""); + + $("perm-status").className = "form-status"; + $("perm-modal").classList.add("open"); +} +$("perm-modal-close").addEventListener("click", () => $("perm-modal").classList.remove("open")); +$("perm-save-btn").addEventListener("click", async () => { + const overrides = {}; + document.querySelectorAll("#perm-matrix select[data-perm]").forEach((sel) => { + if (sel.value === "allow") overrides[sel.dataset.perm] = true; + else if (sel.value === "deny") overrides[sel.dataset.perm] = false; + }); + const res = await apiCall(API_BASE_URL, "/admin/users/set-permissions", { id: permTargetId, overrides }); + if (res.ok) { + $("perm-modal").classList.remove("open"); + } else { + $("perm-status").textContent = res.error || "Fehler."; + $("perm-status").className = "form-status show error"; + } +}); + +/* ---------------- Rollen ---------------- */ +async function loadRoles() { + const res = await apiCall(API_BASE_URL, "/admin/roles/list", {}); + if (!res.ok) return; + currentRoles = res.roles; + renderRoles(); +} + +function renderRoles() { + const session = getSession(); + const canManage = session.isOwner || sessionHasPermission(session, "TEAM_CREATE_ROLE") || sessionHasPermission(session, "TEAM_EDIT_PERMISSIONS"); + const container = $("roles-list"); + if (!currentRoles.length) { + container.innerHTML = `

Noch keine Rollen erstellt.

`; + return; + } + container.innerHTML = currentRoles + .map( + (r) => ` +
+

${r.name}

+

${r.permissions.length} Rechte aktiv

+ ${ + canManage + ? `
+ + +
` + : "" + } +
` + ) + .join(""); + + container.querySelectorAll(".edit-role-btn").forEach((b) => b.addEventListener("click", () => openRoleModal(b.dataset.id))); + container.querySelectorAll(".delete-role-btn").forEach((b) => + b.addEventListener("click", async () => { + if (!confirm("Rolle wirklich löschen?")) return; + const res = await apiCall(API_BASE_URL, "/admin/roles/delete", { id: b.dataset.id }); + if (res.ok) loadRoles(); + else alert(res.error || "Fehler."); + }) + ); +} + +function openRoleModal(id) { + const role = id ? currentRoles.find((r) => r.id === id) : null; + $("role-modal-title").textContent = role ? "Rolle bearbeiten" : "Neue Rolle"; + $("rf-id").value = id || ""; + $("rf-name").value = role ? role.name : ""; + $("role-form-status").className = "form-status"; + + $("role-perm-matrix").innerHTML = PERMISSION_GROUPS.map( + (g) => ` +
${g.label}
+ ${g.keys + .map( + (k) => ` + ` + ) + .join("")}` + ).join(""); + + $("role-modal").classList.add("open"); +} +$("new-role-btn").addEventListener("click", () => openRoleModal(null)); +$("role-modal-close").addEventListener("click", () => $("role-modal").classList.remove("open")); +$("role-save-btn").addEventListener("click", async () => { + const id = $("rf-id").value || null; + const name = $("rf-name").value.trim(); + if (!name) return; + const permissions = [...document.querySelectorAll("#role-perm-matrix input[data-perm]:checked")].map((el) => el.dataset.perm); + + const res = await apiCall(API_BASE_URL, id ? "/admin/roles/update" : "/admin/roles/create", id ? { id, name, permissions } : { name, permissions }); + if (res.ok) { + $("role-modal").classList.remove("open"); + loadRoles(); + } else { + $("role-form-status").textContent = res.error || "Fehler."; + $("role-form-status").className = "form-status show error"; + } +}); + +/* ---------------- Aktivitätsprotokoll ---------------- */ +async function loadAudit() { + const res = await apiCall(API_BASE_URL, "/admin/audit/list", { limit: 150 }); + if (!res.ok) return; + $("audit-list").innerHTML = + res.entries + .map( + (e) => ` +
+ ${e.actor} — ${e.action}${e.target ? ` (${e.target})` : ""} + ${new Date(e.created_at).toLocaleString("de-DE")} +
` + ) + .join("") || `

Noch keine Einträge.

`; +} + +/* ---------------- Login / Logout ---------------- */ +document.addEventListener("DOMContentLoaded", () => { + setupTabs(); + + const existing = getSession(); + if (existing) showApp(existing); + else showGate(); + + $("code-gate").addEventListener("submit", async (e) => { + e.preventDefault(); + const status = $("gate-status"); + status.className = "form-status show"; + status.textContent = "Prüfe Code …"; + const res = await doLogin(API_BASE_URL, $("code-input").value); + if (res.ok) { + status.className = "form-status"; + showApp(getSession()); + } else { + status.textContent = res.error || "Falscher Code."; + status.className = "form-status show error"; + } + }); + + $("logout-btn").addEventListener("click", async () => { + await doLogout(API_BASE_URL); + showGate(); + }); + + // Modals per Klick auf Backdrop schließen + document.querySelectorAll(".modal-backdrop").forEach((m) => { + m.addEventListener("click", (e) => { + if (e.target === m) m.classList.remove("open"); + }); + }); +}); diff --git a/cloudflare-worker/migrations/0001_init.sql b/cloudflare-worker/migrations/0001_init.sql new file mode 100644 index 0000000..954f34f --- /dev/null +++ b/cloudflare-worker/migrations/0001_init.sql @@ -0,0 +1,106 @@ +-- ===================================================================== +-- DOGFATHER UNIVERSE — Interner Bereich: Datenbankschema +-- Setzt "Geschuetzter_Zugang_Rollen_Benutzerverwaltung.pdf" um. +-- ===================================================================== + +-- Rollen: frei erstellbar/umbenennbar durch den Hauptadministrator (Owner). +-- Die Owner-Rolle selbst ist NICHT in dieser Tabelle — sie ist fest an das +-- POSTFACH_CODE-Secret gebunden und kann nicht gelöscht/verändert werden. +CREATE TABLE IF NOT EXISTS roles ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + permissions TEXT NOT NULL DEFAULT '[]', -- JSON-Array erlaubter Permission-Keys + created_at TEXT NOT NULL +); + +-- Zugänge/Teammitglieder. Jede Person hat einen eigenen Code. +-- code_hash: SHA-256 für schnellen Login-Vergleich (nicht umkehrbar). +-- code_enc: AES-GCM verschlüsselter Klartext-Code, NUR vom Owner nach +-- Re-Auth entschlüsselbar (siehe crypto.js). Wird niemals an +-- andere Personen oder über die API an Nicht-Owner ausgegeben. +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + username TEXT NOT NULL UNIQUE, + email TEXT, + role_id TEXT REFERENCES roles(id), + code_hash TEXT, + code_enc TEXT, + status TEXT NOT NULL DEFAULT 'pending', -- pending | active | locked + note TEXT, + expires_at TEXT, -- NULL = dauerhaft + created_at TEXT NOT NULL, + created_by TEXT NOT NULL, + last_login_at TEXT +); + +-- Individuelle Berechtigungs-Ausnahmen pro Person (haben Vorrang vor der Rolle). +CREATE TABLE IF NOT EXISTS user_permission_overrides ( + user_id TEXT NOT NULL REFERENCES users(id), + permission TEXT NOT NULL, + allowed INTEGER NOT NULL, -- 1 = zusätzlich erlaubt, 0 = entzogen + PRIMARY KEY (user_id, permission) +); + +-- Sessions: token wird gehasht gespeichert (wie ein Passwort), nie im Klartext. +CREATE TABLE IF NOT EXISTS sessions ( + token_hash TEXT PRIMARY KEY, + user_id TEXT NOT NULL, -- 'owner' oder users.id + is_owner INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + last_active_at TEXT NOT NULL, + expires_at TEXT NOT NULL +); + +-- Aktivitätsprotokoll — Codes erscheinen hier NIEMALS im Klartext. +CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor TEXT NOT NULL, + action TEXT NOT NULL, + target TEXT, + detail TEXT, + created_at TEXT NOT NULL +); + +-- Login-Schutz: Fehlversuche pro Schlüssel (IP oder Benutzername) zählen, +-- nach zu vielen Versuchen vorübergehend sperren. +CREATE TABLE IF NOT EXISTS login_attempts ( + key TEXT PRIMARY KEY, + count INTEGER NOT NULL DEFAULT 0, + locked_until TEXT, + updated_at TEXT NOT NULL +); + +-- Bewerbungen (bisher in KV, jetzt in D1 für Status/Zuweisung/Notizen/Entwürfe). +CREATE TABLE IF NOT EXISTS applications ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, -- creator | scout | kooperation + data TEXT NOT NULL, -- JSON der Formularfelder + status TEXT NOT NULL DEFAULT 'neu', -- neu | in_bearbeitung | beantwortet + assigned_to TEXT, -- users.id oder 'owner' + archived INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS application_notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + application_id TEXT NOT NULL REFERENCES applications(id), + author TEXT NOT NULL, + text TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS application_drafts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + application_id TEXT NOT NULL REFERENCES applications(id), + author TEXT NOT NULL, + content TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at); +CREATE INDEX IF NOT EXISTS idx_applications_status ON applications(status, archived); +CREATE INDEX IF NOT EXISTS idx_notes_app ON application_notes(application_id); +CREATE INDEX IF NOT EXISTS idx_drafts_app ON application_drafts(application_id); diff --git a/cloudflare-worker/src/lib/audit.js b/cloudflare-worker/src/lib/audit.js new file mode 100644 index 0000000..97a11e7 --- /dev/null +++ b/cloudflare-worker/src/lib/audit.js @@ -0,0 +1,15 @@ +/* ===================================================================== + audit.js — Aktivitätsprotokoll + WICHTIG: Codes dürfen hier NIEMALS im Klartext (oder auch nur + teilweise) auftauchen. `detail` ist für zusätzlichen, unkritischen + Kontext gedacht (z.B. "role: rechte-hand"), niemals für Geheimnisse. + ===================================================================== */ +import { nowIso } from "./crypto.js"; + +export async function logAction(env, actor, action, target, detail) { + await env.DB.prepare( + `INSERT INTO audit_log (actor, action, target, detail, created_at) VALUES (?, ?, ?, ?, ?)` + ) + .bind(actor, action, target || null, detail ? JSON.stringify(detail) : null, nowIso()) + .run(); +} diff --git a/cloudflare-worker/src/lib/auth.js b/cloudflare-worker/src/lib/auth.js new file mode 100644 index 0000000..bb04478 --- /dev/null +++ b/cloudflare-worker/src/lib/auth.js @@ -0,0 +1,161 @@ +/* ===================================================================== + auth.js — Login, Sessions, Rate-Limiting/Lockout + + Sicherheitsmodell: + - Owner-Code kommt aus dem POSTFACH_CODE-Secret (Wrangler), Vergleich + per Konstant-Zeit-String-Vergleich. + - Team-Codes: SHA-256-Hash-Lookup (users.code_hash). + - Nach 5 Fehlversuchen pro Client (IP-basiert) 15 Minuten Sperre — + angelehnt an Cloudflares "Protect your login"-Standardregel. + - Sessions: Token wird nur gehasht gespeichert. Inaktivitäts-Timeout + 30 Minuten, absolute Höchstdauer 12 Stunden. + ===================================================================== */ +import { sha256Hex, generateSessionToken, nowIso } from "./crypto.js"; +import { logAction } from "./audit.js"; + +const MAX_ATTEMPTS = 5; +const LOCKOUT_MINUTES = 15; +const INACTIVITY_MS = 30 * 60 * 1000; +const SESSION_MAX_AGE_MS = 12 * 60 * 60 * 1000; + +export function clientKey(request) { + return request.headers.get("CF-Connecting-IP") || "unknown"; +} + +function timingSafeEqual(a, b) { + if (a.length !== b.length) return false; + let mismatch = 0; + for (let i = 0; i < a.length; i++) mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i); + return mismatch === 0; +} + +export async function isLockedOut(env, key) { + const row = await env.DB.prepare(`SELECT locked_until FROM login_attempts WHERE key = ?`) + .bind(key) + .first(); + if (!row || !row.locked_until) return false; + return new Date(row.locked_until) > new Date(); +} + +export async function recordFailedAttempt(env, key) { + const row = await env.DB.prepare(`SELECT count FROM login_attempts WHERE key = ?`).bind(key).first(); + const count = (row?.count || 0) + 1; + let lockedUntil = null; + if (count >= MAX_ATTEMPTS) { + lockedUntil = new Date(Date.now() + LOCKOUT_MINUTES * 60 * 1000).toISOString(); + } + await env.DB.prepare( + `INSERT INTO login_attempts (key, count, locked_until, updated_at) VALUES (?, ?, ?, ?) + ON CONFLICT(key) DO UPDATE SET count = ?, locked_until = ?, updated_at = ?` + ) + .bind(key, count, lockedUntil, nowIso(), count, lockedUntil, nowIso()) + .run(); + return { count, lockedUntil }; +} + +export async function clearAttempts(env, key) { + await env.DB.prepare(`DELETE FROM login_attempts WHERE key = ?`).bind(key).run(); +} + +/** Prüft einen eingegebenen Code gegen Owner-Secret und Team-Datenbank. */ +export async function verifyCode(env, code) { + if (env.POSTFACH_CODE && timingSafeEqual(code, env.POSTFACH_CODE)) { + return { ok: true, isOwner: true }; + } + const hash = await sha256Hex(code); + const user = await env.DB.prepare( + `SELECT id, name, username, role_id, status, expires_at FROM users WHERE code_hash = ?` + ) + .bind(hash) + .first(); + if (!user) return { ok: false }; + if (user.status !== "active") return { ok: false, reason: "locked" }; + if (user.expires_at && new Date(user.expires_at) < new Date()) return { ok: false, reason: "expired" }; + return { ok: true, isOwner: false, user }; +} + +export async function createSession(env, { isOwner, userId }) { + const token = generateSessionToken(); + const tokenHash = await sha256Hex(token); + const now = new Date(); + const expiresAt = new Date(now.getTime() + SESSION_MAX_AGE_MS).toISOString(); + await env.DB.prepare( + `INSERT INTO sessions (token_hash, user_id, is_owner, created_at, last_active_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)` + ) + .bind(tokenHash, isOwner ? "owner" : userId, isOwner ? 1 : 0, now.toISOString(), now.toISOString(), expiresAt) + .run(); + return token; +} + +/** Löst ein Session-Token auf, prüft Ablauf/Inaktivität, aktualisiert last_active_at. */ +export async function resolveSession(env, token) { + if (!token) return null; + const tokenHash = await sha256Hex(token); + const session = await env.DB.prepare( + `SELECT * FROM sessions WHERE token_hash = ?` + ) + .bind(tokenHash) + .first(); + if (!session) return null; + + const now = Date.now(); + if (new Date(session.expires_at).getTime() < now) { + await env.DB.prepare(`DELETE FROM sessions WHERE token_hash = ?`).bind(tokenHash).run(); + return null; + } + if (now - new Date(session.last_active_at).getTime() > INACTIVITY_MS) { + await env.DB.prepare(`DELETE FROM sessions WHERE token_hash = ?`).bind(tokenHash).run(); + return null; + } + + await env.DB.prepare(`UPDATE sessions SET last_active_at = ? WHERE token_hash = ?`) + .bind(new Date().toISOString(), tokenHash) + .run(); + + if (session.is_owner) { + return { isOwner: true, actor: "owner" }; + } + + const user = await env.DB.prepare( + `SELECT u.id, u.name, u.username, u.role_id, u.status, r.name as role_name, r.permissions + FROM users u LEFT JOIN roles r ON r.id = u.role_id WHERE u.id = ?` + ) + .bind(session.user_id) + .first(); + if (!user || user.status !== "active") return null; + + const overridesRows = await env.DB.prepare( + `SELECT permission, allowed FROM user_permission_overrides WHERE user_id = ?` + ) + .bind(user.id) + .all(); + const overrides = {}; + for (const r of overridesRows.results || []) overrides[r.permission] = !!r.allowed; + + return { + isOwner: false, + actor: user.username, + userId: user.id, + name: user.name, + roleId: user.role_id, + roleName: user.role_name, + permissions: user.permissions ? JSON.parse(user.permissions) : [], + overrides, + }; +} + +export async function endSession(env, token) { + if (!token) return; + const tokenHash = await sha256Hex(token); + await env.DB.prepare(`DELETE FROM sessions WHERE token_hash = ?`).bind(tokenHash).run(); +} + +export async function endAllSessionsForUser(env, userId) { + await env.DB.prepare(`DELETE FROM sessions WHERE user_id = ?`).bind(userId).run(); +} + +export async function requireSession(request, env) { + const auth = request.headers.get("Authorization") || ""; + const token = auth.startsWith("Bearer ") ? auth.slice(7) : null; + return resolveSession(env, token); +} diff --git a/cloudflare-worker/src/lib/crypto.js b/cloudflare-worker/src/lib/crypto.js new file mode 100644 index 0000000..8a728dc --- /dev/null +++ b/cloudflare-worker/src/lib/crypto.js @@ -0,0 +1,78 @@ +/* ===================================================================== + crypto.js — Verschlüsselung & Hashing für das Zugangssystem + + Codes (Zugangscodes von Teammitgliedern) brauchen ZWEI Repräsentationen: + 1) code_hash — SHA-256, nicht umkehrbar. Für den schnellen Login- + Vergleich ("stimmt der eingegebene Code mit dem gespeicherten + überein?"). Kann NIE zurück in den Klartext-Code verwandelt werden. + 2) code_enc — AES-256-GCM, umkehrbar. Nur damit kann der + Hauptadministrator sich den Klartext-Code später wieder anzeigen + lassen ("Code anzeigen"-Funktion aus dem Anforderungsdokument). + Der Schlüssel dafür liegt NUR im ENCRYPTION_KEY-Secret (nie im + Quelltext, nie in der Datenbank). + + Session-Tokens werden nur GEHASHT in der Datenbank gespeichert (wie ein + Passwort) — der Klartext-Token existiert nur kurz beim Login-Response + und im Browser des jeweiligen Geräts. + ===================================================================== */ + +const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // ohne I,O,0,1 + +export function generateFriendlyCode() { + const bytes = crypto.getRandomValues(new Uint8Array(12)); + let raw = ""; + for (const b of bytes) raw += CODE_ALPHABET[b % CODE_ALPHABET.length]; + return `${raw.slice(0, 4)}-${raw.slice(4, 8)}-${raw.slice(8, 12)}`; +} + +export function generateSessionToken() { + const bytes = crypto.getRandomValues(new Uint8Array(32)); + return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +export function generateId() { + return crypto.randomUUID(); +} + +export async function sha256Hex(text) { + const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)); + return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +async function getAesKey(env) { + const raw = Uint8Array.from(atob(env.ENCRYPTION_KEY), (c) => c.charCodeAt(0)); + return crypto.subtle.importKey("raw", raw, "AES-GCM", false, ["encrypt", "decrypt"]); +} + +function bufToBase64(buf) { + return btoa(String.fromCharCode(...new Uint8Array(buf))); +} +function base64ToBuf(b64) { + return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); +} + +/** Verschlüsselt einen Klartext-Code reversibel. Rückgabe: "iv.ciphertext" (base64). */ +export async function encryptCode(env, plaintext) { + const key = await getAesKey(env); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = await crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + key, + new TextEncoder().encode(plaintext) + ); + return `${bufToBase64(iv)}.${bufToBase64(ciphertext)}`; +} + +/** Entschlüsselt einen mit encryptCode() erzeugten Wert. Nur intern für den Owner-Reveal-Flow nutzen. */ +export async function decryptCode(env, encoded) { + const [ivB64, ctB64] = encoded.split("."); + const key = await getAesKey(env); + const iv = base64ToBuf(ivB64); + const ciphertext = base64ToBuf(ctB64); + const plainBuf = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext); + return new TextDecoder().decode(plainBuf); +} + +export function nowIso() { + return new Date().toISOString(); +} diff --git a/cloudflare-worker/src/lib/http.js b/cloudflare-worker/src/lib/http.js new file mode 100644 index 0000000..45a2f97 --- /dev/null +++ b/cloudflare-worker/src/lib/http.js @@ -0,0 +1,19 @@ +/* ===================================================================== + http.js — gemeinsame HTTP-Helfer (CORS, JSON-Responses) + ===================================================================== */ + +export function corsHeaders() { + // TODO: sobald die Domain final steht, statt "*" die echte Domain eintragen. + return { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + }; +} + +export function json(data, status = 200) { + return new Response(JSON.stringify(data), { + status, + headers: { "Content-Type": "application/json", ...corsHeaders() }, + }); +} diff --git a/cloudflare-worker/src/lib/permissions.js b/cloudflare-worker/src/lib/permissions.js new file mode 100644 index 0000000..2b047ef --- /dev/null +++ b/cloudflare-worker/src/lib/permissions.js @@ -0,0 +1,96 @@ +/* ===================================================================== + permissions.js — Vollständiger Permission-Katalog + + Direkt aus dem Anforderungsdokument "Geschützter Zugang, Rollen und + Benutzerverwaltung" übernommen. Diese Keys sind die einzigen, die in + Rollen oder Personen-Ausnahmen aktiviert werden dürfen. + + OWNER_ONLY_ACTIONS sind bewusst NICHT Teil von PERMISSIONS — sie + erscheinen nirgends in der Rechteverwaltung und können über keinen + Schalter an irgendeine Rolle oder Person vergeben werden. Jeder Ort im + Code, der eine dieser Aktionen ausführt, muss den Owner direkt prüfen + (isOwner === true), NIE eine Permission. + ===================================================================== */ + +export const PERMISSIONS = { + // Bewerbungsbereiche + APPLICATIONS_VIEW_CREATOR: "Creator-Bewerbungen ansehen", + APPLICATIONS_VIEW_MANAGER_SCOUT: "Manager-/Scout-Bewerbungen ansehen", + APPLICATIONS_VIEW_ARCHIVED: "Archivierte Bewerbungen ansehen", + APPLICATIONS_OPEN_FULL: "Vollständige Bewerbungen öffnen", + + // Bearbeitung + APPLICATIONS_TAKE_OVER: "Bewerbungen übernehmen", + APPLICATIONS_ASSIGN: "Bewerbungen anderen Personen zuweisen", + APPLICATIONS_CHANGE_STATUS: "Status verändern", + APPLICATIONS_ARCHIVE: "Bewerbungen archivieren", + APPLICATIONS_RESTORE: "Bewerbungen wiederherstellen", + APPLICATIONS_DELETE: "Bewerbungen löschen", + + // Antworten + DRAFTS_CREATE: "Antwortentwürfe erstellen", + DRAFTS_EDIT_OWN: "Eigene Entwürfe bearbeiten", + DRAFTS_EDIT_OTHERS: "Entwürfe anderer Personen bearbeiten", + REPLIES_COPY: "Fertige Antworten kopieren", + EMAILS_COPY: "E-Mail-Adressen kopieren", + APPLICATIONS_MARK_ANSWERED: "Bewerbungen als beantwortet markieren", + + // Interne Notizen + NOTES_READ: "Notizen lesen", + NOTES_WRITE: "Notizen schreiben", + NOTES_EDIT_OWN: "Eigene Notizen bearbeiten", + NOTES_EDIT_OTHERS: "Fremde Notizen bearbeiten", + NOTES_DELETE: "Notizen löschen", + + // Teamverwaltung + TEAM_VIEW: "Andere Teammitglieder ansehen", + TEAM_PREPARE_USER: "Neue Benutzer vorbereiten", + TEAM_LOCK_USER: "Benutzer sperren", + TEAM_ASSIGN_ROLE: "Rollen vergeben", + TEAM_CREATE_ROLE: "Rollen erstellen", + TEAM_EDIT_PERMISSIONS: "Allgemeine Berechtigungen verändern", + + // Einstellungen + SETTINGS_EDIT_FORMS: "Bewerbungsformulare bearbeiten", + SETTINGS_EDIT_STATUS_TYPES: "Statusarten verändern", + SETTINGS_VIEW_STATS: "Statistiken ansehen", + SETTINGS_VIEW_ACTIVITY_LOG: "Aktivitätsverlauf ansehen", + SETTINGS_EDIT_GENERAL: "Allgemeine Einstellungen bearbeiten", +}; + +export const PERMISSION_GROUPS = [ + { label: "Bewerbungsbereiche", keys: ["APPLICATIONS_VIEW_CREATOR", "APPLICATIONS_VIEW_MANAGER_SCOUT", "APPLICATIONS_VIEW_ARCHIVED", "APPLICATIONS_OPEN_FULL"] }, + { label: "Bearbeitung", keys: ["APPLICATIONS_TAKE_OVER", "APPLICATIONS_ASSIGN", "APPLICATIONS_CHANGE_STATUS", "APPLICATIONS_ARCHIVE", "APPLICATIONS_RESTORE", "APPLICATIONS_DELETE"] }, + { label: "Antworten", keys: ["DRAFTS_CREATE", "DRAFTS_EDIT_OWN", "DRAFTS_EDIT_OTHERS", "REPLIES_COPY", "EMAILS_COPY", "APPLICATIONS_MARK_ANSWERED"] }, + { label: "Interne Notizen", keys: ["NOTES_READ", "NOTES_WRITE", "NOTES_EDIT_OWN", "NOTES_EDIT_OTHERS", "NOTES_DELETE"] }, + { label: "Teamverwaltung", keys: ["TEAM_VIEW", "TEAM_PREPARE_USER", "TEAM_LOCK_USER", "TEAM_ASSIGN_ROLE", "TEAM_CREATE_ROLE", "TEAM_EDIT_PERMISSIONS"] }, + { label: "Einstellungen", keys: ["SETTINGS_EDIT_FORMS", "SETTINGS_EDIT_STATUS_TYPES", "SETTINGS_VIEW_STATS", "SETTINGS_VIEW_ACTIVITY_LOG", "SETTINGS_EDIT_GENERAL"] }, +]; + +// Nur zur Dokumentation/Klarheit — diese Aktionen sind NIE als Permission +// wählbar und werden im Code immer direkt über isOwner geprüft: +export const OWNER_ONLY_ACTIONS = [ + "CODE_VIEW", + "CODE_COPY", + "CODE_CREATE", + "CODE_EDIT", + "CODE_RESET", + "EDIT_OWNER_ACCOUNT", + "DELETE_OR_LIMIT_OWNER_ROLE", + "PROMOTE_TO_OWNER", +]; + +/** + * Prüft, ob eine Person (Rolle + individuelle Ausnahmen) ein Recht hat. + * Individuelle Ausnahmen (overrides) haben immer Vorrang vor der Rolle. + */ +export function hasPermission(rolePermissions, overrides, key) { + if (overrides && Object.prototype.hasOwnProperty.call(overrides, key)) { + return !!overrides[key]; + } + return Array.isArray(rolePermissions) && rolePermissions.includes(key); +} + +export function isValidPermissionKey(key) { + return Object.prototype.hasOwnProperty.call(PERMISSIONS, key); +} diff --git a/cloudflare-worker/src/routes/applications.js b/cloudflare-worker/src/routes/applications.js new file mode 100644 index 0000000..71e9349 --- /dev/null +++ b/cloudflare-worker/src/routes/applications.js @@ -0,0 +1,218 @@ +/* ===================================================================== + routes/applications.js — Bewerbungen (Postfach), jetzt in D1 mit + vollem Rollen-/Rechte-Modell statt dem alten pauschalen Owner/Helfer- + Zugriff. Status, Zuweisung, Notizen und Antwortentwürfe pro Bewerbung. + ===================================================================== */ +import { generateId, nowIso } from "../lib/crypto.js"; +import { hasPermission } from "../lib/permissions.js"; +import { logAction } from "../lib/audit.js"; +import { json } from "../lib/http.js"; + +const ALLOWED_TYPES = ["creator", "scout", "kooperation"]; + +function can(session, key) { + if (!session) return false; + if (session.isOwner) return true; + return hasPermission(session.permissions, session.overrides, key); +} + +function viewPermissionForType(type) { + return type === "creator" ? "APPLICATIONS_VIEW_CREATOR" : "APPLICATIONS_VIEW_MANAGER_SCOUT"; +} + +export async function submitApplication(request, env) { + let body; + try { + body = await request.json(); + } catch { + return json({ ok: false, error: "Ungültiges JSON." }, 400); + } + const { type, data } = body || {}; + if (!ALLOWED_TYPES.includes(type) || typeof data !== "object" || !data) { + return json({ ok: false, error: "Ungültige Bewerbungsdaten." }, 400); + } + const values = Object.values(data).map((v) => String(v || "").trim()); + if (values.every((v) => v === "")) return json({ ok: false, error: "Leere Bewerbung." }, 400); + + const id = generateId(); + await env.DB.prepare( + `INSERT INTO applications (id, type, data, status, archived, created_at) VALUES (?, ?, ?, 'neu', 0, ?)` + ) + .bind(id, type, JSON.stringify(data), nowIso()) + .run(); + + return json({ ok: true }); +} + +export async function listApplications(request, env, session) { + if (!session) return json({ ok: false, error: "Nicht angemeldet." }, 401); + const body = await request.json().catch(() => ({})); + const includeArchived = !!body.includeArchived; + + const allowedTypes = ALLOWED_TYPES.filter((t) => session.isOwner || can(session, viewPermissionForType(t))); + if (allowedTypes.length === 0) return json({ ok: true, applications: [] }); + if (includeArchived && !session.isOwner && !can(session, "APPLICATIONS_VIEW_ARCHIVED")) { + return json({ ok: false, error: "Keine Berechtigung für archivierte Bewerbungen." }, 403); + } + + const placeholders = allowedTypes.map(() => "?").join(","); + const archivedClause = includeArchived ? "" : "AND archived = 0"; + const rows = await env.DB.prepare( + `SELECT * FROM applications WHERE type IN (${placeholders}) ${archivedClause} ORDER BY created_at DESC LIMIT 300` + ) + .bind(...allowedTypes) + .all(); + + const applications = (rows.results || []).map((r) => ({ + id: r.id, + type: r.type, + data: session.isOwner || can(session, "APPLICATIONS_OPEN_FULL") ? JSON.parse(r.data) : null, + status: r.status, + assignedTo: r.assigned_to, + archived: !!r.archived, + createdAt: r.created_at, + })); + + return json({ ok: true, applications }); +} + +export async function changeApplicationStatus(request, env, session) { + if (!can(session, "APPLICATIONS_CHANGE_STATUS")) return json({ ok: false, error: "Keine Berechtigung." }, 403); + const body = await request.json().catch(() => ({})); + const id = String(body.id || ""); + const status = String(body.status || "").slice(0, 40); + await env.DB.prepare(`UPDATE applications SET status = ? WHERE id = ?`).bind(status, id).run(); + await logAction(env, session.actor, "application.status", id, { status }); + return json({ ok: true }); +} + +export async function takeOverApplication(request, env, session) { + if (!can(session, "APPLICATIONS_TAKE_OVER")) return json({ ok: false, error: "Keine Berechtigung." }, 403); + const body = await request.json().catch(() => ({})); + const id = String(body.id || ""); + const who = session.isOwner ? "owner" : session.userId; + await env.DB.prepare(`UPDATE applications SET assigned_to = ? WHERE id = ?`).bind(who, id).run(); + await logAction(env, session.actor, "application.take_over", id, null); + return json({ ok: true }); +} + +export async function assignApplication(request, env, session) { + if (!can(session, "APPLICATIONS_ASSIGN")) return json({ ok: false, error: "Keine Berechtigung." }, 403); + const body = await request.json().catch(() => ({})); + const id = String(body.id || ""); + const assignedTo = body.assignedTo ? String(body.assignedTo) : null; + await env.DB.prepare(`UPDATE applications SET assigned_to = ? WHERE id = ?`).bind(assignedTo, id).run(); + await logAction(env, session.actor, "application.assign", id, { assignedTo }); + return json({ ok: true }); +} + +export async function archiveApplication(request, env, session) { + if (!can(session, "APPLICATIONS_ARCHIVE")) return json({ ok: false, error: "Keine Berechtigung." }, 403); + const body = await request.json().catch(() => ({})); + const id = String(body.id || ""); + await env.DB.prepare(`UPDATE applications SET archived = 1 WHERE id = ?`).bind(id).run(); + await logAction(env, session.actor, "application.archive", id, null); + return json({ ok: true }); +} + +export async function restoreApplication(request, env, session) { + if (!can(session, "APPLICATIONS_RESTORE")) return json({ ok: false, error: "Keine Berechtigung." }, 403); + const body = await request.json().catch(() => ({})); + const id = String(body.id || ""); + await env.DB.prepare(`UPDATE applications SET archived = 0 WHERE id = ?`).bind(id).run(); + await logAction(env, session.actor, "application.restore", id, null); + return json({ ok: true }); +} + +export async function deleteApplication(request, env, session) { + if (!can(session, "APPLICATIONS_DELETE")) return json({ ok: false, error: "Keine Berechtigung." }, 403); + const body = await request.json().catch(() => ({})); + const id = String(body.id || ""); + await env.DB.prepare(`DELETE FROM application_notes WHERE application_id = ?`).bind(id).run(); + await env.DB.prepare(`DELETE FROM application_drafts WHERE application_id = ?`).bind(id).run(); + await env.DB.prepare(`DELETE FROM applications WHERE id = ?`).bind(id).run(); + await logAction(env, session.actor, "application.delete", id, null); + return json({ ok: true }); +} + +export async function markAnswered(request, env, session) { + if (!can(session, "APPLICATIONS_MARK_ANSWERED")) return json({ ok: false, error: "Keine Berechtigung." }, 403); + const body = await request.json().catch(() => ({})); + const id = String(body.id || ""); + await env.DB.prepare(`UPDATE applications SET status = 'beantwortet' WHERE id = ?`).bind(id).run(); + await logAction(env, session.actor, "application.mark_answered", id, null); + return json({ ok: true }); +} + +/* ---- Notizen ---- */ +export async function listNotes(request, env, session) { + if (!can(session, "NOTES_READ")) return json({ ok: false, error: "Keine Berechtigung." }, 403); + const body = await request.json().catch(() => ({})); + const rows = await env.DB.prepare( + `SELECT * FROM application_notes WHERE application_id = ? ORDER BY created_at ASC` + ) + .bind(String(body.applicationId || "")) + .all(); + return json({ ok: true, notes: rows.results || [] }); +} + +export async function addNote(request, env, session) { + if (!can(session, "NOTES_WRITE")) return json({ ok: false, error: "Keine Berechtigung." }, 403); + const body = await request.json().catch(() => ({})); + const text = String(body.text || "").slice(0, 2000); + if (!text.trim()) return json({ ok: false, error: "Leere Notiz." }, 400); + await env.DB.prepare( + `INSERT INTO application_notes (application_id, author, text, created_at) VALUES (?, ?, ?, ?)` + ) + .bind(String(body.applicationId || ""), session.actor, text, nowIso()) + .run(); + await logAction(env, session.actor, "note.add", body.applicationId, null); + return json({ ok: true }); +} + +export async function deleteNote(request, env, session) { + if (!can(session, "NOTES_DELETE")) return json({ ok: false, error: "Keine Berechtigung." }, 403); + const body = await request.json().catch(() => ({})); + await env.DB.prepare(`DELETE FROM application_notes WHERE id = ?`).bind(Number(body.id)).run(); + await logAction(env, session.actor, "note.delete", String(body.id), null); + return json({ ok: true }); +} + +/* ---- Antwortentwürfe ---- */ +export async function listDrafts(request, env, session) { + const body = await request.json().catch(() => ({})); + const rows = await env.DB.prepare( + `SELECT * FROM application_drafts WHERE application_id = ? ORDER BY updated_at DESC` + ) + .bind(String(body.applicationId || "")) + .all(); + return json({ ok: true, drafts: rows.results || [] }); +} + +export async function saveDraft(request, env, session) { + const body = await request.json().catch(() => ({})); + const content = String(body.content || "").slice(0, 5000); + + if (body.id) { + const existing = await env.DB.prepare(`SELECT * FROM application_drafts WHERE id = ?`).bind(Number(body.id)).first(); + if (!existing) return json({ ok: false, error: "Entwurf nicht gefunden." }, 404); + const isOwnDraft = existing.author === session.actor; + if (!can(session, isOwnDraft ? "DRAFTS_EDIT_OWN" : "DRAFTS_EDIT_OTHERS")) { + return json({ ok: false, error: "Keine Berechtigung." }, 403); + } + await env.DB.prepare(`UPDATE application_drafts SET content = ?, updated_at = ? WHERE id = ?`) + .bind(content, nowIso(), Number(body.id)) + .run(); + await logAction(env, session.actor, "draft.update", String(body.id), null); + return json({ ok: true }); + } + + if (!can(session, "DRAFTS_CREATE")) return json({ ok: false, error: "Keine Berechtigung." }, 403); + await env.DB.prepare( + `INSERT INTO application_drafts (application_id, author, content, created_at, updated_at) VALUES (?, ?, ?, ?, ?)` + ) + .bind(String(body.applicationId || ""), session.actor, content, nowIso(), nowIso()) + .run(); + await logAction(env, session.actor, "draft.create", body.applicationId, null); + return json({ ok: true }); +} diff --git a/cloudflare-worker/src/routes/audit.js b/cloudflare-worker/src/routes/audit.js new file mode 100644 index 0000000..4ed467d --- /dev/null +++ b/cloudflare-worker/src/routes/audit.js @@ -0,0 +1,20 @@ +/* ===================================================================== + routes/audit.js — Aktivitätsprotokoll ansehen + ===================================================================== */ +import { hasPermission } from "../lib/permissions.js"; +import { json } from "../lib/http.js"; + +export async function listAuditLog(request, env, session) { + if (!session) return json({ ok: false, error: "Nicht angemeldet." }, 401); + if (!session.isOwner && !hasPermission(session.permissions, session.overrides, "SETTINGS_VIEW_ACTIVITY_LOG")) { + return json({ ok: false, error: "Keine Berechtigung." }, 403); + } + const body = await request.json().catch(() => ({})); + const limit = Math.min(Number(body.limit) || 100, 500); + const rows = await env.DB.prepare( + `SELECT id, actor, action, target, detail, created_at FROM audit_log ORDER BY created_at DESC LIMIT ?` + ) + .bind(limit) + .all(); + return json({ ok: true, entries: rows.results || [] }); +} diff --git a/cloudflare-worker/src/routes/auth.js b/cloudflare-worker/src/routes/auth.js new file mode 100644 index 0000000..c6daca8 --- /dev/null +++ b/cloudflare-worker/src/routes/auth.js @@ -0,0 +1,69 @@ +/* ===================================================================== + routes/auth.js — Login / Logout + ===================================================================== */ +import { verifyCode, createSession, endSession, isLockedOut, recordFailedAttempt, clearAttempts, clientKey } from "../lib/auth.js"; +import { logAction } from "../lib/audit.js"; +import { nowIso } from "../lib/crypto.js"; +import { json } from "../lib/http.js"; + +export async function login(request, env) { + const body = await request.json().catch(() => ({})); + const code = String(body.code || ""); + const key = `login:${clientKey(request)}`; + + if (await isLockedOut(env, key)) { + return json({ ok: false, error: "Zu viele Fehlversuche. Bitte in ein paar Minuten erneut versuchen." }, 429); + } + + const result = await verifyCode(env, code); + if (!result.ok) { + const { count } = await recordFailedAttempt(env, key); + await logAction(env, "anonym", "login.failed", null, { attempt: count }); + if (result.reason === "locked") return json({ ok: false, error: "Dieser Zugang ist gesperrt." }, 403); + if (result.reason === "expired") return json({ ok: false, error: "Dieser Zugang ist abgelaufen." }, 403); + return json({ ok: false, error: "Falscher Code." }, 401); + } + + await clearAttempts(env, key); + + if (result.isOwner) { + const token = await createSession(env, { isOwner: true }); + await logAction(env, "owner", "login.success", null, null); + return json({ ok: true, token, isOwner: true, name: "Dogi", roleName: "Hauptadministrator" }); + } + + const token = await createSession(env, { isOwner: false, userId: result.user.id }); + await env.DB.prepare(`UPDATE users SET last_login_at = ? WHERE id = ?`).bind(nowIso(), result.user.id).run(); + + const roleRow = result.user.role_id + ? await env.DB.prepare(`SELECT name, permissions FROM roles WHERE id = ?`).bind(result.user.role_id).first() + : null; + const overridesRows = await env.DB.prepare( + `SELECT permission, allowed FROM user_permission_overrides WHERE user_id = ?` + ) + .bind(result.user.id) + .all(); + const overrides = {}; + for (const r of overridesRows.results || []) overrides[r.permission] = !!r.allowed; + const permissions = roleRow ? JSON.parse(roleRow.permissions || "[]") : []; + + await logAction(env, result.user.username, "login.success", null, null); + + return json({ + ok: true, + token, + isOwner: false, + name: result.user.name, + username: result.user.username, + roleName: roleRow?.name || "Ohne Rolle", + permissions, + overrides, + }); +} + +export async function logout(request, env) { + const auth = request.headers.get("Authorization") || ""; + const token = auth.startsWith("Bearer ") ? auth.slice(7) : null; + await endSession(env, token); + return json({ ok: true }); +} diff --git a/cloudflare-worker/src/routes/roles.js b/cloudflare-worker/src/routes/roles.js new file mode 100644 index 0000000..f733f48 --- /dev/null +++ b/cloudflare-worker/src/routes/roles.js @@ -0,0 +1,95 @@ +/* ===================================================================== + routes/roles.js — Rollen erstellen/umbenennen/verwalten + + Die Owner-Rolle existiert absichtlich NICHT in dieser Tabelle — sie ist + fest an POSTFACH_CODE gebunden und kann hier weder erstellt, verändert + noch gelöscht werden (siehe Anforderungsdokument: "Meine persönliche + Hauptadministrator-Rolle bleibt davon ausgeschlossen"). + ===================================================================== */ +import { generateId, nowIso } from "../lib/crypto.js"; +import { isValidPermissionKey, hasPermission } from "../lib/permissions.js"; +import { logAction } from "../lib/audit.js"; +import { json } from "../lib/http.js"; + +function canManageRoles(session) { + if (!session) return false; + if (session.isOwner) return true; + return ( + hasPermission(session.permissions, session.overrides, "TEAM_CREATE_ROLE") || + hasPermission(session.permissions, session.overrides, "TEAM_EDIT_PERMISSIONS") + ); +} + +function canViewRoles(session) { + if (!session) return false; + if (session.isOwner) return true; + return ( + canManageRoles(session) || + hasPermission(session.permissions, session.overrides, "TEAM_ASSIGN_ROLE") || + hasPermission(session.permissions, session.overrides, "TEAM_VIEW") + ); +} + +function sanitizePermissions(list) { + if (!Array.isArray(list)) return []; + return [...new Set(list.filter((k) => typeof k === "string" && isValidPermissionKey(k)))]; +} + +export async function listRoles(env, session) { + if (!canViewRoles(session)) return json({ ok: false, error: "Keine Berechtigung." }, 403); + const rows = await env.DB.prepare(`SELECT * FROM roles ORDER BY created_at ASC`).all(); + const roles = (rows.results || []).map((r) => ({ ...r, permissions: JSON.parse(r.permissions || "[]") })); + return json({ ok: true, roles }); +} + +export async function createRole(request, env, session) { + if (!canManageRoles(session)) return json({ ok: false, error: "Keine Berechtigung." }, 403); + const body = await request.json().catch(() => ({})); + const name = String(body.name || "").trim().slice(0, 60); + if (!name) return json({ ok: false, error: "Name fehlt." }, 400); + const permissions = sanitizePermissions(body.permissions); + const id = generateId(); + + await env.DB.prepare(`INSERT INTO roles (id, name, permissions, created_at) VALUES (?, ?, ?, ?)`) + .bind(id, name, JSON.stringify(permissions), nowIso()) + .run(); + + await logAction(env, session.actor, "role.create", id, { name, permissions }); + return json({ ok: true, role: { id, name, permissions } }); +} + +export async function updateRole(request, env, session) { + if (!canManageRoles(session)) return json({ ok: false, error: "Keine Berechtigung." }, 403); + const body = await request.json().catch(() => ({})); + const id = String(body.id || ""); + if (!id) return json({ ok: false, error: "id fehlt." }, 400); + + const existing = await env.DB.prepare(`SELECT * FROM roles WHERE id = ?`).bind(id).first(); + if (!existing) return json({ ok: false, error: "Rolle nicht gefunden." }, 404); + + const name = body.name !== undefined ? String(body.name).trim().slice(0, 60) || existing.name : existing.name; + const permissions = body.permissions !== undefined ? sanitizePermissions(body.permissions) : JSON.parse(existing.permissions || "[]"); + + await env.DB.prepare(`UPDATE roles SET name = ?, permissions = ? WHERE id = ?`) + .bind(name, JSON.stringify(permissions), id) + .run(); + + await logAction(env, session.actor, "role.update", id, { name, permissions }); + return json({ ok: true }); +} + +export async function deleteRole(request, env, session) { + if (!canManageRoles(session)) return json({ ok: false, error: "Keine Berechtigung." }, 403); + const body = await request.json().catch(() => ({})); + const id = String(body.id || ""); + if (!id) return json({ ok: false, error: "id fehlt." }, 400); + + const inUse = await env.DB.prepare(`SELECT COUNT(*) as n FROM users WHERE role_id = ?`).bind(id).first(); + if (inUse && inUse.n > 0) { + return json({ ok: false, error: "Rolle ist noch Personen zugewiesen — erst umziehen." }, 400); + } + + await env.DB.prepare(`DELETE FROM roles WHERE id = ?`).bind(id).run(); + await logAction(env, session.actor, "role.delete", id, null); + return json({ ok: true }); +} diff --git a/cloudflare-worker/src/routes/users.js b/cloudflare-worker/src/routes/users.js new file mode 100644 index 0000000..247b399 --- /dev/null +++ b/cloudflare-worker/src/routes/users.js @@ -0,0 +1,269 @@ +/* ===================================================================== + routes/users.js — Zugänge und Teammitglieder + + Zentrale Regel aus dem Anforderungsdokument, überall durchgesetzt: + AUSSCHLIESSLICH der Owner darf Codes sehen, erstellen, ändern, + kopieren oder zurücksetzen. Keine Rolle und keine individuelle + Berechtigung kann das jemals freischalten — die entsprechenden + Funktionen prüfen `session.isOwner` direkt, nie eine Permission. + ===================================================================== */ +import { generateId, generateFriendlyCode, sha256Hex, encryptCode, decryptCode, nowIso } from "../lib/crypto.js"; +import { hasPermission, isValidPermissionKey } from "../lib/permissions.js"; +import { endAllSessionsForUser, verifyCode } from "../lib/auth.js"; +import { logAction } from "../lib/audit.js"; +import { json } from "../lib/http.js"; + +function can(session, key) { + if (!session) return false; + if (session.isOwner) return true; + return hasPermission(session.permissions, session.overrides, key); +} + +async function loadUser(env, id) { + return env.DB.prepare( + `SELECT u.*, r.name as role_name FROM users u LEFT JOIN roles r ON r.id = u.role_id WHERE u.id = ?` + ) + .bind(id) + .first(); +} + +function publicUser(u) { + return { + id: u.id, + name: u.name, + username: u.username, + email: u.email, + roleId: u.role_id, + roleName: u.role_name, + status: u.status, + note: u.note, + expiresAt: u.expires_at, + createdAt: u.created_at, + createdBy: u.created_by, + lastLoginAt: u.last_login_at, + // Codes erscheinen hier bewusst NIE — auch nicht verschlüsselt/gehasht. + }; +} + +export async function listUsers(env, session) { + if (!can(session, "TEAM_VIEW") && !session?.isOwner) { + return json({ ok: false, error: "Keine Berechtigung." }, 403); + } + const rows = await env.DB.prepare( + `SELECT u.*, r.name as role_name FROM users u LEFT JOIN roles r ON r.id = u.role_id ORDER BY u.created_at DESC` + ).all(); + return json({ ok: true, users: (rows.results || []).map(publicUser) }); +} + +/** Nicht-Owner mit TEAM_PREPARE_USER können einen Entwurf anlegen — noch ohne Code, inaktiv. */ +export async function prepareUser(request, env, session) { + if (!can(session, "TEAM_PREPARE_USER")) return json({ ok: false, error: "Keine Berechtigung." }, 403); + const body = await request.json().catch(() => ({})); + const name = String(body.name || "").trim().slice(0, 80); + const username = String(body.username || "").trim().toLowerCase().slice(0, 40); + if (!name || !username) return json({ ok: false, error: "Name und Benutzername sind Pflicht." }, 400); + + const id = generateId(); + try { + await env.DB.prepare( + `INSERT INTO users (id, name, username, email, role_id, status, note, created_at, created_by) + VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?)` + ) + .bind(id, name, username, String(body.email || "").trim() || null, body.roleId || null, String(body.note || "").slice(0, 500) || null, nowIso(), session.actor) + .run(); + } catch { + return json({ ok: false, error: "Benutzername bereits vergeben." }, 400); + } + + await logAction(env, session.actor, "user.prepare", id, { username }); + return json({ ok: true, id }); +} + +/** NUR Owner: legt einen Zugang final an (neu oder aus einem vorbereiteten Entwurf) inkl. Code. */ +export async function finalizeUser(request, env, session) { + if (!session?.isOwner) return json({ ok: false, error: "Nur der Hauptadministrator darf Zugänge anlegen/aktivieren." }, 403); + const body = await request.json().catch(() => ({})); + + let id = body.pendingUserId ? String(body.pendingUserId) : null; + const name = String(body.name || "").trim().slice(0, 80); + const username = String(body.username || "").trim().toLowerCase().slice(0, 40); + const email = String(body.email || "").trim() || null; + const roleId = body.roleId || null; + const note = String(body.note || "").slice(0, 500) || null; + + if (!id && (!name || !username)) { + return json({ ok: false, error: "Name und Benutzername sind Pflicht." }, 400); + } + + const code = body.code ? String(body.code).trim().slice(0, 40) : generateFriendlyCode(); + const codeHash = await sha256Hex(code); + const codeEnc = await encryptCode(env, code); + + let expiresAt = null; + if (body.expiresAt) expiresAt = new Date(body.expiresAt).toISOString(); + else if (body.expiresInDays) expiresAt = new Date(Date.now() + Number(body.expiresInDays) * 86400000).toISOString(); + + if (id) { + const existing = await loadUser(env, id); + if (!existing) return json({ ok: false, error: "Vorbereiteter Benutzer nicht gefunden." }, 404); + await env.DB.prepare( + `UPDATE users SET name = ?, username = ?, email = ?, role_id = ?, code_hash = ?, code_enc = ?, + status = 'active', note = ?, expires_at = ? WHERE id = ?` + ) + .bind(name || existing.name, username || existing.username, email, roleId, codeHash, codeEnc, note, expiresAt, id) + .run(); + } else { + id = generateId(); + try { + await env.DB.prepare( + `INSERT INTO users (id, name, username, email, role_id, code_hash, code_enc, status, note, expires_at, created_at, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?)` + ) + .bind(id, name, username, email, roleId, codeHash, codeEnc, note, expiresAt, nowIso(), session.actor) + .run(); + } catch { + return json({ ok: false, error: "Benutzername bereits vergeben." }, 400); + } + } + + await logAction(env, session.actor, "user.finalize", id, { username: username || undefined }); + // Der Klartext-Code wird JETZT einmalig zurückgegeben — danach nie wieder abrufbar, + // außer über den geschützten "Code anzeigen"-Reveal-Flow (siehe revealCode()). + return json({ ok: true, id, code }); +} + +/** NUR Owner: neuen Code für bestehenden Benutzer setzen (manuell oder automatisch). */ +export async function setUserCode(request, env, session) { + if (!session?.isOwner) return json({ ok: false, error: "Nur der Hauptadministrator darf Codes ändern." }, 403); + const body = await request.json().catch(() => ({})); + const id = String(body.id || ""); + const user = await loadUser(env, id); + if (!user) return json({ ok: false, error: "Benutzer nicht gefunden." }, 404); + + const code = body.code ? String(body.code).trim().slice(0, 40) : generateFriendlyCode(); + const codeHash = await sha256Hex(code); + const codeEnc = await encryptCode(env, code); + + await env.DB.prepare(`UPDATE users SET code_hash = ?, code_enc = ? WHERE id = ?`) + .bind(codeHash, codeEnc, id) + .run(); + await endAllSessionsForUser(env, id); // alter Code darf auf keinem Gerät mehr gültig bleiben + + await logAction(env, session.actor, "user.code_reset", id, null); + return json({ ok: true, code }); +} + +/** NUR Owner, mit erneuter Re-Auth (ownerCode): zeigt einen bestehenden Code im Klartext. */ +export async function revealUserCode(request, env, session) { + if (!session?.isOwner) return json({ ok: false, error: "Nur der Hauptadministrator darf Codes ansehen." }, 403); + const body = await request.json().catch(() => ({})); + + const reAuth = await verifyCode(env, String(body.reAuthCode || "")); + if (!reAuth.ok || !reAuth.isOwner) { + return json({ ok: false, error: "Re-Authentifizierung fehlgeschlagen." }, 401); + } + + const id = String(body.id || ""); + const user = await loadUser(env, id); + if (!user || !user.code_enc) return json({ ok: false, error: "Kein Code hinterlegt." }, 404); + + const code = await decryptCode(env, user.code_enc); + await logAction(env, session.actor, "user.code_reveal", id, null); + return json({ ok: true, code }); +} + +export async function lockUser(request, env, session) { + if (!can(session, "TEAM_LOCK_USER")) return json({ ok: false, error: "Keine Berechtigung." }, 403); + const body = await request.json().catch(() => ({})); + const id = String(body.id || ""); + await env.DB.prepare(`UPDATE users SET status = 'locked' WHERE id = ?`).bind(id).run(); + await endAllSessionsForUser(env, id); + await logAction(env, session.actor, "user.lock", id, null); + return json({ ok: true }); +} + +export async function unlockUser(request, env, session) { + if (!can(session, "TEAM_LOCK_USER")) return json({ ok: false, error: "Keine Berechtigung." }, 403); + const body = await request.json().catch(() => ({})); + const id = String(body.id || ""); + const user = await loadUser(env, id); + if (!user) return json({ ok: false, error: "Benutzer nicht gefunden." }, 404); + if (user.expires_at && new Date(user.expires_at) < new Date()) { + return json({ ok: false, error: "Zugang ist abgelaufen — bitte zuerst Gültigkeit verlängern." }, 400); + } + await env.DB.prepare(`UPDATE users SET status = 'active' WHERE id = ?`).bind(id).run(); + await logAction(env, session.actor, "user.unlock", id, null); + return json({ ok: true }); +} + +/** NUR Owner: Zugang vollständig löschen. */ +export async function deleteUser(request, env, session) { + if (!session?.isOwner) return json({ ok: false, error: "Nur der Hauptadministrator darf Zugänge löschen." }, 403); + const body = await request.json().catch(() => ({})); + const id = String(body.id || ""); + await env.DB.prepare(`DELETE FROM user_permission_overrides WHERE user_id = ?`).bind(id).run(); + await env.DB.prepare(`DELETE FROM sessions WHERE user_id = ?`).bind(id).run(); + await env.DB.prepare(`DELETE FROM users WHERE id = ?`).bind(id).run(); + await logAction(env, session.actor, "user.delete", id, null); + return json({ ok: true }); +} + +export async function setUserRole(request, env, session) { + if (!can(session, "TEAM_ASSIGN_ROLE")) return json({ ok: false, error: "Keine Berechtigung." }, 403); + const body = await request.json().catch(() => ({})); + const id = String(body.id || ""); + const roleId = body.roleId || null; + await env.DB.prepare(`UPDATE users SET role_id = ? WHERE id = ?`).bind(roleId, id).run(); + await logAction(env, session.actor, "user.set_role", id, { roleId }); + return json({ ok: true }); +} + +export async function setUserPermissions(request, env, session) { + if (!can(session, "TEAM_EDIT_PERMISSIONS")) return json({ ok: false, error: "Keine Berechtigung." }, 403); + const body = await request.json().catch(() => ({})); + const id = String(body.id || ""); + const overrides = body.overrides && typeof body.overrides === "object" ? body.overrides : {}; + + await env.DB.prepare(`DELETE FROM user_permission_overrides WHERE user_id = ?`).bind(id).run(); + for (const [key, allowed] of Object.entries(overrides)) { + if (!isValidPermissionKey(key)) continue; // Owner-only-Aktionen können hier nie landen + await env.DB.prepare( + `INSERT INTO user_permission_overrides (user_id, permission, allowed) VALUES (?, ?, ?)` + ) + .bind(id, key, allowed ? 1 : 0) + .run(); + } + await logAction(env, session.actor, "user.set_permissions", id, { overrides }); + return json({ ok: true }); +} + +/** NUR Owner: alle Sitzungen einer Person sofort beenden ("alle Geräte abmelden"). */ +export async function endUserSessions(request, env, session) { + if (!session?.isOwner) return json({ ok: false, error: "Nur der Hauptadministrator darf Sitzungen beenden." }, 403); + const body = await request.json().catch(() => ({})); + const id = String(body.id || ""); + await endAllSessionsForUser(env, id); + await logAction(env, session.actor, "user.end_sessions", id, null); + return json({ ok: true }); +} + +export async function updateUserMeta(request, env, session) { + if (!session?.isOwner) return json({ ok: false, error: "Nur der Hauptadministrator darf diese Angaben ändern." }, 403); + const body = await request.json().catch(() => ({})); + const id = String(body.id || ""); + const user = await loadUser(env, id); + if (!user) return json({ ok: false, error: "Benutzer nicht gefunden." }, 404); + + const name = body.name !== undefined ? String(body.name).trim().slice(0, 80) || user.name : user.name; + const email = body.email !== undefined ? String(body.email).trim() || null : user.email; + const note = body.note !== undefined ? String(body.note).slice(0, 500) || null : user.note; + let expiresAt = user.expires_at; + if (body.expiresAt !== undefined) expiresAt = body.expiresAt ? new Date(body.expiresAt).toISOString() : null; + if (body.expiresInDays) expiresAt = new Date(Date.now() + Number(body.expiresInDays) * 86400000).toISOString(); + + await env.DB.prepare(`UPDATE users SET name = ?, email = ?, note = ?, expires_at = ? WHERE id = ?`) + .bind(name, email, note, expiresAt, id) + .run(); + await logAction(env, session.actor, "user.update_meta", id, null); + return json({ ok: true }); +} diff --git a/cloudflare-worker/src/worker.js b/cloudflare-worker/src/worker.js index 0c87478..dddc29b 100644 --- a/cloudflare-worker/src/worker.js +++ b/cloudflare-worker/src/worker.js @@ -1,285 +1,130 @@ /* ===================================================================== - DOGFATHER UNIVERSE — Bewerbungs-Postfach Worker - Nimmt Bewerbungen von bewerben.html entgegen, speichert sie in KV, - und gibt sie nur nach korrektem Code (serverseitig geprüft) an - postfach.html zurück. + DOGFATHER UNIVERSE — Interner Bereich Worker - Zugriffs-Modell (wichtig für's Verständnis): - - Owner-Code (POSTFACH_CODE, Secret) = Dogi selbst. Sieht alle - Bewerbungen UND kann Helfer-Codes mit Rollen (z.B. "Rechte Hand", - "Linke Hand") anlegen/entziehen. - - Helfer-Codes (von Dogi vergeben) = sehen NUR die Bewerbungen. - Sie können KEINE neuen Codes anlegen, KEINE Rollen verwalten, - und haben KEINERLEI Möglichkeit, irgendetwas an der Website - selbst zu verändern oder zu löschen — dafür gibt es in diesem - Worker schlicht keine Funktion. Er kann ausschließlich - Bewerbungen entgegennehmen und (mit gültigem Code) anzeigen. - - Alle Codes werden nur als SHA-256-Hash in KV gespeichert, nie im - Klartext. Ein neu erzeugter Code wird genau einmal zurückgegeben - (beim Erstellen) — danach ist er nicht mehr abrufbar. + Setzt "Geschuetzter_Zugang_Rollen_Benutzerverwaltung.pdf" um: + Session-basierte Anmeldung, verschlüsselte (reversible) Code-Speicherung + NUR für den Owner einsehbar, vollständig konfigurierbare Rollen mit + granularen Rechten, individuelle Personen-Ausnahmen, Aktivitätsprotokoll, + Login-Lockout nach Fehlversuchen. - Endpoints: - POST /submit { type, data } -> Bewerbung speichern - POST /postfach { code } -> Bewerbungen ansehen - POST /admin/roles/list { ownerCode } -> Helfer-Codes auflisten (nur Owner) - POST /admin/roles/create { ownerCode, role, label } -> neuen Helfer-Code erzeugen (nur Owner) - POST /admin/roles/revoke { ownerCode, keyId } -> Helfer-Code entziehen (nur Owner) - GET /live-status -> öffentlich: {live, updatedAt} - POST /live-status/set { ownerCode, live } -> Live-Status umschalten (nur Owner) - GET /health -> einfacher Check ob der Worker läuft + Architektur: + src/lib/crypto.js Hashing, AES-GCM (Code-Verschlüsselung), IDs + src/lib/auth.js Login-Prüfung, Sessions, Rate-Limiting + src/lib/permissions.js Vollständiger Permission-Katalog + src/lib/audit.js Aktivitätsprotokoll schreiben + src/lib/http.js CORS/JSON-Helfer + src/routes/*.js Endpoint-Handler pro Bereich - Secrets/Bindings (siehe wrangler.toml + README.md): - KV Binding: BEWERBUNGEN (Cloudflare KV Namespace, wird auch für Rollen genutzt) - Secret: POSTFACH_CODE (mit `wrangler secret put POSTFACH_CODE` setzen) + Bindings (siehe wrangler.toml): + D1: DB (Datenbank für Users/Rollen/Sessions/Audit/Bewerbungen) + KV: BEWERBUNGEN (nur noch für den Live-Status-Schalter genutzt) + Secret: POSTFACH_CODE (Owner-Code, mit `wrangler secret put` gesetzt) + Secret: ENCRYPTION_KEY (AES-256-Schlüssel, base64, für Code-Verschlüsselung) ===================================================================== */ +import { json, corsHeaders } from "./lib/http.js"; +import { requireSession } from "./lib/auth.js"; +import { login, logout } from "./routes/auth.js"; +import { + listRoles, createRole, updateRole, deleteRole, +} from "./routes/roles.js"; +import { + listUsers, prepareUser, finalizeUser, setUserCode, revealUserCode, + lockUser, unlockUser, deleteUser, setUserRole, setUserPermissions, + endUserSessions, updateUserMeta, +} from "./routes/users.js"; +import { + submitApplication, listApplications, changeApplicationStatus, + takeOverApplication, assignApplication, archiveApplication, + restoreApplication, deleteApplication, markAnswered, + listNotes, addNote, deleteNote, listDrafts, saveDraft, +} from "./routes/applications.js"; +import { listAuditLog } from "./routes/audit.js"; -const ALLOWED_TYPES = ["creator", "scout", "kooperation"]; -const ROLE_KEY_PREFIX = "role:"; const LIVE_STATUS_KEY = "meta:live"; -function corsHeaders() { - // TODO: sobald die Domain final steht, statt "*" die echte Domain eintragen - // (z.B. "https://dogfather-universe.example"), damit nur die eigene Seite - // an die API senden kann. - return { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", - }; -} - -function json(data, status = 200) { - return new Response(JSON.stringify(data), { - status, - headers: { "Content-Type": "application/json", ...corsHeaders() }, - }); -} - -async function sha256Hex(text) { - const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)); - return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join(""); -} - -function generateFriendlyCode() { - // z.B. "K3F9-QX2L-7HRT" — leicht abzutippen/vorzulesen, ausreichend Entropie - const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // ohne verwechselbare Zeichen (I,O,0,1) - const bytes = crypto.getRandomValues(new Uint8Array(12)); - let raw = ""; - for (const b of bytes) raw += alphabet[b % alphabet.length]; - return `${raw.slice(0, 4)}-${raw.slice(4, 8)}-${raw.slice(8, 12)}`; -} - -async function isOwnerCode(code, env) { - return !!env.POSTFACH_CODE && code === env.POSTFACH_CODE; -} - -async function findHelperRole(code, env) { - const hash = await sha256Hex(code); - const raw = await env.BEWERBUNGEN.get(ROLE_KEY_PREFIX + hash); - return raw ? { keyId: ROLE_KEY_PREFIX + hash, ...JSON.parse(raw) } : null; -} - -async function handleSubmit(request, env) { - let body; - try { - body = await request.json(); - } catch { - return json({ ok: false, error: "Ungültiges JSON." }, 400); - } - - const { type, data } = body || {}; - if (!ALLOWED_TYPES.includes(type) || typeof data !== "object" || !data) { - return json({ ok: false, error: "Ungültige Bewerbungsdaten." }, 400); - } - - const values = Object.values(data).map((v) => String(v || "").trim()); - if (values.every((v) => v === "")) { - return json({ ok: false, error: "Leere Bewerbung." }, 400); - } - - const id = crypto.randomUUID(); - const timestamp = new Date().toISOString(); - const key = `sub:${timestamp}:${id}`; - - await env.BEWERBUNGEN.put(key, JSON.stringify({ id, type, data, timestamp })); - - return json({ ok: true }); -} - -async function handlePostfach(request, env) { - let body; - try { - body = await request.json(); - } catch { - return json({ ok: false, error: "Ungültiges JSON." }, 400); - } - - const code = (body && body.code) || ""; - let owner = await isOwnerCode(code, env); - let helperRole = null; - - if (!owner) { - helperRole = await findHelperRole(code, env); - } - - if (!owner && !helperRole) { - // bewusst generische Fehlermeldung, kein Hinweis ob Code existiert - return json({ ok: false, error: "Falscher Code." }, 401); - } - - const list = await env.BEWERBUNGEN.list({ prefix: "sub:", limit: 500 }); - const items = await Promise.all( - list.keys.map(async (k) => { - const raw = await env.BEWERBUNGEN.get(k.name); - return raw ? JSON.parse(raw) : null; - }) - ); - - const bewerbungen = items - .filter(Boolean) - .sort((a, b) => (a.timestamp < b.timestamp ? 1 : -1)); // neueste zuerst - - return json({ - ok: true, - isOwner: owner, - roleLabel: owner ? "Dogi (Owner)" : helperRole.label, - bewerbungen, - }); -} - -async function requireOwner(body, env) { - const ownerCode = (body && body.ownerCode) || ""; - return isOwnerCode(ownerCode, env); -} - -async function handleRolesList(request, env) { - let body; - try { - body = await request.json(); - } catch { - return json({ ok: false, error: "Ungültiges JSON." }, 400); - } - if (!(await requireOwner(body, env))) { - return json({ ok: false, error: "Nur Dogi darf Rollen verwalten." }, 401); - } - - const list = await env.BEWERBUNGEN.list({ prefix: ROLE_KEY_PREFIX, limit: 200 }); - const roles = await Promise.all( - list.keys.map(async (k) => { - const raw = await env.BEWERBUNGEN.get(k.name); - return raw ? { keyId: k.name, ...JSON.parse(raw) } : null; - }) - ); - - return json({ ok: true, roles: roles.filter(Boolean) }); -} - -async function handleRolesCreate(request, env) { - let body; - try { - body = await request.json(); - } catch { - return json({ ok: false, error: "Ungültiges JSON." }, 400); - } - if (!(await requireOwner(body, env))) { - return json({ ok: false, error: "Nur Dogi darf Rollen verwalten." }, 401); - } - - const role = String((body && body.role) || "").slice(0, 60).trim() || "Helfer"; - const label = String((body && body.label) || "").slice(0, 80).trim() || role; - - const newCode = generateFriendlyCode(); - const hash = await sha256Hex(newCode); - const record = { role, label, createdAt: new Date().toISOString() }; - - await env.BEWERBUNGEN.put(ROLE_KEY_PREFIX + hash, JSON.stringify(record)); - - // Der Klartext-Code wird NUR jetzt einmalig zurückgegeben — danach nie wieder. - return json({ ok: true, code: newCode, ...record }); -} - -async function handleRolesRevoke(request, env) { - let body; - try { - body = await request.json(); - } catch { - return json({ ok: false, error: "Ungültiges JSON." }, 400); - } - if (!(await requireOwner(body, env))) { - return json({ ok: false, error: "Nur Dogi darf Rollen verwalten." }, 401); - } - - const keyId = String((body && body.keyId) || ""); - if (!keyId.startsWith(ROLE_KEY_PREFIX)) { - return json({ ok: false, error: "Ungültige keyId." }, 400); - } - - await env.BEWERBUNGEN.delete(keyId); - return json({ ok: true }); -} - async function handleLiveStatusGet(env) { const raw = await env.BEWERBUNGEN.get(LIVE_STATUS_KEY); const data = raw ? JSON.parse(raw) : { live: false, updatedAt: null }; return json({ ok: true, ...data }); } -async function handleLiveStatusSet(request, env) { - let body; - try { - body = await request.json(); - } catch { - return json({ ok: false, error: "Ungültiges JSON." }, 400); - } - if (!(await requireOwner(body, env))) { - return json({ ok: false, error: "Nur Dogi darf den Live-Status ändern." }, 401); - } - - const live = !!(body && body.live); - const record = { live, updatedAt: new Date().toISOString() }; +async function handleLiveStatusSet(request, env, session) { + if (!session?.isOwner) return json({ ok: false, error: "Nur Dogi darf den Live-Status ändern." }, 403); + const body = await request.json().catch(() => ({})); + const record = { live: !!body.live, updatedAt: new Date().toISOString() }; await env.BEWERBUNGEN.put(LIVE_STATUS_KEY, JSON.stringify(record)); - return json({ ok: true, ...record }); } +// Routen, die eine gültige Session brauchen. `session` wird injiziert. +const AUTHED_ROUTES = { + "POST /auth/logout": (req, env) => logout(req, env), + "POST /live-status/set": (req, env, session) => handleLiveStatusSet(req, env, session), + + "POST /admin/applications/list": (req, env, session) => listApplications(req, env, session), + "POST /admin/applications/status": (req, env, session) => changeApplicationStatus(req, env, session), + "POST /admin/applications/take-over": (req, env, session) => takeOverApplication(req, env, session), + "POST /admin/applications/assign": (req, env, session) => assignApplication(req, env, session), + "POST /admin/applications/archive": (req, env, session) => archiveApplication(req, env, session), + "POST /admin/applications/restore": (req, env, session) => restoreApplication(req, env, session), + "POST /admin/applications/delete": (req, env, session) => deleteApplication(req, env, session), + "POST /admin/applications/mark-answered": (req, env, session) => markAnswered(req, env, session), + + "POST /admin/notes/list": (req, env, session) => listNotes(req, env, session), + "POST /admin/notes/add": (req, env, session) => addNote(req, env, session), + "POST /admin/notes/delete": (req, env, session) => deleteNote(req, env, session), + + "POST /admin/drafts/list": (req, env, session) => listDrafts(req, env, session), + "POST /admin/drafts/save": (req, env, session) => saveDraft(req, env, session), + + "POST /admin/roles/list": (req, env, session) => listRoles(env, session), + "POST /admin/roles/create": (req, env, session) => createRole(req, env, session), + "POST /admin/roles/update": (req, env, session) => updateRole(req, env, session), + "POST /admin/roles/delete": (req, env, session) => deleteRole(req, env, session), + + "POST /admin/users/list": (req, env, session) => listUsers(env, session), + "POST /admin/users/prepare": (req, env, session) => prepareUser(req, env, session), + "POST /admin/users/finalize": (req, env, session) => finalizeUser(req, env, session), + "POST /admin/users/set-code": (req, env, session) => setUserCode(req, env, session), + "POST /admin/users/reveal-code": (req, env, session) => revealUserCode(req, env, session), + "POST /admin/users/lock": (req, env, session) => lockUser(req, env, session), + "POST /admin/users/unlock": (req, env, session) => unlockUser(req, env, session), + "POST /admin/users/delete": (req, env, session) => deleteUser(req, env, session), + "POST /admin/users/set-role": (req, env, session) => setUserRole(req, env, session), + "POST /admin/users/set-permissions": (req, env, session) => setUserPermissions(req, env, session), + "POST /admin/users/end-sessions": (req, env, session) => endUserSessions(req, env, session), + "POST /admin/users/update-meta": (req, env, session) => updateUserMeta(req, env, session), + + "POST /admin/audit/list": (req, env, session) => listAuditLog(req, env, session), +}; + export default { async fetch(request, env) { const url = new URL(request.url); + const routeKey = `${request.method} ${url.pathname}`; if (request.method === "OPTIONS") { return new Response(null, { headers: corsHeaders() }); } - if (url.pathname === "/health") { - return json({ ok: true, service: "dogfather-universe-postfach" }); + if (url.pathname === "/health" && request.method === "GET") { + return json({ ok: true, service: "dogfather-universe-interner-bereich" }); } - if (url.pathname === "/submit" && request.method === "POST") { - return handleSubmit(request, env); + return submitApplication(request, env); } - - if (url.pathname === "/postfach" && request.method === "POST") { - return handlePostfach(request, env); - } - - if (url.pathname === "/admin/roles/list" && request.method === "POST") { - return handleRolesList(request, env); - } - - if (url.pathname === "/admin/roles/create" && request.method === "POST") { - return handleRolesCreate(request, env); - } - - if (url.pathname === "/admin/roles/revoke" && request.method === "POST") { - return handleRolesRevoke(request, env); - } - if (url.pathname === "/live-status" && request.method === "GET") { return handleLiveStatusGet(env); } + if (url.pathname === "/auth/login" && request.method === "POST") { + return login(request, env); + } - if (url.pathname === "/live-status/set" && request.method === "POST") { - return handleLiveStatusSet(request, env); + const handler = AUTHED_ROUTES[routeKey]; + if (handler) { + const session = await requireSession(request, env); + if (!session) return json({ ok: false, error: "Sitzung abgelaufen oder ungültig. Bitte erneut anmelden." }, 401); + return handler(request, env, session); } return json({ ok: false, error: "Not found." }, 404); diff --git a/cloudflare-worker/wrangler.toml b/cloudflare-worker/wrangler.toml index 02e09ad..2bb353e 100644 --- a/cloudflare-worker/wrangler.toml +++ b/cloudflare-worker/wrangler.toml @@ -10,3 +10,8 @@ compatibility_date = "2024-01-01" [[kv_namespaces]] binding = "BEWERBUNGEN" id = "f8f8a692150f452cab5e334743194fe2" + +[[d1_databases]] +binding = "DB" +database_name = "dogfather-universe-db" +database_id = "62a0a329-7932-4739-8244-518fc9f31b56" diff --git a/postfach.html b/postfach.html index 6460886..685fdf9 100644 --- a/postfach.html +++ b/postfach.html @@ -11,6 +11,16 @@ + @@ -24,8 +34,9 @@ -
-
+ +
+
@@ -34,37 +45,21 @@
- -
- Noch nicht aktiv: Dieses Postfach ist erst nutzbar, sobald der - Cloudflare-Worker deployed und die Worker-URL in dieser Datei - (API_BASE_URL, ganz unten im Script) sowie in - assets/js/forms.js eingetragen ist. Anleitung: - cloudflare-worker/README.md. -
- + - - - - + - -