Vollständiges Zugangs-/Rollen-/Rechtesystem gebaut (Anforderungsdokument): D1-Datenbank, AES-verschlüsselte Codes nur für Owner einsehbar, granulare Permissions, Session-Auth mit Lockout, Zugänge-&-Teammitglieder-Verwaltungsseite, Bewerbungen mit Status/Notizen/Entwürfen

This commit is contained in:
2026-07-31 00:37:04 +02:00
parent 96087f460e
commit 6a1c0e355c
18 changed files with 2283 additions and 539 deletions
+81
View File
@@ -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 };
}
+216
View File
@@ -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 = `<p class="muted">Keine Bewerbungen in dieser Ansicht.</p>`;
return;
}
list.innerHTML = applications
.map((a) => {
const fields = a.data
? Object.entries(a.data).map(([k, v]) => `<p class="small"><strong>${k}:</strong> ${v}</p>`).join("")
: `<p class="small muted">Keine Berechtigung, den vollen Inhalt zu öffnen.</p>`;
const date = new Date(a.createdAt).toLocaleString("de-DE");
const actions = [];
if (sessionHasPermission(session, "APPLICATIONS_TAKE_OVER")) {
actions.push(`<button class="btn btn-outline take-btn" data-id="${a.id}">Übernehmen</button>`);
}
if (sessionHasPermission(session, "APPLICATIONS_MARK_ANSWERED")) {
actions.push(`<button class="btn btn-outline answered-btn" data-id="${a.id}">Als beantwortet markieren</button>`);
}
if (sessionHasPermission(session, "APPLICATIONS_ARCHIVE") && !a.archived) {
actions.push(`<button class="btn btn-outline archive-btn" data-id="${a.id}">Archivieren</button>`);
}
if (sessionHasPermission(session, "APPLICATIONS_RESTORE") && a.archived) {
actions.push(`<button class="btn btn-outline restore-btn" data-id="${a.id}">Wiederherstellen</button>`);
}
if (sessionHasPermission(session, "APPLICATIONS_DELETE")) {
actions.push(`<button class="btn btn-outline delete-btn" data-id="${a.id}" style="color:var(--accent);">Löschen</button>`);
}
if (sessionHasPermission(session, "NOTES_READ")) {
actions.push(`<button class="btn btn-outline notes-toggle-btn" data-id="${a.id}">Notizen</button>`);
}
const statusSelect = sessionHasPermission(session, "APPLICATIONS_CHANGE_STATUS")
? `<select class="status-select" data-id="${a.id}">
${STATUS_OPTIONS.map((s) => `<option value="${s}" ${s === a.status ? "selected" : ""}>${s}</option>`).join("")}
</select>`
: `<span class="tag">${a.status}</span>`;
return `
<div class="card app-card">
<div style="display:flex;justify-content:space-between;align-items:center;">
<span class="tag">${a.type}${a.archived ? " · archiviert" : ""}</span>
${statusSelect}
</div>
<p class="small muted" style="margin:0;">${date}</p>
<div class="fields">${fields}</div>
<div class="actions">${actions.join("")}</div>
<div class="notes-box" id="notes-${a.id}">
<div class="notes-list" id="notes-list-${a.id}"><p class="small muted">Lädt…</p></div>
${
sessionHasPermission(session, "NOTES_WRITE")
? `<div class="field" style="margin-top:.6rem;">
<textarea class="note-input" data-id="${a.id}" placeholder="Neue Notiz…" style="min-height:60px;"></textarea>
<button class="btn btn-outline add-note-btn" data-id="${a.id}" style="margin-top:.4rem;">Notiz speichern</button>
</div>`
: ""
}
</div>
</div>`;
})
.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 = `<p class="small muted">Konnte Notizen nicht laden.</p>`;
return;
}
box.innerHTML =
res.notes
.map((n) => `<div class="note-item"><strong>${n.author}</strong>: ${n.text}<br><span class="small muted">${new Date(n.created_at).toLocaleString("de-DE")}</span></div>`)
.join("") || `<p class="small muted">Noch keine Notizen.</p>`;
}
/* ---------------- 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));
});
+510
View File
@@ -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 = `<p class="muted">Noch keine Zugänge angelegt.</p>`;
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(`<button class="btn btn-primary finalize-btn" data-id="${u.id}">Freigeben &amp; Code erstellen</button>`);
}
if (isOwner && u.status !== "pending") {
actions.push(`<button class="btn btn-outline reveal-btn" data-id="${u.id}">Code anzeigen</button>`);
actions.push(`<button class="btn btn-outline newcode-btn" data-id="${u.id}">Neuer Code</button>`);
actions.push(`<button class="btn btn-outline endsessions-btn" data-id="${u.id}">Alle Geräte abmelden</button>`);
}
if (canLock && u.status !== "pending") {
actions.push(
u.status === "active"
? `<button class="btn btn-outline lock-btn" data-id="${u.id}">Sperren</button>`
: `<button class="btn btn-outline unlock-btn" data-id="${u.id}">Entsperren</button>`
);
}
if (canAssignRole) {
actions.push(`<button class="btn btn-outline role-btn" data-id="${u.id}">Rolle ändern</button>`);
}
if (canEditPerms) {
actions.push(`<button class="btn btn-outline perms-btn" data-id="${u.id}">Rechte verwalten</button>`);
}
if (isOwner) {
actions.push(`<button class="btn btn-outline delete-btn" data-id="${u.id}" style="color:var(--accent);">Löschen</button>`);
}
return `
<div class="user-row">
<div><strong>${u.name}</strong><br><span class="small muted">@${u.username}${u.email ? " · " + u.email : ""}</span></div>
<div>${roleNameById(u.roleId)}</div>
<div><span class="status-pill ${statusClass}">${u.status}</span></div>
<div class="small muted">${u.expiresAt ? new Date(u.expiresAt).toLocaleDateString("de-DE") : "dauerhaft"}</div>
<div class="small muted">${u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleString("de-DE") : "nie"}</div>
<div class="actions">${actions.join("")}</div>
</div>`;
})
.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) => `<option value="${r.id}">${r.name}</option>`).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 = `<strong>Code:</strong> ${res.code}<br><span class="small">Jetzt sichern/weitergeben — wird nirgends mehr angezeigt!</span>`;
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) => `
<div class="perm-group-title">${g.label}</div>
<table class="perm-table"><tbody>
${g.keys
.map((k) => {
const inRole = roleDefaults.includes(k);
return `
<tr>
<td>${PERMISSION_LABELS[k]} <span class="small muted">${inRole ? "(in Rolle enthalten)" : ""}</span></td>
<td>
<select data-perm="${k}" style="background:var(--bg-alt);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:.3rem;">
<option value="inherit">Rolle (${inRole ? "erlaubt" : "nicht erlaubt"})</option>
<option value="allow">Erlaubt</option>
<option value="deny">Verboten</option>
</select>
</td>
</tr>`;
})
.join("")}
</tbody></table>`
).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 = `<p class="muted">Noch keine Rollen erstellt.</p>`;
return;
}
container.innerHTML = currentRoles
.map(
(r) => `
<div class="card">
<h3>${r.name}</h3>
<p class="small muted">${r.permissions.length} Rechte aktiv</p>
${
canManage
? `<div class="btn-row" style="margin-top:.6rem;">
<button class="btn btn-outline edit-role-btn" data-id="${r.id}">Bearbeiten</button>
<button class="btn btn-outline delete-role-btn" data-id="${r.id}" style="color:var(--accent);">Löschen</button>
</div>`
: ""
}
</div>`
)
.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) => `
<div class="perm-group-title">${g.label}</div>
${g.keys
.map(
(k) => `
<label class="small" style="display:flex;gap:.5rem;align-items:center;padding:.25rem 0;">
<input type="checkbox" data-perm="${k}" ${role && role.permissions.includes(k) ? "checked" : ""} />
${PERMISSION_LABELS[k]}
</label>`
)
.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) => `
<div class="audit-row">
<strong>${e.actor}</strong> — ${e.action}${e.target ? ` <span class="small muted">(${e.target})</span>` : ""}
<span class="small muted" style="float:right;">${new Date(e.created_at).toLocaleString("de-DE")}</span>
</div>`
)
.join("") || `<p class="muted">Noch keine Einträge.</p>`;
}
/* ---------------- 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");
});
});
});
+106
View File
@@ -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);
+15
View File
@@ -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();
}
+161
View File
@@ -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);
}
+78
View File
@@ -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();
}
+19
View File
@@ -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() },
});
}
+96
View File
@@ -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);
}
@@ -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 });
}
+20
View File
@@ -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 || [] });
}
+69
View File
@@ -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 });
}
+95
View File
@@ -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 });
}
+269
View File
@@ -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 });
}
+94 -249
View File
@@ -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);
+5
View File
@@ -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"
+30 -290
View File
@@ -11,6 +11,16 @@
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css" />
<style>
.app-card { display:flex; flex-direction:column; gap:.5rem; }
.app-card .fields p { margin:.15rem 0; }
.app-card .actions { display:flex; flex-wrap:wrap; gap:.4rem; margin-top:.6rem; }
.app-card select.status-select { background:var(--bg-alt); color:var(--text); border:1px solid var(--border); border-radius:8px; padding:.35rem .6rem; font-size:.8rem; }
.notes-box { margin-top:.7rem; padding-top:.7rem; border-top:1px solid var(--border); display:none; }
.notes-box.open { display:block; }
.note-item { font-size:.82rem; padding:.4rem 0; border-bottom:1px solid var(--border); }
.filter-row { display:flex; gap:.6rem; flex-wrap:wrap; margin-bottom:1.2rem; }
</style>
</head>
<body>
<div id="site-header"></div>
@@ -24,8 +34,9 @@
</div>
</section>
<section class="section-tight">
<div class="container" style="max-width:520px;" id="gate-container">
<!-- ===== Login-Gate ===== -->
<section class="section-tight" id="gate-container">
<div class="container" style="max-width:520px;">
<form id="code-gate" class="app-form" novalidate>
<div class="field">
<label for="code-input">Zugangscode</label>
@@ -34,37 +45,21 @@
<button class="btn btn-primary" type="submit">Postfach öffnen</button>
<div class="form-status" id="gate-status"></div>
</form>
<div class="todo-note" id="not-configured-note">
<strong>Noch nicht aktiv:</strong> Dieses Postfach ist erst nutzbar, sobald der
Cloudflare-Worker deployed und die Worker-URL in dieser Datei
(<code>API_BASE_URL</code>, ganz unten im Script) sowie in
<code>assets/js/forms.js</code> eingetragen ist. Anleitung:
<code>cloudflare-worker/README.md</code>.
</div>
</div>
</section>
<!-- ===== Eingeloggt: Kopfzeile mit Rolle + Logout ===== -->
<!-- ===== Session-Leiste ===== -->
<section class="section-tight" id="session-bar" style="display:none;padding-top:0;">
<div class="container" style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:1rem;">
<span class="badge rank-1" id="role-badge">Eingeloggt</span>
<button class="btn btn-outline" id="logout-btn" type="button">Abmelden</button>
</div>
</section>
<!-- ===== Mailbox (für alle mit gültigem Code) ===== -->
<section class="section-tight" id="inbox-section" style="display:none;">
<div class="container">
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:1rem;margin-bottom:1.4rem;">
<h2 style="margin:0;">Eingegangene Bewerbungen</h2>
<button class="btn btn-outline" id="refresh-btn" type="button">Aktualisieren</button>
<div class="btn-row" style="margin:0;">
<a class="btn btn-outline" href="zugaenge.html" id="team-link" style="display:none;">Zugänge &amp; Team</a>
<button class="btn btn-outline" id="logout-btn" type="button">Abmelden</button>
</div>
<div id="inbox-list" class="grid grid-2"></div>
</div>
</section>
<!-- ===== Live-Status umschalten (NUR für Dogi/Owner sichtbar) ===== -->
<!-- ===== Live-Status (nur Owner) ===== -->
<section class="section-tight" id="live-toggle-section" style="display:none;">
<div class="container text-center">
<h2>Live-Status</h2>
@@ -79,279 +74,24 @@
</div>
</section>
<!-- ===== Rollen-Verwaltung (NUR für Dogi/Owner sichtbar) ===== -->
<section id="admin-section" style="display:none;">
<!-- ===== Mailbox ===== -->
<section class="section-tight" id="inbox-section" style="display:none;">
<div class="container">
<h2 class="text-center">Rollen verwalten</h2>
<p class="lead text-center" style="max-width:60ch;margin:0 auto;">
Vergib Codes an Rechte Hand, Linke Hand oder wen du sonst reinlassen willst. Diese Codes
zeigen <strong>ausschließlich die Bewerbungen</strong> — nichts an der Website selbst kann
damit verändert oder gelöscht werden, dafür gibt es hier schlicht keine Funktion.
</p>
<form id="role-create-form" class="app-form" style="max-width:520px;margin:1.6rem auto;" novalidate>
<div class="field">
<label for="role-select">Rolle</label>
<select id="role-select" name="role">
<option value="Rechte Hand">Rechte Hand</option>
<option value="Linke Hand">Linke Hand</option>
<option value="Helfer">Sonstige / frei</option>
</select>
</div>
<div class="field">
<label for="role-label">Für wen? (Name/Bezeichnung)</label>
<input id="role-label" name="label" type="text" placeholder="z.B. VanVan" required />
</div>
<button class="btn btn-primary" type="submit">Neuen Code erzeugen</button>
<div class="form-status" id="role-create-status"></div>
</form>
<div class="todo-note" id="new-code-reveal" style="display:none;max-width:520px;margin:0 auto 2rem;"></div>
<h3 class="text-center">Bestehende Codes</h3>
<div id="roles-list" class="grid grid-2"></div>
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:1rem;margin-bottom:1rem;">
<h2 style="margin:0;">Eingegangene Bewerbungen</h2>
<button class="btn btn-outline" id="refresh-btn" type="button">Aktualisieren</button>
</div>
<div class="filter-row">
<label class="small"><input type="checkbox" id="show-archived" /> Archivierte anzeigen</label>
</div>
<div id="inbox-list" class="grid grid-2"></div>
</div>
</section>
</main>
<div id="site-footer"></div>
<script src="assets/js/main.js"></script>
<script>
// TODO: nach dem Deploy des Cloudflare Workers hier die Worker-URL ohne
// abschließenden Slash eintragen (identisch zu assets/js/forms.js).
const API_BASE_URL = "https://dogfather-universe-postfach.dogfather1608.workers.dev";
document.addEventListener("DOMContentLoaded", () => {
const gateContainer = document.getElementById("gate-container");
const form = document.getElementById("code-gate");
const status = document.getElementById("gate-status");
const sessionBar = document.getElementById("session-bar");
const roleBadge = document.getElementById("role-badge");
const logoutBtn = document.getElementById("logout-btn");
const inboxSection = document.getElementById("inbox-section");
const inboxList = document.getElementById("inbox-list");
const refreshBtn = document.getElementById("refresh-btn");
const notConfigured = document.getElementById("not-configured-note");
const adminSection = document.getElementById("admin-section");
const roleCreateForm = document.getElementById("role-create-form");
const roleCreateStatus = document.getElementById("role-create-status");
const newCodeReveal = document.getElementById("new-code-reveal");
const rolesList = document.getElementById("roles-list");
const liveToggleSection = document.getElementById("live-toggle-section");
const adminLiveDot = document.getElementById("admin-live-dot");
const adminLiveText = document.getElementById("admin-live-text");
const goLiveBtn = document.getElementById("go-live-btn");
const goOfflineBtn = document.getElementById("go-offline-btn");
if (!API_BASE_URL) {
notConfigured.style.display = "block";
} else {
notConfigured.style.display = "none";
}
let session = { code: "", isOwner: false, roleLabel: "" };
function renderInbox(bewerbungen) {
if (!bewerbungen.length) {
inboxList.innerHTML = `<p class="muted">Noch keine Bewerbungen eingegangen.</p>`;
return;
}
inboxList.innerHTML = bewerbungen
.map((b) => {
const fields = Object.entries(b.data)
.map(([k, v]) => `<p class="small"><strong>${k}:</strong> ${v}</p>`)
.join("");
const date = new Date(b.timestamp).toLocaleString("de-DE");
return `
<div class="card">
<span class="tag">${b.type}</span>
<p class="small muted" style="margin-bottom:.6rem;">${date}</p>
${fields}
</div>`;
})
.join("");
}
async function loadInbox() {
status.className = "form-status show";
status.textContent = "Lade Postfach …";
try {
const res = await fetch(`${API_BASE_URL}/postfach`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code: session.code }),
});
const data = await res.json();
if (!res.ok || !data.ok) {
status.textContent = "Falscher Code oder Postfach nicht erreichbar.";
status.className = "form-status show error";
return false;
}
session.isOwner = data.isOwner;
session.roleLabel = data.roleLabel;
gateContainer.style.display = "none";
sessionBar.style.display = "block";
inboxSection.style.display = "block";
roleBadge.textContent = `Eingeloggt als: ${data.roleLabel}`;
renderInbox(data.bewerbungen);
if (data.isOwner) {
adminSection.style.display = "block";
liveToggleSection.style.display = "block";
loadRoles();
loadLiveStatus();
} else {
adminSection.style.display = "none";
liveToggleSection.style.display = "none";
}
return true;
} catch {
status.textContent = "Postfach gerade nicht erreichbar. Bitte später erneut versuchen.";
status.className = "form-status show error";
return false;
}
}
async function loadRoles() {
try {
const res = await fetch(`${API_BASE_URL}/admin/roles/list`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ownerCode: session.code }),
});
const data = await res.json();
if (!res.ok || !data.ok) {
rolesList.innerHTML = `<p class="muted">Konnte Codes nicht laden.</p>`;
return;
}
if (!data.roles.length) {
rolesList.innerHTML = `<p class="muted">Noch keine Helfer-Codes vergeben.</p>`;
return;
}
rolesList.innerHTML = data.roles
.map(
(r) => `
<div class="card">
<span class="tag">${r.role}</span>
<h3>${r.label}</h3>
<p class="small muted">Erstellt: ${new Date(r.createdAt).toLocaleDateString("de-DE")}</p>
<button class="btn btn-outline revoke-btn" data-key="${r.keyId}" type="button">Code entziehen</button>
</div>`
)
.join("");
rolesList.querySelectorAll(".revoke-btn").forEach((btn) => {
btn.addEventListener("click", async () => {
if (!confirm("Diesen Code wirklich entziehen? Die Person kann sich danach nicht mehr einloggen.")) return;
await fetch(`${API_BASE_URL}/admin/roles/revoke`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ownerCode: session.code, keyId: btn.dataset.key }),
});
loadRoles();
});
});
} catch {
rolesList.innerHTML = `<p class="muted">Konnte Codes nicht laden.</p>`;
}
}
function renderLiveStatus(live) {
adminLiveDot.classList.toggle("is-live", !!live);
adminLiveText.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 {
// still, keine Blockade der restlichen Seite
}
}
async function setLiveStatus(live) {
try {
const res = await fetch(`${API_BASE_URL}/live-status/set`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ownerCode: session.code, live }),
});
const data = await res.json();
if (res.ok && data.ok) renderLiveStatus(data.live);
} catch {
// keine Blockade
}
}
goLiveBtn.addEventListener("click", () => setLiveStatus(true));
goOfflineBtn.addEventListener("click", () => setLiveStatus(false));
form.addEventListener("submit", async (e) => {
e.preventDefault();
if (!API_BASE_URL) {
status.textContent = "Postfach ist noch nicht eingerichtet (siehe Hinweis unten).";
status.className = "form-status show error";
return;
}
session.code = document.getElementById("code-input").value;
const ok = await loadInbox();
if (ok) {
status.className = "form-status";
form.reset();
}
});
logoutBtn.addEventListener("click", () => {
session = { code: "", isOwner: false, roleLabel: "" };
gateContainer.style.display = "block";
sessionBar.style.display = "none";
inboxSection.style.display = "none";
adminSection.style.display = "none";
liveToggleSection.style.display = "none";
status.textContent = "";
status.className = "form-status";
});
refreshBtn.addEventListener("click", () => {
if (session.code) loadInbox();
});
roleCreateForm.addEventListener("submit", async (e) => {
e.preventDefault();
const role = document.getElementById("role-select").value;
const label = document.getElementById("role-label").value.trim();
if (!label) return;
try {
const res = await fetch(`${API_BASE_URL}/admin/roles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ownerCode: session.code, role, label }),
});
const data = await res.json();
if (!res.ok || !data.ok) {
roleCreateStatus.textContent = "Konnte keinen Code erzeugen.";
roleCreateStatus.className = "form-status show error";
return;
}
newCodeReveal.style.display = "block";
newCodeReveal.innerHTML = `
<strong>Neuer Code für ${data.label} (${data.role}):</strong><br>
<span style="font-size:1.3rem;font-family:monospace;letter-spacing:.05em;">${data.code}</span><br>
<span class="small">Jetzt sichern/weitergeben — dieser Code wird nirgends mehr angezeigt!</span>`;
roleCreateStatus.textContent = "";
roleCreateStatus.className = "form-status";
roleCreateForm.reset();
loadRoles();
} catch {
roleCreateStatus.textContent = "Postfach gerade nicht erreichbar.";
roleCreateStatus.className = "form-status show error";
}
});
});
</script>
<script src="assets/js/admin-auth.js"></script>
<script src="assets/js/postfach.js"></script>
</body>
</html>
+201
View File
@@ -0,0 +1,201 @@
<!DOCTYPE html>
<html lang="de" data-theme="spicymedia">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow" />
<title>Zugänge und Teammitglieder — DOGFATHER UNIVERSE</title>
<meta name="description" content="Interner Bereich: Zugänge, Rollen und Rechte verwalten." />
<link rel="icon" type="image/png" href="assets/img/favicon.png" />
<link rel="stylesheet" href="assets/css/main.css" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css" />
<style>
.perm-table { width:100%; border-collapse:collapse; font-size:.85rem; }
.perm-table th, .perm-table td { text-align:left; padding:.5rem .6rem; border-bottom:1px solid var(--border); }
.perm-group-title { font-weight:700; color:var(--accent); margin:1.2rem 0 .4rem; font-size:.8rem; text-transform:uppercase; letter-spacing:.08em; }
.user-row { display:grid; grid-template-columns: 1.4fr 1fr 1fr .8fr 1fr; gap:.6rem; align-items:center; padding:.8rem 0; border-bottom:1px solid var(--border); font-size:.88rem; }
.user-row .actions { display:flex; flex-wrap:wrap; gap:.4rem; grid-column:1/-1; margin-top:.4rem; }
.status-pill { display:inline-block; padding:.15rem .55rem; border-radius:999px; font-size:.7rem; font-weight:700; text-transform:uppercase; }
.status-pill.active { background:rgba(34,197,94,.15); color:#22c55e; }
.status-pill.locked { background:rgba(217,74,92,.15); color:var(--accent); }
.status-pill.pending { background:rgba(232,147,58,.15); color:var(--accent-2); }
.modal-backdrop { position:fixed; inset:0; background:rgba(0,0,0,.7); display:none; align-items:center; justify-content:center; z-index:300; padding:5vh 4vw; }
.modal-backdrop.open { display:flex; }
.modal-box { background:var(--card-bg); border:1px solid var(--border); border-radius:var(--radius); padding:1.6rem; max-width:640px; width:100%; max-height:88vh; overflow:auto; }
.code-reveal-box { font-family:monospace; font-size:1.3rem; letter-spacing:.05em; background:var(--bg-alt); border:1px dashed var(--accent); border-radius:8px; padding:.8rem 1rem; margin:.6rem 0; text-align:center; }
.audit-row { padding:.6rem 0; border-bottom:1px solid var(--border); font-size:.85rem; }
</style>
</head>
<body>
<div id="site-header"></div>
<main>
<section class="hero">
<div class="container">
<span class="eyebrow">🔒 Interner Bereich</span>
<h1>Zugänge und Teammitglieder</h1>
<p class="lead worlds">Rollen, Rechte, Codes — vollständig unter Dogis Kontrolle.</p>
</div>
</section>
<!-- ===== Login-Gate ===== -->
<section class="section-tight" id="gate-container">
<div class="container" style="max-width:480px;">
<form id="code-gate" class="app-form" novalidate>
<div class="field">
<label for="code-input">Zugangscode</label>
<input id="code-input" name="code" type="password" autocomplete="off" required />
</div>
<button class="btn btn-primary" type="submit">Anmelden</button>
<div class="form-status" id="gate-status"></div>
</form>
<p class="small muted" style="margin-top:1rem;">
<a href="postfach.html">← Zurück zum Postfach</a>
</p>
</div>
</section>
<!-- ===== Session-Leiste ===== -->
<section class="section-tight" id="session-bar" style="display:none;padding-top:0;">
<div class="container" style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:1rem;">
<span class="badge rank-1" id="role-badge">Eingeloggt</span>
<div class="btn-row" style="margin:0;">
<a class="btn btn-outline" href="postfach.html">Zum Postfach</a>
<button class="btn btn-outline" id="logout-btn" type="button">Abmelden</button>
</div>
</div>
</section>
<!-- ===== Hauptbereich ===== -->
<section id="main-area" style="display:none;">
<div class="container">
<div class="tabs">
<button class="tab-btn active" data-tab="tab-users">Zugänge</button>
<button class="tab-btn" data-tab="tab-roles">Rollen</button>
<button class="tab-btn" data-tab="tab-audit">Aktivitätsprotokoll</button>
</div>
<!-- ---- Tab: Zugänge ---- -->
<div class="tab-panel active" id="tab-users">
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:1rem;margin-bottom:1rem;">
<h2 style="margin:0;">Alle Zugänge</h2>
<div class="btn-row" style="margin:0;">
<button class="btn btn-outline" id="refresh-users-btn" type="button">Aktualisieren</button>
<button class="btn btn-primary" id="new-user-btn" type="button">+ Neuer Zugang</button>
</div>
</div>
<div id="users-list"></div>
</div>
<!-- ---- Tab: Rollen ---- -->
<div class="tab-panel" id="tab-roles">
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:1rem;margin-bottom:1rem;">
<h2 style="margin:0;">Rollen</h2>
<button class="btn btn-primary" id="new-role-btn" type="button">+ Neue Rolle</button>
</div>
<div id="roles-list" class="grid grid-2"></div>
</div>
<!-- ---- Tab: Aktivitätsprotokoll ---- -->
<div class="tab-panel" id="tab-audit">
<h2>Aktivitätsprotokoll</h2>
<p class="small muted">Zugangscodes erscheinen hier niemals im Klartext.</p>
<div id="audit-list"></div>
</div>
</div>
</section>
</main>
<!-- ===== Modal: Neuer/Bearbeiten Zugang ===== -->
<div class="modal-backdrop" id="user-modal">
<div class="modal-box">
<h2 id="user-modal-title">Neuer Zugang</h2>
<form id="user-form" class="app-form" novalidate>
<input type="hidden" id="uf-pending-id" />
<div class="field"><label for="uf-name">Name</label><input id="uf-name" required /></div>
<div class="field"><label for="uf-username">Benutzername</label><input id="uf-username" required /></div>
<div class="field"><label for="uf-email">E-Mail (optional)</label><input id="uf-email" type="email" /></div>
<div class="field">
<label for="uf-role">Rolle</label>
<select id="uf-role"></select>
</div>
<div class="field" id="uf-code-field">
<label>Zugangscode</label>
<div class="btn-row" style="margin:.3rem 0;">
<label class="small"><input type="radio" name="uf-code-mode" value="auto" checked /> Automatisch sicher erstellen</label>
<label class="small"><input type="radio" name="uf-code-mode" value="manual" /> Selbst festlegen</label>
</div>
<input id="uf-code-manual" placeholder="eigener Code" style="display:none;" />
</div>
<div class="field">
<label for="uf-expiry">Gültigkeit</label>
<select id="uf-expiry">
<option value="">Dauerhaft</option>
<option value="7">7 Tage</option>
<option value="30">30 Tage</option>
<option value="90">90 Tage</option>
<option value="date">Bestimmtes Datum…</option>
</select>
<input id="uf-expiry-date" type="date" style="display:none;margin-top:.5rem;" />
</div>
<div class="field"><label for="uf-note">Interne Notiz</label><textarea id="uf-note"></textarea></div>
<button class="btn btn-primary" type="submit" id="uf-submit-btn">Zugang erstellen</button>
<div class="form-status" id="user-form-status"></div>
</form>
<div class="code-reveal-box" id="uf-new-code" style="display:none;"></div>
<button class="btn btn-outline" id="user-modal-close" type="button" style="margin-top:1rem;">Schließen</button>
</div>
</div>
<!-- ===== Modal: Code anzeigen (Re-Auth) ===== -->
<div class="modal-backdrop" id="reveal-modal">
<div class="modal-box" style="max-width:420px;">
<h2>Code anzeigen</h2>
<p class="small muted">Aus Sicherheitsgründen bitte deinen Owner-Code erneut eingeben.</p>
<form id="reveal-form" class="app-form" novalidate>
<div class="field"><label for="reveal-reauth">Dein Owner-Code</label><input id="reveal-reauth" type="password" required /></div>
<button class="btn btn-primary" type="submit">Code anzeigen</button>
<div class="form-status" id="reveal-status"></div>
</form>
<div class="code-reveal-box" id="reveal-code-box" style="display:none;"></div>
<button class="btn btn-outline" id="reveal-modal-close" type="button" style="margin-top:1rem;">Schließen</button>
</div>
</div>
<!-- ===== Modal: Rechte pro Person ===== -->
<div class="modal-backdrop" id="perm-modal">
<div class="modal-box">
<h2>Individuelle Rechte</h2>
<p class="small muted">Ausnahmen wirken zusätzlich zur Rolle. „Rolle" = Standardwert der Rolle übernehmen.</p>
<div id="perm-matrix"></div>
<div class="btn-row">
<button class="btn btn-primary" id="perm-save-btn" type="button">Speichern</button>
<button class="btn btn-outline" id="perm-modal-close" type="button">Abbrechen</button>
</div>
<div class="form-status" id="perm-status"></div>
</div>
</div>
<!-- ===== Modal: Rolle erstellen/bearbeiten ===== -->
<div class="modal-backdrop" id="role-modal">
<div class="modal-box">
<h2 id="role-modal-title">Neue Rolle</h2>
<input type="hidden" id="rf-id" />
<div class="field"><label for="rf-name">Name der Rolle</label><input id="rf-name" required /></div>
<div id="role-perm-matrix" style="margin-top:1rem;"></div>
<div class="btn-row">
<button class="btn btn-primary" id="role-save-btn" type="button">Speichern</button>
<button class="btn btn-outline" id="role-modal-close" type="button">Abbrechen</button>
</div>
<div class="form-status" id="role-form-status"></div>
</div>
</div>
<div id="site-footer"></div>
<script src="assets/js/main.js"></script>
<script src="assets/js/admin-auth.js"></script>
<script src="assets/js/zugaenge.js"></script>
</body>
</html>