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:
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
});
|
||||||
@@ -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 & 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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() },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 || [] });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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
@@ -1,285 +1,130 @@
|
|||||||
/* =====================================================================
|
/* =====================================================================
|
||||||
DOGFATHER UNIVERSE — Bewerbungs-Postfach Worker
|
DOGFATHER UNIVERSE — Interner Bereich 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.
|
|
||||||
|
|
||||||
Zugriffs-Modell (wichtig für's Verständnis):
|
Setzt "Geschuetzter_Zugang_Rollen_Benutzerverwaltung.pdf" um:
|
||||||
- Owner-Code (POSTFACH_CODE, Secret) = Dogi selbst. Sieht alle
|
Session-basierte Anmeldung, verschlüsselte (reversible) Code-Speicherung
|
||||||
Bewerbungen UND kann Helfer-Codes mit Rollen (z.B. "Rechte Hand",
|
NUR für den Owner einsehbar, vollständig konfigurierbare Rollen mit
|
||||||
"Linke Hand") anlegen/entziehen.
|
granularen Rechten, individuelle Personen-Ausnahmen, Aktivitätsprotokoll,
|
||||||
- Helfer-Codes (von Dogi vergeben) = sehen NUR die Bewerbungen.
|
Login-Lockout nach Fehlversuchen.
|
||||||
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.
|
|
||||||
|
|
||||||
Endpoints:
|
Architektur:
|
||||||
POST /submit { type, data } -> Bewerbung speichern
|
src/lib/crypto.js Hashing, AES-GCM (Code-Verschlüsselung), IDs
|
||||||
POST /postfach { code } -> Bewerbungen ansehen
|
src/lib/auth.js Login-Prüfung, Sessions, Rate-Limiting
|
||||||
POST /admin/roles/list { ownerCode } -> Helfer-Codes auflisten (nur Owner)
|
src/lib/permissions.js Vollständiger Permission-Katalog
|
||||||
POST /admin/roles/create { ownerCode, role, label } -> neuen Helfer-Code erzeugen (nur Owner)
|
src/lib/audit.js Aktivitätsprotokoll schreiben
|
||||||
POST /admin/roles/revoke { ownerCode, keyId } -> Helfer-Code entziehen (nur Owner)
|
src/lib/http.js CORS/JSON-Helfer
|
||||||
GET /live-status -> öffentlich: {live, updatedAt}
|
src/routes/*.js Endpoint-Handler pro Bereich
|
||||||
POST /live-status/set { ownerCode, live } -> Live-Status umschalten (nur Owner)
|
|
||||||
GET /health -> einfacher Check ob der Worker läuft
|
|
||||||
|
|
||||||
Secrets/Bindings (siehe wrangler.toml + README.md):
|
Bindings (siehe wrangler.toml):
|
||||||
KV Binding: BEWERBUNGEN (Cloudflare KV Namespace, wird auch für Rollen genutzt)
|
D1: DB (Datenbank für Users/Rollen/Sessions/Audit/Bewerbungen)
|
||||||
Secret: POSTFACH_CODE (mit `wrangler secret put POSTFACH_CODE` setzen)
|
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";
|
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) {
|
async function handleLiveStatusGet(env) {
|
||||||
const raw = await env.BEWERBUNGEN.get(LIVE_STATUS_KEY);
|
const raw = await env.BEWERBUNGEN.get(LIVE_STATUS_KEY);
|
||||||
const data = raw ? JSON.parse(raw) : { live: false, updatedAt: null };
|
const data = raw ? JSON.parse(raw) : { live: false, updatedAt: null };
|
||||||
return json({ ok: true, ...data });
|
return json({ ok: true, ...data });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLiveStatusSet(request, env) {
|
async function handleLiveStatusSet(request, env, session) {
|
||||||
let body;
|
if (!session?.isOwner) return json({ ok: false, error: "Nur Dogi darf den Live-Status ändern." }, 403);
|
||||||
try {
|
const body = await request.json().catch(() => ({}));
|
||||||
body = await request.json();
|
const record = { live: !!body.live, updatedAt: new Date().toISOString() };
|
||||||
} 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() };
|
|
||||||
await env.BEWERBUNGEN.put(LIVE_STATUS_KEY, JSON.stringify(record));
|
await env.BEWERBUNGEN.put(LIVE_STATUS_KEY, JSON.stringify(record));
|
||||||
|
|
||||||
return json({ ok: true, ...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 {
|
export default {
|
||||||
async fetch(request, env) {
|
async fetch(request, env) {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
|
const routeKey = `${request.method} ${url.pathname}`;
|
||||||
|
|
||||||
if (request.method === "OPTIONS") {
|
if (request.method === "OPTIONS") {
|
||||||
return new Response(null, { headers: corsHeaders() });
|
return new Response(null, { headers: corsHeaders() });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname === "/health") {
|
if (url.pathname === "/health" && request.method === "GET") {
|
||||||
return json({ ok: true, service: "dogfather-universe-postfach" });
|
return json({ ok: true, service: "dogfather-universe-interner-bereich" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname === "/submit" && request.method === "POST") {
|
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") {
|
if (url.pathname === "/live-status" && request.method === "GET") {
|
||||||
return handleLiveStatusGet(env);
|
return handleLiveStatusGet(env);
|
||||||
}
|
}
|
||||||
|
if (url.pathname === "/auth/login" && request.method === "POST") {
|
||||||
|
return login(request, env);
|
||||||
|
}
|
||||||
|
|
||||||
if (url.pathname === "/live-status/set" && request.method === "POST") {
|
const handler = AUTHED_ROUTES[routeKey];
|
||||||
return handleLiveStatusSet(request, env);
|
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);
|
return json({ ok: false, error: "Not found." }, 404);
|
||||||
|
|||||||
@@ -10,3 +10,8 @@ compatibility_date = "2024-01-01"
|
|||||||
[[kv_namespaces]]
|
[[kv_namespaces]]
|
||||||
binding = "BEWERBUNGEN"
|
binding = "BEWERBUNGEN"
|
||||||
id = "f8f8a692150f452cab5e334743194fe2"
|
id = "f8f8a692150f452cab5e334743194fe2"
|
||||||
|
|
||||||
|
[[d1_databases]]
|
||||||
|
binding = "DB"
|
||||||
|
database_name = "dogfather-universe-db"
|
||||||
|
database_id = "62a0a329-7932-4739-8244-518fc9f31b56"
|
||||||
|
|||||||
+30
-290
@@ -11,6 +11,16 @@
|
|||||||
<link rel="stylesheet" href="assets/css/theme-dogfather.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-hasidog.css" />
|
||||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="site-header"></div>
|
<div id="site-header"></div>
|
||||||
@@ -24,8 +34,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section-tight">
|
<!-- ===== Login-Gate ===== -->
|
||||||
<div class="container" style="max-width:520px;" id="gate-container">
|
<section class="section-tight" id="gate-container">
|
||||||
|
<div class="container" style="max-width:520px;">
|
||||||
<form id="code-gate" class="app-form" novalidate>
|
<form id="code-gate" class="app-form" novalidate>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="code-input">Zugangscode</label>
|
<label for="code-input">Zugangscode</label>
|
||||||
@@ -34,37 +45,21 @@
|
|||||||
<button class="btn btn-primary" type="submit">Postfach öffnen</button>
|
<button class="btn btn-primary" type="submit">Postfach öffnen</button>
|
||||||
<div class="form-status" id="gate-status"></div>
|
<div class="form-status" id="gate-status"></div>
|
||||||
</form>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ===== Eingeloggt: Kopfzeile mit Rolle + Logout ===== -->
|
<!-- ===== Session-Leiste ===== -->
|
||||||
<section class="section-tight" id="session-bar" style="display:none;padding-top:0;">
|
<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;">
|
<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>
|
<span class="badge rank-1" id="role-badge">Eingeloggt</span>
|
||||||
<button class="btn btn-outline" id="logout-btn" type="button">Abmelden</button>
|
<div class="btn-row" style="margin:0;">
|
||||||
</div>
|
<a class="btn btn-outline" href="zugaenge.html" id="team-link" style="display:none;">Zugänge & Team</a>
|
||||||
</section>
|
<button class="btn btn-outline" id="logout-btn" type="button">Abmelden</button>
|
||||||
|
|
||||||
<!-- ===== 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>
|
</div>
|
||||||
<div id="inbox-list" class="grid grid-2"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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;">
|
<section class="section-tight" id="live-toggle-section" style="display:none;">
|
||||||
<div class="container text-center">
|
<div class="container text-center">
|
||||||
<h2>Live-Status</h2>
|
<h2>Live-Status</h2>
|
||||||
@@ -79,279 +74,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ===== Rollen-Verwaltung (NUR für Dogi/Owner sichtbar) ===== -->
|
<!-- ===== Mailbox ===== -->
|
||||||
<section id="admin-section" style="display:none;">
|
<section class="section-tight" id="inbox-section" style="display:none;">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="text-center">Rollen verwalten</h2>
|
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:1rem;margin-bottom:1rem;">
|
||||||
<p class="lead text-center" style="max-width:60ch;margin:0 auto;">
|
<h2 style="margin:0;">Eingegangene Bewerbungen</h2>
|
||||||
Vergib Codes an Rechte Hand, Linke Hand oder wen du sonst reinlassen willst. Diese Codes
|
<button class="btn btn-outline" id="refresh-btn" type="button">Aktualisieren</button>
|
||||||
zeigen <strong>ausschließlich die Bewerbungen</strong> — nichts an der Website selbst kann
|
</div>
|
||||||
damit verändert oder gelöscht werden, dafür gibt es hier schlicht keine Funktion.
|
<div class="filter-row">
|
||||||
</p>
|
<label class="small"><input type="checkbox" id="show-archived" /> Archivierte anzeigen</label>
|
||||||
|
</div>
|
||||||
<form id="role-create-form" class="app-form" style="max-width:520px;margin:1.6rem auto;" novalidate>
|
<div id="inbox-list" class="grid grid-2"></div>
|
||||||
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div id="site-footer"></div>
|
<div id="site-footer"></div>
|
||||||
<script src="assets/js/main.js"></script>
|
<script src="assets/js/main.js"></script>
|
||||||
<script>
|
<script src="assets/js/admin-auth.js"></script>
|
||||||
// TODO: nach dem Deploy des Cloudflare Workers hier die Worker-URL ohne
|
<script src="assets/js/postfach.js"></script>
|
||||||
// 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>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+201
@@ -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>
|
||||||
Reference in New Issue
Block a user