Interne Bewerbungs-Rückseiten (Postfach, Zugänge, Login) komplett entfernt

Auf Wunsch: die öffentlichen Bewerbungsseiten (bewerben*.html) bleiben
unverändert bestehen, aber die internen "Rückseiten", wo die Bewerbungen
verwaltet wurden, werden nicht mehr gebraucht und sind komplett raus:

- postfach.html (Bewerbungs-Postfach) und zugaenge.html (Zugänge & Rollen)
  gelöscht
- zugehörige tote Skripte gelöscht: postfach.js, zugaenge.js, admin-auth.js
- "Login"-Knopf in der Navigation entfernt
- "Intern"-Block im Footer (Bewerbungs-Postfach / Zugänge & Rollen) entfernt
- KEIN_NAHTLOSER_WECHSEL-Ausnahmeliste geleert (referenzierte nur die
  jetzt gelöschten Seiten)
- .nav-login CSS-Regeln entfernt (totes CSS)
- robots.txt: Disallow-Zeile für /postfach.html entfernt

Der Cloudflare-Worker (Backend/D1) und die Bewerbungsformulare selbst
bleiben unangetastet — Bewerbungen können weiterhin eingereicht werden.
This commit is contained in:
2026-08-01 11:04:01 +02:00
parent 888de712de
commit e910017e51
8 changed files with 5 additions and 1269 deletions
-81
View File
@@ -1,81 +0,0 @@
/* =====================================================================
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 };
}
+5 -15
View File
@@ -95,11 +95,6 @@
<nav class="nav-links">
${NAV.map(renderNavItem).join("")}
<a class="nav-cta" href="bewerben.html"><span>Bewerben 🩵</span></a>
<!-- Interner Einstieg: klein, aber auf JEDER Seite oben sichtbar,
damit Dogi und das Team nie danach suchen müssen. -->
<a class="nav-login" href="postfach.html" title="Interner Login — Postfach &amp; Zugänge">
<span aria-hidden="true">🔒</span> Login
</a>
</nav>
</div>
</header>`;
@@ -175,16 +170,11 @@
<li><a href="bewerben.html">Bewerben</a></li>
<li><a href="kontakt.html">Kontakt</a></li>
</ul>
<h4 style="margin-top:1.4rem;">Intern 🔒</h4>
<ul>
<li><a href="postfach.html">Bewerbungs-Postfach</a></li>
<li><a href="zugaenge.html">Zugänge &amp; Rollen</a></li>
</ul>
</div>
</div>
<div class="container footer-bottom">
<span>© ${year} Dogfather Universe. Team Dogi ist Familie 🩵</span>
<span><a href="kontakt.html">Impressum &amp; Datenschutz</a> · <a href="postfach.html">🔒 Interner Login</a></span>
<span><a href="kontakt.html">Impressum &amp; Datenschutz</a></span>
</div>
</footer>`;
}
@@ -286,14 +276,14 @@
Inhaltsbereich <main> aus. Kopfzeile, Fußzeile und der Musik-Knopf
bleiben stehen — die Musik läuft einfach weiter.
Wichtig für die Sicherheit: Die internen Seiten (Postfach, Zugänge)
sind bewusst AUSGENOMMEN und laden weiterhin ganz normal. An Login,
Sitzung und Rechten ändert sich dadurch nichts.
Die Liste unten kann bei Bedarf wieder Seiten aufnehmen, die bewusst
NICHT nahtlos geladen werden sollen (z.B. künftige interne Bereiche
mit eigenem Login) — aktuell gibt es keine solche Seite mehr.
Absicherung: Geht beim Holen irgendetwas schief, wird ganz normal
zur Zielseite navigiert. Schlimmstenfalls ist es also wie vorher.
=================================================================== */
const KEIN_NAHTLOSER_WECHSEL = ["postfach.html", "zugaenge.html"];
const KEIN_NAHTLOSER_WECHSEL = [];
/* Seiteneigene Skripte melden sich fast alle über "DOMContentLoaded" an.
Nach dem ersten Laden ist dieses Ereignis längst vorbei — ein später
-266
View File
@@ -1,266 +0,0 @@
/* =====================================================================
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 ----------------
Der Status kommt automatisch von TikTok (Cron im Worker, jede Minute).
Die Handschaltung ist nur ein zeitlich begrenzter Vorrang.
------------------------------------------------------------------- */
function formatSince(iso) {
if (!iso) return null;
const diffMin = Math.floor((Date.now() - new Date(iso).getTime()) / 60000);
if (diffMin < 1) return "gerade eben";
if (diffMin === 1) return "vor 1 Minute";
if (diffMin < 60) return `vor ${diffMin} Minuten`;
const h = Math.floor(diffMin / 60);
return h === 1 ? "vor 1 Stunde" : `vor ${h} Stunden`;
}
function renderLiveStatus(data) {
const live = !!(data && data.live);
const dot = $("admin-live-dot");
dot.classList.remove("is-unknown");
dot.classList.toggle("is-live", live);
$("admin-live-text").textContent = live ? "Jetzt live" : "Gerade offline";
const manual = data && data.source === "manuell";
$("go-auto-btn").style.display = manual ? "inline-flex" : "none";
const lines = [];
if (manual) {
lines.push("⚠️ Von Hand gesetzt — die Automatik ist gerade übersteuert.");
if (data.overrideUntil) {
const until = new Date(data.overrideUntil);
lines.push(`Läuft aus um ${until.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr.`);
}
if (data.autoLive !== null && data.autoLive !== undefined) {
lines.push(`TikTok meldet gerade: ${data.autoLive ? "live" : "offline"}.`);
}
} else {
if (data && data.title && live) lines.push(`Titel: ${data.title}`);
const since = formatSince(data && data.autoCheckedAt);
if (since) lines.push(`Automatisch geprüft ${since}.`);
if (data && data.autoHealthy === false) {
lines.push("⚠️ Der letzte Abruf bei TikTok hat nicht geklappt — angezeigt wird der letzte bekannte Stand.");
}
}
$("admin-live-detail").innerHTML = lines.join("<br>");
}
async function loadLiveStatus() {
try {
const res = await fetch(`${API_BASE_URL}/live-status`, { cache: "no-store" });
const data = await res.json();
if (res.ok && data.ok) renderLiveStatus(data);
} catch {
/* keine Blockade */
}
}
async function setLiveStatus(live) {
const res = await apiCall(API_BASE_URL, "/live-status/set", { live });
if (res.ok) renderLiveStatus(res);
}
async function backToAutoLive() {
const res = await apiCall(API_BASE_URL, "/live-status/set", { auto: true });
if (res.ok) renderLiveStatus(res);
}
/* ---------------- 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));
$("go-auto-btn").addEventListener("click", backToAutoLive);
// Anzeige alle 30 Sekunden auffrischen, damit man die Automatik arbeiten sieht.
setInterval(() => {
if (!document.hidden && getSession()) loadLiveStatus();
}, 30000);
});
-543
View File
@@ -1,543 +0,0 @@
/* =====================================================================
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";
// "Mein Zugang" (eigenen Owner-Code ändern) gibt es nur für Dogi selbst.
const myCodeTabBtn = document.querySelector('[data-tab="tab-mycode"]');
if (myCodeTabBtn) myCodeTabBtn.style.display = session.isOwner ? "" : "none";
loadRoles().then(loadUsers);
if (session.isOwner || sessionHasPermission(session, "SETTINGS_VIEW_ACTIVITY_LOG")) loadAudit();
}
function showGate() {
$("gate-container").style.display = "block";
$("session-bar").style.display = "none";
$("main-area").style.display = "none";
}
/* ---------------- Tabs ---------------- */
function setupTabs() {
document.querySelectorAll(".tab-btn").forEach((btn) => {
btn.addEventListener("click", () => {
document.querySelectorAll(".tab-btn").forEach((b) => b.classList.remove("active"));
document.querySelectorAll(".tab-panel").forEach((p) => p.classList.remove("active"));
btn.classList.add("active");
$(btn.dataset.tab).classList.add("active");
});
});
}
/* ---------------- Users ---------------- */
async function loadUsers() {
const res = await apiCall(API_BASE_URL, "/admin/users/list", {});
if (!res.ok) return;
currentUsers = res.users;
renderUsers();
}
function roleNameById(id) {
const r = currentRoles.find((x) => x.id === id);
return r ? r.name : "—";
}
function renderUsers() {
const session = getSession();
const container = $("users-list");
if (!currentUsers.length) {
container.innerHTML = `<p class="muted">Noch keine Zugänge angelegt.</p>`;
return;
}
container.innerHTML = currentUsers
.map((u) => {
const statusClass = u.status === "active" ? "active" : u.status === "pending" ? "pending" : "locked";
const isOwner = session.isOwner;
const canLock = isOwner || sessionHasPermission(session, "TEAM_LOCK_USER");
const canAssignRole = isOwner || sessionHasPermission(session, "TEAM_ASSIGN_ROLE");
const canEditPerms = isOwner || sessionHasPermission(session, "TEAM_EDIT_PERMISSIONS");
const actions = [];
if (u.status === "pending" && isOwner) {
actions.push(`<button class="btn btn-primary finalize-btn" data-id="${u.id}">Freigeben &amp; Code erstellen</button>`);
}
if (isOwner && u.status !== "pending") {
actions.push(`<button class="btn btn-outline reveal-btn" data-id="${u.id}">Code anzeigen</button>`);
actions.push(`<button class="btn btn-outline newcode-btn" data-id="${u.id}">Neuer Code</button>`);
actions.push(`<button class="btn btn-outline endsessions-btn" data-id="${u.id}">Alle Geräte abmelden</button>`);
}
if (canLock && u.status !== "pending") {
actions.push(
u.status === "active"
? `<button class="btn btn-outline lock-btn" data-id="${u.id}">Sperren</button>`
: `<button class="btn btn-outline unlock-btn" data-id="${u.id}">Entsperren</button>`
);
}
if (canAssignRole) {
actions.push(`<button class="btn btn-outline role-btn" data-id="${u.id}">Rolle ändern</button>`);
}
if (canEditPerms) {
actions.push(`<button class="btn btn-outline perms-btn" data-id="${u.id}">Rechte verwalten</button>`);
}
if (isOwner) {
actions.push(`<button class="btn btn-outline delete-btn" data-id="${u.id}" style="color:var(--accent);">Löschen</button>`);
}
return `
<div class="user-row">
<div><strong>${u.name}</strong><br><span class="small muted">@${u.username}${u.email ? " · " + u.email : ""}</span></div>
<div>${roleNameById(u.roleId)}</div>
<div><span class="status-pill ${statusClass}">${u.status}</span></div>
<div class="small muted">${u.expiresAt ? new Date(u.expiresAt).toLocaleDateString("de-DE") : "dauerhaft"}</div>
<div class="small muted">${u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleString("de-DE") : "nie"}</div>
<div class="actions">${actions.join("")}</div>
</div>`;
})
.join("");
container.querySelectorAll(".lock-btn").forEach((b) => b.addEventListener("click", () => userAction("/admin/users/lock", b.dataset.id)));
container.querySelectorAll(".unlock-btn").forEach((b) => b.addEventListener("click", () => userAction("/admin/users/unlock", b.dataset.id)));
container.querySelectorAll(".delete-btn").forEach((b) =>
b.addEventListener("click", () => {
if (confirm("Diesen Zugang wirklich vollständig löschen?")) userAction("/admin/users/delete", b.dataset.id);
})
);
container.querySelectorAll(".endsessions-btn").forEach((b) => b.addEventListener("click", () => userAction("/admin/users/end-sessions", b.dataset.id, "Alle Geräte wurden abgemeldet.")));
container.querySelectorAll(".reveal-btn").forEach((b) => b.addEventListener("click", () => openRevealModal(b.dataset.id)));
container.querySelectorAll(".newcode-btn").forEach((b) => b.addEventListener("click", () => newCodeForUser(b.dataset.id)));
container.querySelectorAll(".finalize-btn").forEach((b) => b.addEventListener("click", () => openUserModal({ pendingUserId: b.dataset.id })));
container.querySelectorAll(".role-btn").forEach((b) => b.addEventListener("click", () => changeUserRole(b.dataset.id)));
container.querySelectorAll(".perms-btn").forEach((b) => b.addEventListener("click", () => openPermModal(b.dataset.id)));
}
async function userAction(path, id, successMsg) {
const res = await apiCall(API_BASE_URL, path, { id });
if (res.ok) {
if (successMsg) alert(successMsg);
loadUsers();
} else {
alert(res.error || "Fehler.");
}
}
async function newCodeForUser(id) {
if (!confirm("Neuen Code erstellen? Der alte Code funktioniert danach sofort nicht mehr.")) return;
const res = await apiCall(API_BASE_URL, "/admin/users/set-code", { id });
if (res.ok) {
alert(`Neuer Code: ${res.code}\n\nJetzt sichern/weitergeben — wird nirgends mehr angezeigt!`);
} else {
alert(res.error || "Fehler.");
}
}
function changeUserRole(id) {
const options = currentRoles.map((r) => `${r.name} (${r.id})`).join("\n");
const pick = prompt(`Neue Rolle für diese Person — Rollenname eingeben:\n\nVerfügbar:\n${currentRoles.map((r) => r.name).join(", ")}`);
if (pick === null) return;
const role = currentRoles.find((r) => r.name.toLowerCase() === pick.trim().toLowerCase());
if (!role) return alert("Rolle nicht gefunden.");
apiCall(API_BASE_URL, "/admin/users/set-role", { id, roleId: role.id }).then((res) => {
if (res.ok) loadUsers();
else alert(res.error || "Fehler.");
});
}
/* ---------------- Neuer Zugang / Vorbereiten ---------------- */
function openUserModal({ pendingUserId } = {}) {
const session = getSession();
$("user-form").reset();
$("uf-new-code").style.display = "none";
$("uf-pending-id").value = pendingUserId || "";
$("user-modal-title").textContent = pendingUserId ? "Zugang freigeben" : session.isOwner ? "Neuer Zugang" : "Neuen Benutzer vorbereiten";
$("uf-submit-btn").textContent = pendingUserId ? "Freigeben & Code erstellen" : session.isOwner ? "Zugang erstellen" : "Zur Freigabe vorbereiten";
$("uf-code-field").style.display = session.isOwner ? "block" : "none";
const roleSelect = $("uf-role");
roleSelect.innerHTML = currentRoles.map((r) => `<option value="${r.id}">${r.name}</option>`).join("");
if (pendingUserId) {
const u = currentUsers.find((x) => x.id === pendingUserId);
if (u) {
$("uf-name").value = u.name;
$("uf-username").value = u.username;
$("uf-email").value = u.email || "";
if (u.roleId) roleSelect.value = u.roleId;
}
}
$("user-modal").classList.add("open");
}
$("user-modal-close").addEventListener("click", () => $("user-modal").classList.remove("open"));
$("new-user-btn").addEventListener("click", () => openUserModal());
document.querySelectorAll('input[name="uf-code-mode"]').forEach((el) => {
el.addEventListener("change", () => {
$("uf-code-manual").style.display = document.querySelector('input[name="uf-code-mode"]:checked').value === "manual" ? "block" : "none";
});
});
$("uf-expiry").addEventListener("change", () => {
$("uf-expiry-date").style.display = $("uf-expiry").value === "date" ? "block" : "none";
});
$("user-form").addEventListener("submit", async (e) => {
e.preventDefault();
const session = getSession();
const pendingUserId = $("uf-pending-id").value || null;
const isOwner = session.isOwner;
const payload = {
name: $("uf-name").value.trim(),
username: $("uf-username").value.trim(),
email: $("uf-email").value.trim(),
roleId: $("uf-role").value,
note: $("uf-note").value.trim(),
};
const statusEl = $("user-form-status");
if (!isOwner && !pendingUserId) {
const res = await apiCall(API_BASE_URL, "/admin/users/prepare", payload);
if (res.ok) {
statusEl.textContent = "Vorbereitet — Dogi muss den Zugang jetzt noch mit einem Code freigeben.";
statusEl.className = "form-status show success";
loadUsers();
setTimeout(() => $("user-modal").classList.remove("open"), 1800);
} else {
statusEl.textContent = res.error || "Fehler.";
statusEl.className = "form-status show error";
}
return;
}
if (pendingUserId) payload.pendingUserId = pendingUserId;
const mode = document.querySelector('input[name="uf-code-mode"]:checked').value;
if (mode === "manual") payload.code = $("uf-code-manual").value.trim();
const expiryChoice = $("uf-expiry").value;
if (expiryChoice === "date") payload.expiresAt = $("uf-expiry-date").value;
else if (expiryChoice) payload.expiresInDays = Number(expiryChoice);
const res = await apiCall(API_BASE_URL, "/admin/users/finalize", payload);
if (res.ok) {
$("uf-new-code").style.display = "block";
$("uf-new-code").innerHTML = `<strong>Code:</strong> ${res.code}<br><span class="small">Jetzt sichern/weitergeben — wird nirgends mehr angezeigt!</span>`;
statusEl.textContent = "";
statusEl.className = "form-status";
loadUsers();
} else {
statusEl.textContent = res.error || "Fehler.";
statusEl.className = "form-status show error";
}
});
/* ---------------- Code anzeigen (Re-Auth) ---------------- */
let revealTargetId = null;
function openRevealModal(id) {
revealTargetId = id;
$("reveal-form").reset();
$("reveal-code-box").style.display = "none";
$("reveal-status").className = "form-status";
$("reveal-modal").classList.add("open");
}
$("reveal-modal-close").addEventListener("click", () => $("reveal-modal").classList.remove("open"));
$("reveal-form").addEventListener("submit", async (e) => {
e.preventDefault();
const res = await apiCall(API_BASE_URL, "/admin/users/reveal-code", {
id: revealTargetId,
reAuthCode: $("reveal-reauth").value,
});
if (res.ok) {
$("reveal-code-box").style.display = "block";
$("reveal-code-box").textContent = res.code;
$("reveal-status").className = "form-status";
} else {
$("reveal-status").textContent = res.error || "Fehler.";
$("reveal-status").className = "form-status show error";
}
});
/* ---------------- Individuelle Rechte ---------------- */
let permTargetId = null;
function openPermModal(id) {
permTargetId = id;
const user = currentUsers.find((u) => u.id === id);
const role = currentRoles.find((r) => r.id === user?.roleId);
const roleDefaults = role ? role.permissions : [];
$("perm-matrix").innerHTML = PERMISSION_GROUPS.map(
(g) => `
<div class="perm-group-title">${g.label}</div>
<table class="perm-table"><tbody>
${g.keys
.map((k) => {
const inRole = roleDefaults.includes(k);
return `
<tr>
<td>${PERMISSION_LABELS[k]} <span class="small muted">${inRole ? "(in Rolle enthalten)" : ""}</span></td>
<td>
<select data-perm="${k}" style="background:var(--bg-alt);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:.3rem;">
<option value="inherit">Rolle (${inRole ? "erlaubt" : "nicht erlaubt"})</option>
<option value="allow">Erlaubt</option>
<option value="deny">Verboten</option>
</select>
</td>
</tr>`;
})
.join("")}
</tbody></table>`
).join("");
$("perm-status").className = "form-status";
$("perm-modal").classList.add("open");
}
$("perm-modal-close").addEventListener("click", () => $("perm-modal").classList.remove("open"));
$("perm-save-btn").addEventListener("click", async () => {
const overrides = {};
document.querySelectorAll("#perm-matrix select[data-perm]").forEach((sel) => {
if (sel.value === "allow") overrides[sel.dataset.perm] = true;
else if (sel.value === "deny") overrides[sel.dataset.perm] = false;
});
const res = await apiCall(API_BASE_URL, "/admin/users/set-permissions", { id: permTargetId, overrides });
if (res.ok) {
$("perm-modal").classList.remove("open");
} else {
$("perm-status").textContent = res.error || "Fehler.";
$("perm-status").className = "form-status show error";
}
});
/* ---------------- Rollen ---------------- */
async function loadRoles() {
const res = await apiCall(API_BASE_URL, "/admin/roles/list", {});
if (!res.ok) return;
currentRoles = res.roles;
renderRoles();
}
function renderRoles() {
const session = getSession();
const canManage = session.isOwner || sessionHasPermission(session, "TEAM_CREATE_ROLE") || sessionHasPermission(session, "TEAM_EDIT_PERMISSIONS");
const container = $("roles-list");
if (!currentRoles.length) {
container.innerHTML = `<p class="muted">Noch keine Rollen erstellt.</p>`;
return;
}
container.innerHTML = currentRoles
.map(
(r) => `
<div class="card">
<h3>${r.name}</h3>
<p class="small muted">${r.permissions.length} Rechte aktiv</p>
${
canManage
? `<div class="btn-row" style="margin-top:.6rem;">
<button class="btn btn-outline edit-role-btn" data-id="${r.id}">Bearbeiten</button>
<button class="btn btn-outline delete-role-btn" data-id="${r.id}" style="color:var(--accent);">Löschen</button>
</div>`
: ""
}
</div>`
)
.join("");
container.querySelectorAll(".edit-role-btn").forEach((b) => b.addEventListener("click", () => openRoleModal(b.dataset.id)));
container.querySelectorAll(".delete-role-btn").forEach((b) =>
b.addEventListener("click", async () => {
if (!confirm("Rolle wirklich löschen?")) return;
const res = await apiCall(API_BASE_URL, "/admin/roles/delete", { id: b.dataset.id });
if (res.ok) loadRoles();
else alert(res.error || "Fehler.");
})
);
}
function openRoleModal(id) {
const role = id ? currentRoles.find((r) => r.id === id) : null;
$("role-modal-title").textContent = role ? "Rolle bearbeiten" : "Neue Rolle";
$("rf-id").value = id || "";
$("rf-name").value = role ? role.name : "";
$("role-form-status").className = "form-status";
$("role-perm-matrix").innerHTML = PERMISSION_GROUPS.map(
(g) => `
<div class="perm-group-title">${g.label}</div>
${g.keys
.map(
(k) => `
<label class="small" style="display:flex;gap:.5rem;align-items:center;padding:.25rem 0;">
<input type="checkbox" data-perm="${k}" ${role && role.permissions.includes(k) ? "checked" : ""} />
${PERMISSION_LABELS[k]}
</label>`
)
.join("")}`
).join("");
$("role-modal").classList.add("open");
}
$("new-role-btn").addEventListener("click", () => openRoleModal(null));
$("role-modal-close").addEventListener("click", () => $("role-modal").classList.remove("open"));
$("role-save-btn").addEventListener("click", async () => {
const id = $("rf-id").value || null;
const name = $("rf-name").value.trim();
if (!name) return;
const permissions = [...document.querySelectorAll("#role-perm-matrix input[data-perm]:checked")].map((el) => el.dataset.perm);
const res = await apiCall(API_BASE_URL, id ? "/admin/roles/update" : "/admin/roles/create", id ? { id, name, permissions } : { name, permissions });
if (res.ok) {
$("role-modal").classList.remove("open");
loadRoles();
} else {
$("role-form-status").textContent = res.error || "Fehler.";
$("role-form-status").className = "form-status show error";
}
});
/* ---------------- Aktivitätsprotokoll ---------------- */
async function loadAudit() {
const res = await apiCall(API_BASE_URL, "/admin/audit/list", { limit: 150 });
if (!res.ok) return;
$("audit-list").innerHTML =
res.entries
.map(
(e) => `
<div class="audit-row">
<strong>${e.actor}</strong> — ${e.action}${e.target ? ` <span class="small muted">(${e.target})</span>` : ""}
<span class="small muted" style="float:right;">${new Date(e.created_at).toLocaleString("de-DE")}</span>
</div>`
)
.join("") || `<p class="muted">Noch keine Einträge.</p>`;
}
/* ---------------- Login / Logout ---------------- */
document.addEventListener("DOMContentLoaded", () => {
setupTabs();
const existing = getSession();
if (existing) showApp(existing);
else showGate();
$("code-gate").addEventListener("submit", async (e) => {
e.preventDefault();
const status = $("gate-status");
status.className = "form-status show";
status.textContent = "Prüfe Code …";
const res = await doLogin(API_BASE_URL, $("code-input").value);
if (res.ok) {
status.className = "form-status";
showApp(getSession());
} else {
status.textContent = res.error || "Falscher Code.";
status.className = "form-status show error";
}
});
$("logout-btn").addEventListener("click", async () => {
await doLogout(API_BASE_URL);
showGate();
});
// Eigenen Hauptadministrator-Code ändern
$("owner-code-form").addEventListener("submit", async (e) => {
e.preventDefault();
const status = $("owner-code-status");
const currentCode = $("oc-current").value;
const newCode = $("oc-new").value;
const repeat = $("oc-new2").value;
const fail = (msg) => {
status.textContent = msg;
status.className = "form-status show error";
};
if (newCode !== repeat) return fail("Die beiden neuen Codes sind nicht gleich.");
if (newCode.length < 10) return fail("Der neue Code muss mindestens 10 Zeichen haben.");
status.textContent = "Wird geändert …";
status.className = "form-status show";
const res = await apiCall(API_BASE_URL, "/admin/owner/set-code", { currentCode, newCode });
if (res.ok) {
status.textContent = "Erledigt — ab sofort gilt dein neuer Code. Gut merken! 🩵";
status.className = "form-status show success";
$("owner-code-form").reset();
} else {
fail(res.error || "Hat nicht geklappt.");
}
});
// 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");
});
});
});