625 lines
40 KiB
JavaScript
625 lines
40 KiB
JavaScript
"use strict";
|
||
|
||
// ------------------------------------------------------------------ Grundlagen
|
||
const $ = (sel, el = document) => el.querySelector(sel);
|
||
const esc = (v) => String(v ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||
const state = { token: null, me: null, users: [], timer: null };
|
||
|
||
try { state.token = localStorage.getItem("proxy_token"); } catch (_) { /* ignore */ }
|
||
|
||
async function api(path, opts = {}) {
|
||
const headers = { ...(opts.headers || {}) };
|
||
if (state.token) headers.Authorization = "Bearer " + state.token;
|
||
let body = opts.body;
|
||
if (body && typeof body !== "string") { body = JSON.stringify(body); headers["Content-Type"] = "application/json"; }
|
||
const res = await fetch(path, { ...opts, headers, body });
|
||
if (res.status === 401 && !path.startsWith("/api/auth/login")) { logout(true); throw new Error("Sitzung abgelaufen"); }
|
||
if (opts.raw) { if (!res.ok) throw new Error(res.statusText); return res; }
|
||
const data = res.headers.get("content-type")?.includes("json") ? await res.json() : await res.text();
|
||
if (!res.ok) {
|
||
let msg = data?.detail ?? res.statusText;
|
||
if (Array.isArray(msg)) msg = msg.map((d) => `${(d.loc || []).slice(1).join(".")}: ${d.msg}`).join("; ");
|
||
throw new Error(msg);
|
||
}
|
||
return data;
|
||
}
|
||
|
||
function toast(msg, err = false) {
|
||
const t = $("#toast");
|
||
t.textContent = msg;
|
||
t.className = "toast" + (err ? " err" : "");
|
||
t.hidden = false;
|
||
clearTimeout(toast._t);
|
||
toast._t = setTimeout(() => (t.hidden = true), err ? 6000 : 2800);
|
||
}
|
||
|
||
const fmtBytes = (n) => {
|
||
n = Number(n) || 0;
|
||
const u = ["B", "KB", "MB", "GB", "TB"];
|
||
let i = 0;
|
||
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
||
return (i ? n.toFixed(n < 10 ? 1 : 0) : n) + " " + u[i];
|
||
};
|
||
const fmtNum = (n) => Number(n || 0).toLocaleString("de-DE");
|
||
const fmtTime = (ts) => ts ? new Date(ts * 1000).toLocaleString("de-DE") : "–";
|
||
const fmtDur = (s) => { s = Number(s) || 0; if (s < 60) return s.toFixed(1) + " s"; if (s < 3600) return Math.floor(s / 60) + " min"; return (s / 3600).toFixed(1) + " h"; };
|
||
const ago = (ts) => { if (!ts) return "nie"; const s = Date.now() / 1000 - ts; if (s < 60) return "gerade eben"; if (s < 3600) return `vor ${Math.floor(s / 60)} min`; if (s < 86400) return `vor ${Math.floor(s / 3600)} h`; return `vor ${Math.floor(s / 86400)} Tagen`; };
|
||
|
||
const STATUS = {
|
||
ok: ["ok", "OK"], blocked: ["bad", "Gesperrt"], error: ["warn", "Fehler"], auth_failed: ["bad", "Login fehlgeschl."],
|
||
};
|
||
const statusBadge = (s) => { const [c, l] = STATUS[s] || ["", s]; return `<span class="badge ${c}">${esc(l)}</span>`; };
|
||
const PROTO = { http: "HTTP", https: "HTTPS", connect: "CONNECT", socks: "SOCKS5", "socks-udp": "SOCKS5 UDP", "fwd-tcp": "Weiterl. TCP", "fwd-udp": "Weiterl. UDP" };
|
||
const protoBadge = (p) => `<span class="badge info">${esc(PROTO[p] || p || "–")}</span>`;
|
||
const policyLabel = { inherit: "Wie global", allow: "Alles erlauben (Blacklist)", deny: "Alles sperren (Whitelist)" };
|
||
|
||
// ------------------------------------------------------------------ Modal
|
||
const KEEP_OPEN = Symbol("keep");
|
||
|
||
// onSubmit(data) darf werfen (Fehler wird angezeigt) oder KEEP_OPEN liefern (Dialog bleibt offen).
|
||
function modal(title, fieldsHtml, { submit = "Speichern", danger = false, onSubmit = null } = {}) {
|
||
const dlg = $("#modal");
|
||
const form = $("#modal-form");
|
||
form.innerHTML = `<h2>${esc(title)}</h2>${fieldsHtml}
|
||
<p class="error" id="modal-error"></p>
|
||
<div class="modal-actions">
|
||
<button class="btn" value="cancel" formnovalidate>${submit ? "Abbrechen" : "Schließen"}</button>
|
||
${submit ? `<button class="btn ${danger ? "danger" : "primary"}" value="ok">${esc(submit)}</button>` : ""}
|
||
</div>`;
|
||
dlg.showModal();
|
||
return new Promise((resolve) => {
|
||
let busy = false;
|
||
form.onsubmit = async (e) => {
|
||
if (e.submitter?.value !== "ok") return; // Abbrechen schließt per method=dialog
|
||
e.preventDefault();
|
||
if (busy) return;
|
||
const data = {};
|
||
for (const el of form.elements) if (el.name) data[el.name] = el.type === "checkbox" ? el.checked : el.value;
|
||
if (!onSubmit) { resolve(data); dlg.close(); return; }
|
||
busy = true;
|
||
$("#modal-error").textContent = "";
|
||
try {
|
||
const out = await onSubmit(data);
|
||
if (out !== KEEP_OPEN) { resolve(out ?? true); dlg.close(); }
|
||
} catch (err) { $("#modal-error").textContent = err.message; }
|
||
busy = false;
|
||
};
|
||
dlg.onclose = () => resolve(null);
|
||
});
|
||
}
|
||
|
||
const modalLoop = (title, html, action, opts = {}) => modal(title, html, { ...opts, onSubmit: action });
|
||
|
||
const confirmBox = (text, submit = "Löschen") => modal("Bitte bestätigen", `<p>${esc(text)}</p>`, { submit, danger: true });
|
||
|
||
// ------------------------------------------------------------------ Navigation
|
||
const PAGES_ADMIN = [
|
||
["dashboard", "◧", "Übersicht"], ["users", "☺", "Benutzer"], ["rules", "⛨", "Regeln"],
|
||
["forwards", "⇆", "Weiterleitungen"], ["history", "☰", "Verlauf"], ["connections", "⚡", "Live-Verbindungen"],
|
||
["settings", "⚙", "Einstellungen"], ["api", "⌘", "API"],
|
||
];
|
||
const PAGES_USER = [["myhistory", "☰", "Mein Verlauf"], ["help", "?", "Einrichtung"]];
|
||
|
||
function renderNav() {
|
||
const pages = state.me.is_admin ? PAGES_ADMIN : PAGES_USER;
|
||
const current = location.hash.slice(1).split("?")[0] || pages[0][0];
|
||
$("#nav").innerHTML = pages.map(([id, ico, label]) =>
|
||
`<a href="#${id}" class="${id === current ? "active" : ""}"><span class="ico">${ico}</span>${label}</a>`).join("");
|
||
}
|
||
|
||
async function route() {
|
||
clearInterval(state.timer);
|
||
const pages = state.me.is_admin ? PAGES_ADMIN : PAGES_USER;
|
||
const [page, query] = location.hash.slice(1).split("?");
|
||
const id = pages.some((p) => p[0] === page) ? page : pages[0][0];
|
||
renderNav();
|
||
const params = new URLSearchParams(query || "");
|
||
try { await VIEWS[id]($("#main"), params); }
|
||
catch (e) { $("#main").innerHTML = `<div class="card error">${esc(e.message)}</div>`; }
|
||
}
|
||
|
||
const head = (title, sub = "", actions = "") =>
|
||
`<div class="page-head"><div><h1>${esc(title)}</h1>${sub ? `<p>${sub}</p>` : ""}</div><div class="toolbar" style="margin:0">${actions}</div></div>`;
|
||
|
||
async function loadUsers() { state.users = await api("/api/users"); return state.users; }
|
||
const userOptions = (selected, withGlobal = true) =>
|
||
(withGlobal ? `<option value="">Global (alle Benutzer)</option>` : "") +
|
||
state.users.map((u) => `<option value="${u.id}" ${String(selected) === String(u.id) ? "selected" : ""}>${esc(u.username)}</option>`).join("");
|
||
|
||
// ------------------------------------------------------------------ Diagramm
|
||
function barChart(el, points, bucket) {
|
||
if (!points.length) { el.innerHTML = `<div class="empty">Noch keine Daten im Zeitraum</div>`; return; }
|
||
const W = Math.max(el.clientWidth, 300), H = 180, padL = 40, padB = 22, padT = 8;
|
||
const max = Math.max(...points.map((p) => p.n), 1);
|
||
const step = (W - padL) / points.length;
|
||
const bw = Math.max(Math.min(step - 2, 28), 2);
|
||
const y = (v) => padT + (H - padT - padB) * (1 - v / max);
|
||
const ticks = [0, Math.round(max / 2), max];
|
||
const fmtLabel = (ts) => bucket >= 86400
|
||
? new Date(ts * 1000).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit" })
|
||
: new Date(ts * 1000).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
||
const every = Math.ceil(points.length / Math.floor((W - padL) / 60));
|
||
let svg = `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="Anfragen pro ${bucket >= 86400 ? "Tag" : "Stunde"}">`;
|
||
for (const t of ticks) svg += `<line class="grid-line" x1="${padL}" x2="${W}" y1="${y(t)}" y2="${y(t)}"/><text class="axis" x="${padL - 6}" y="${y(t) + 4}" text-anchor="end">${fmtNum(t)}</text>`;
|
||
points.forEach((p, i) => {
|
||
const x = padL + i * step + (step - bw) / 2;
|
||
const top = y(p.n), h = Math.max(H - padB - top, p.n ? 1 : 0);
|
||
const r = Math.min(4, bw / 2, h);
|
||
// oben abgerundet, unten an der Grundlinie gerade
|
||
const path = h > 0 ? `M${x},${top + h} V${top + r} Q${x},${top} ${x + r},${top} H${x + bw - r} Q${x + bw},${top} ${x + bw},${top + r} V${top + h} Z` : "";
|
||
svg += `<path class="bar" data-i="${i}" d="${path}"/>`;
|
||
svg += `<rect data-i="${i}" x="${padL + i * step}" y="${padT}" width="${step}" height="${H - padT - padB}" fill="transparent"/>`;
|
||
if (i % every === 0) svg += `<text class="axis" x="${x + bw / 2}" y="${H - 6}" text-anchor="middle">${fmtLabel(p.b)}</text>`;
|
||
});
|
||
svg += "</svg>";
|
||
el.innerHTML = svg + `<div class="chart-tip" hidden></div>`;
|
||
const tip = $(".chart-tip", el);
|
||
el.querySelector("svg").addEventListener("mousemove", (e) => {
|
||
const i = e.target.dataset?.i;
|
||
el.querySelectorAll(".bar.hover").forEach((b) => b.classList.remove("hover"));
|
||
if (i === undefined) { tip.hidden = true; return; }
|
||
const p = points[i];
|
||
el.querySelector(`.bar[data-i="${i}"]`)?.classList.add("hover");
|
||
const rect = el.getBoundingClientRect();
|
||
tip.hidden = false;
|
||
tip.style.left = (e.clientX - rect.left) + "px";
|
||
tip.style.top = (e.clientY - rect.top) + "px";
|
||
tip.innerHTML = `<b>${esc(bucket >= 86400 ? new Date(p.b * 1000).toLocaleDateString("de-DE") : fmtTime(p.b))}</b><br>${fmtNum(p.n)} Anfragen · ${fmtNum(p.blocked)} gesperrt`;
|
||
});
|
||
el.querySelector("svg").addEventListener("mouseleave", () => { tip.hidden = true; el.querySelectorAll(".bar.hover").forEach((b) => b.classList.remove("hover")); });
|
||
}
|
||
|
||
function fillBuckets(timeline, hours, bucket) {
|
||
const map = new Map(timeline.map((t) => [t.b, t]));
|
||
const end = Math.floor(Date.now() / 1000 / bucket) * bucket;
|
||
const n = Math.ceil(hours * 3600 / bucket);
|
||
const out = [];
|
||
for (let i = n - 1; i >= 0; i--) { const b = end - i * bucket; out.push(map.get(b) || { b, n: 0, blocked: 0 }); }
|
||
return out;
|
||
}
|
||
|
||
// ------------------------------------------------------------------ Seiten
|
||
const VIEWS = {};
|
||
|
||
VIEWS.dashboard = async (main, params) => {
|
||
const hours = Number(params.get("h") || 24);
|
||
const s = await api(`/api/stats?hours=${hours}`);
|
||
const t = s.totals;
|
||
const rangeSel = `<select id="range">${[[24, "Letzte 24 Stunden"], [168, "Letzte 7 Tage"], [720, "Letzte 30 Tage"]].map(([v, l]) => `<option value="${v}" ${v === hours ? "selected" : ""}>${l}</option>`).join("")}</select>`;
|
||
const blockedPct = t.total ? ((t.blocked / t.total) * 100).toFixed(1) : "0";
|
||
const list = (rows, cols) => rows.length ? `<div class="table-wrap"><table class="mini"><tbody>${rows.map(cols).join("")}</tbody></table></div>` : `<div class="empty">Keine Daten</div>`;
|
||
main.innerHTML = head("Übersicht", "Aktivität des Proxys", rangeSel) + `
|
||
<div class="stack">
|
||
<div class="grid tiles">
|
||
<div class="card tile"><div class="label">Anfragen</div><div class="value">${fmtNum(t.total)}</div><div class="sub">${fmtNum(t.users)} aktive Benutzer</div></div>
|
||
<div class="card tile"><div class="label">Gesperrt</div><div class="value">${fmtNum(t.blocked)}</div><div class="sub">${blockedPct} % aller Anfragen</div></div>
|
||
<div class="card tile"><div class="label">Datenvolumen</div><div class="value">${fmtBytes(t.up + t.down)}</div><div class="sub">↑ ${fmtBytes(t.up)} · ↓ ${fmtBytes(t.down)}</div></div>
|
||
<div class="card tile"><div class="label">Live-Verbindungen</div><div class="value">${fmtNum(s.active_connections)}</div><div class="sub">${fmtNum(s.user_count)} Benutzer angelegt</div></div>
|
||
<div class="card tile"><div class="label">Fehler / Fehllogins</div><div class="value">${fmtNum(t.errors)} / ${fmtNum(t.auth_failed)}</div><div class="sub">Verbindungsfehler / Anmeldung</div></div>
|
||
</div>
|
||
<div class="card"><h2>Anfragen pro ${s.bucket >= 86400 ? "Tag" : "Stunde"}</h2><div class="chart" id="chart"></div></div>
|
||
<div class="grid two">
|
||
<div class="card"><h2>Meistbesuchte Ziele</h2>${list(s.top_hosts, (r) => `<tr><td class="wrap">${esc(r.host)}</td><td class="num">${fmtNum(r.n)}</td><td class="num">${fmtBytes(r.bytes)}</td></tr>`)}</div>
|
||
<div class="card"><h2>Meistgesperrte Ziele</h2>${list(s.top_blocked, (r) => `<tr><td class="wrap">${esc(r.host)}</td><td class="num">${fmtNum(r.n)}</td></tr>`)}</div>
|
||
<div class="card"><h2>Benutzer nach Datenvolumen</h2>${list(s.top_users, (r) => `<tr><td><a href="#history?username=${encodeURIComponent(r.username)}">${esc(r.username)}</a></td><td class="num">${fmtNum(r.n)}</td><td class="num">${fmtBytes(r.bytes)}</td></tr>`)}</div>
|
||
</div>
|
||
</div>`;
|
||
$("#range").onchange = (e) => (location.hash = `dashboard?h=${e.target.value}`);
|
||
barChart($("#chart"), fillBuckets(s.timeline, hours, s.bucket), s.bucket);
|
||
};
|
||
|
||
// ---- Benutzer
|
||
function userForm(u = {}) {
|
||
const pol = u.policy || "inherit";
|
||
return `<div class="form-grid">
|
||
<label>Benutzername<input name="username" value="${esc(u.username)}" required pattern="[A-Za-z0-9._@\\-]+"></label>
|
||
<label>Passwort${u.id ? ' <span class="hint">(leer = unverändert)</span>' : ""}<input name="password" type="password" minlength="6" ${u.id ? "" : "required"} autocomplete="new-password"></label>
|
||
</div>
|
||
<label>Standardrichtlinie
|
||
<select name="policy">${Object.entries(policyLabel).map(([k, l]) => `<option value="${k}" ${k === pol ? "selected" : ""}>${l}</option>`).join("")}</select>
|
||
<span class="hint">Greift, wenn keine Regel passt. „Alles sperren“ + Erlaubt-Regeln = Whitelist für diesen Benutzer.</span>
|
||
</label>
|
||
<label>Kommentar<input name="comment" value="${esc(u.comment)}"></label>
|
||
<label class="check"><input type="checkbox" name="enabled" ${u.enabled !== 0 && u.enabled !== false ? "checked" : ""}> Aktiv (darf den Proxy benutzen)</label>
|
||
<label class="check"><input type="checkbox" name="is_admin" ${u.is_admin ? "checked" : ""}> Administrator (Zugriff auf diese Oberfläche)</label>`;
|
||
}
|
||
|
||
VIEWS.users = async (main) => {
|
||
const users = await loadUsers();
|
||
main.innerHTML = head("Benutzer", "Wer den Proxy benutzen darf", `<button class="btn primary" id="add">+ Benutzer anlegen</button>`) +
|
||
`<div class="table-wrap"><table><thead><tr><th>Benutzer</th><th>Status</th><th>Richtlinie</th><th class="num">Eigene Regeln</th><th>Zuletzt aktiv</th><th>Kommentar</th><th></th></tr></thead><tbody>
|
||
${users.map((u) => `<tr>
|
||
<td><b>${esc(u.username)}</b> ${u.is_admin ? '<span class="badge info">Admin</span>' : ""}</td>
|
||
<td>${u.enabled ? '<span class="badge ok">Aktiv</span>' : '<span class="badge bad">Gesperrt</span>'}</td>
|
||
<td>${esc(policyLabel[u.policy])}</td>
|
||
<td class="num"><a href="#rules?user=${u.id}">${u.rule_count}</a></td>
|
||
<td title="${esc(fmtTime(u.last_seen))}">${ago(u.last_seen)}</td>
|
||
<td class="wrap">${esc(u.comment)}</td>
|
||
<td class="actions">
|
||
<a class="btn small ghost" href="#history?username=${encodeURIComponent(u.username)}">Verlauf</a>
|
||
<button class="btn small" data-edit="${u.id}">Bearbeiten</button>
|
||
<button class="btn small danger" data-del="${u.id}">Löschen</button>
|
||
</td></tr>`).join("") || `<tr><td colspan="7" class="empty">Keine Benutzer</td></tr>`}
|
||
</tbody></table></div>`;
|
||
$("#add").onclick = async () => {
|
||
const ok = await modalLoop("Benutzer anlegen", userForm(), (d) => api("/api/users", { method: "POST", body: d }));
|
||
if (ok) { toast("Benutzer angelegt"); route(); }
|
||
};
|
||
main.onclick = async (e) => {
|
||
const edit = e.target.dataset.edit, del = e.target.dataset.del;
|
||
if (edit) {
|
||
const u = users.find((x) => x.id == edit);
|
||
const ok = await modalLoop(`Benutzer „${u.username}“ bearbeiten`, userForm(u), (d) => {
|
||
if (!d.password) delete d.password;
|
||
return api(`/api/users/${edit}`, { method: "PUT", body: d });
|
||
});
|
||
if (ok) { toast("Gespeichert"); route(); }
|
||
}
|
||
if (del) {
|
||
const u = users.find((x) => x.id == del);
|
||
if (await confirmBox(`Benutzer „${u.username}“ inkl. seiner Regeln löschen?`)) {
|
||
try { await api(`/api/users/${del}`, { method: "DELETE" }); toast("Gelöscht"); route(); } catch (err) { toast(err.message, true); }
|
||
}
|
||
}
|
||
};
|
||
};
|
||
|
||
// ---- Regeln
|
||
function ruleForm(r = {}) {
|
||
return `<label>Gilt für<select name="user_id">${userOptions(r.user_id ?? "")}</select></label>
|
||
<label>Aktion<select name="action">
|
||
<option value="deny" ${r.action !== "allow" ? "selected" : ""}>Sperren (Blacklist)</option>
|
||
<option value="allow" ${r.action === "allow" ? "selected" : ""}>Erlauben (Whitelist)</option></select></label>
|
||
<div class="form-grid">
|
||
<label>Host / Domain / Netz<input name="host" value="${esc(r.host)}" placeholder="z. B. facebook.com">
|
||
<span class="hint">leer = alle · example.com (inkl. Subdomains) · *.example.com · 10.0.0.0/8 · re:regex</span></label>
|
||
<label>Ports<input name="ports" value="${esc(r.ports)}" placeholder="z. B. 25 oder 6000-7000,8080">
|
||
<span class="hint">leer = alle Ports</span></label>
|
||
</div>
|
||
<label>Kommentar<input name="comment" value="${esc(r.comment)}"></label>
|
||
<label class="check"><input type="checkbox" name="enabled" ${r.enabled !== 0 ? "checked" : ""}> Regel aktiv</label>`;
|
||
}
|
||
const ruleBody = (d) => ({ ...d, user_id: d.user_id ? Number(d.user_id) : null });
|
||
|
||
VIEWS.rules = async (main, params) => {
|
||
await loadUsers();
|
||
const userFilter = params.get("user") || "";
|
||
const rules = await api(userFilter ? `/api/rules?user_id=${userFilter}` : "/api/rules");
|
||
const filterSel = `<select id="rule-filter"><option value="">Alle Regeln</option>${state.users.map((u) => `<option value="${u.id}" ${userFilter == u.id ? "selected" : ""}>nur ${esc(u.username)}</option>`).join("")}</select>`;
|
||
main.innerHTML = head("Regeln", "Black- und Whitelist für Domains, IPs und Ports",
|
||
`${filterSel}<button class="btn" id="test">Regel testen</button><button class="btn" id="bulk">Liste importieren</button><button class="btn primary" id="add">+ Regel</button>`) +
|
||
`<div class="card" style="margin-bottom:16px"><b>So wird ausgewertet:</b> Benutzer-Sperren → Benutzer-Erlaubnisse → globale Sperren → globale Erlaubnisse → Standardrichtlinie.
|
||
Benutzerregeln haben also Vorrang vor globalen Regeln. Für eine reine <b>Whitelist</b> die Standardrichtlinie (global oder pro Benutzer) auf „Alles sperren“ stellen und Erlaubt-Regeln anlegen.</div>
|
||
<div class="table-wrap"><table><thead><tr><th>Gilt für</th><th>Aktion</th><th>Host / Netz</th><th>Ports</th><th>Kommentar</th><th>Aktiv</th><th></th></tr></thead><tbody>
|
||
${rules.map((r) => `<tr>
|
||
<td>${r.user_id ? `<span class="badge info">${esc(r.username)}</span>` : '<span class="badge">Global</span>'}</td>
|
||
<td>${r.action === "deny" ? '<span class="badge bad">Sperren</span>' : '<span class="badge ok">Erlauben</span>'}</td>
|
||
<td class="wrap mono">${esc(r.host || "* (alle)")}</td>
|
||
<td class="mono">${esc(r.ports || "alle")}</td>
|
||
<td class="wrap">${esc(r.comment)}</td>
|
||
<td>${r.enabled ? "✓" : "–"}</td>
|
||
<td class="actions"><button class="btn small" data-edit="${r.id}">Bearbeiten</button> <button class="btn small danger" data-del="${r.id}">Löschen</button></td>
|
||
</tr>`).join("") || `<tr><td colspan="7" class="empty">Keine Regeln – alles wird nach der Standardrichtlinie behandelt.</td></tr>`}
|
||
</tbody></table></div>`;
|
||
$("#rule-filter").onchange = (e) => (location.hash = e.target.value ? `rules?user=${e.target.value}` : "rules");
|
||
$("#add").onclick = async () => {
|
||
const ok = await modalLoop("Regel anlegen", ruleForm({ user_id: userFilter || null }), (d) => api("/api/rules", { method: "POST", body: ruleBody(d) }));
|
||
if (ok) { toast("Regel angelegt"); route(); }
|
||
};
|
||
$("#bulk").onclick = async () => {
|
||
const html = `<label>Gilt für<select name="user_id">${userOptions(userFilter)}</select></label>
|
||
<label>Aktion<select name="action"><option value="deny">Sperren (Blacklist)</option><option value="allow">Erlauben (Whitelist)</option></select></label>
|
||
<label>Hosts (einer pro Zeile)<textarea name="hosts" placeholder="facebook.com *.tiktok.com 0.0.0.0 ads.example.com" required></textarea>
|
||
<span class="hint">Auch Hosts-Datei-Format und #-Kommentare werden verstanden.</span></label>
|
||
<label>Ports<input name="ports" placeholder="leer = alle"></label>
|
||
<label>Kommentar<input name="comment" placeholder="z. B. Social Media"></label>`;
|
||
const out = await modalLoop("Liste importieren", html, (d) => api("/api/rules/bulk", { method: "POST", body: { ...ruleBody(d), hosts: d.hosts.split(/\r?\n/) } }), { submit: "Importieren" });
|
||
if (out) { toast(`${out.added} Regeln importiert, ${out.skipped} übersprungen`); route(); }
|
||
};
|
||
$("#test").onclick = async () => {
|
||
const html = `<label>Benutzer<select name="username"><option value="">(anonym / nur globale Regeln)</option>${state.users.map((u) => `<option>${esc(u.username)}</option>`).join("")}</select></label>
|
||
<div class="form-grid"><label>Host<input name="host" required placeholder="www.example.com"></label><label>Port<input name="port" type="number" value="443" min="1" max="65535"></label></div>
|
||
<div id="test-result"></div>`;
|
||
await modal("Regel testen", html, { submit: "Prüfen", onSubmit: async (d) => {
|
||
const res = await api("/api/rules/test", { method: "POST", body: { username: d.username || null, host: d.host, port: Number(d.port) } });
|
||
$("#test-result").innerHTML = `<div class="card">${res.allowed ? '<span class="badge ok">Erlaubt</span>' : '<span class="badge bad">Gesperrt</span>'} ${esc(res.reason)}
|
||
${res.addresses.length ? `<div class="hint" style="margin-top:8px">${res.addresses.map((a) => `${esc(a.ip)}: ${a.allowed ? "erlaubt" : "gesperrt – " + esc(a.reason)}`).join("<br>")}</div>` : ""}
|
||
${res.dns_error ? `<div class="hint">DNS: ${esc(res.dns_error)}</div>` : ""}</div>`;
|
||
return KEEP_OPEN;
|
||
} });
|
||
};
|
||
main.onclick = async (e) => {
|
||
const edit = e.target.dataset.edit, del = e.target.dataset.del;
|
||
if (edit) {
|
||
const rule = rules.find((x) => x.id == edit);
|
||
const ok = await modalLoop("Regel bearbeiten", ruleForm(rule), (d) => api(`/api/rules/${edit}`, { method: "PUT", body: ruleBody(d) }));
|
||
if (ok) { toast("Gespeichert"); route(); }
|
||
}
|
||
if (del && await confirmBox("Regel löschen?")) {
|
||
await api(`/api/rules/${del}`, { method: "DELETE" }); toast("Gelöscht"); route();
|
||
}
|
||
};
|
||
};
|
||
|
||
// ---- Weiterleitungen
|
||
function forwardForm(f = {}) {
|
||
return `<label>Name<input name="name" value="${esc(f.name)}" required placeholder="z. B. WireGuard Büro"></label>
|
||
<div class="form-grid">
|
||
<label>Protokoll<select name="protocol"><option value="tcp" ${f.protocol !== "udp" ? "selected" : ""}>TCP</option><option value="udp" ${f.protocol === "udp" ? "selected" : ""}>UDP</option></select></label>
|
||
<label>Lokaler Port<input name="listen_port" type="number" min="1" max="65535" value="${esc(f.listen_port)}" required></label>
|
||
</div>
|
||
<div class="form-grid">
|
||
<label>Ziel-Host<input name="target_host" value="${esc(f.target_host)}" required placeholder="vpn.example.com"></label>
|
||
<label>Ziel-Port<input name="target_port" type="number" min="1" max="65535" value="${esc(f.target_port)}" required></label>
|
||
</div>
|
||
<label>Erlaubte Quell-Netze<input name="allowed_sources" value="${esc(f.allowed_sources)}" placeholder="z. B. 192.168.1.0/24 10.8.0.5">
|
||
<span class="hint">Leer = jeder darf (das Ziel ist fest, es ist also kein offener Proxy). Globale Regeln gelten zusätzlich.</span></label>
|
||
<label>Kommentar<input name="comment" value="${esc(f.comment)}"></label>
|
||
<label class="check"><input type="checkbox" name="enabled" ${f.enabled !== 0 ? "checked" : ""}> Aktiv</label>`;
|
||
}
|
||
const fwdBody = (d) => ({ ...d, listen_port: Number(d.listen_port), target_port: Number(d.target_port) });
|
||
|
||
VIEWS.forwards = async (main) => {
|
||
const { items, ports_hint } = await api("/api/forwards");
|
||
main.innerHTML = head("Weiterleitungen", `Für Programme ohne Proxy-Unterstützung (WireGuard, Outlook-Mailkonten, Geräte …): Port am Proxy → festes Ziel. Im Docker-Setup freigegebene Ports: <b>${esc(ports_hint)}</b> (TCP & UDP).`,
|
||
`<button class="btn primary" id="add">+ Weiterleitung</button>`) +
|
||
`<div class="table-wrap"><table><thead><tr><th>Name</th><th>Proto</th><th>Proxy-Port</th><th>Ziel</th><th>Erlaubte Quellen</th><th>Status</th><th></th></tr></thead><tbody>
|
||
${items.map((f) => `<tr>
|
||
<td><b>${esc(f.name)}</b>${f.comment ? `<div class="hint">${esc(f.comment)}</div>` : ""}</td>
|
||
<td><span class="badge">${f.protocol.toUpperCase()}</span></td>
|
||
<td class="mono">${f.listen_port}</td>
|
||
<td class="mono">${esc(f.target_host)}:${f.target_port}</td>
|
||
<td class="mono wrap">${esc(f.allowed_sources || "alle")}</td>
|
||
<td>${f.status === "aktiv" ? '<span class="badge ok">aktiv</span>' : f.status === "deaktiviert" ? '<span class="badge">deaktiviert</span>' : `<span class="badge bad">${esc(f.status)}</span>`}</td>
|
||
<td class="actions"><button class="btn small" data-edit="${f.id}">Bearbeiten</button> <button class="btn small danger" data-del="${f.id}">Löschen</button></td>
|
||
</tr>`).join("") || `<tr><td colspan="7" class="empty">Keine Weiterleitungen. Beispiel: WireGuard → UDP, Port 40000 → vpn.firma.de:51820</td></tr>`}
|
||
</tbody></table></div>`;
|
||
$("#add").onclick = async () => {
|
||
const ok = await modalLoop("Weiterleitung anlegen", forwardForm(), (d) => api("/api/forwards", { method: "POST", body: fwdBody(d) }));
|
||
if (ok) { toast("Weiterleitung angelegt"); route(); }
|
||
};
|
||
main.onclick = async (e) => {
|
||
const edit = e.target.dataset.edit, del = e.target.dataset.del;
|
||
if (edit) {
|
||
const f = items.find((x) => x.id == edit);
|
||
const ok = await modalLoop("Weiterleitung bearbeiten", forwardForm(f), (d) => api(`/api/forwards/${edit}`, { method: "PUT", body: fwdBody(d) }));
|
||
if (ok) { toast("Gespeichert"); route(); }
|
||
}
|
||
if (del && await confirmBox("Weiterleitung löschen?")) {
|
||
await api(`/api/forwards/${del}`, { method: "DELETE" }); toast("Gelöscht"); route();
|
||
}
|
||
};
|
||
};
|
||
|
||
// ---- Verlauf
|
||
function historyView(admin) {
|
||
return async (main, params) => {
|
||
if (admin) await loadUsers();
|
||
const f = Object.fromEntries(params.entries());
|
||
const limit = 100, offset = Number(f.offset || 0);
|
||
const q = new URLSearchParams();
|
||
for (const k of ["username", "q", "status", "proto"]) if (f[k]) q.set(k, f[k]);
|
||
if (f.from) q.set("since", new Date(f.from).getTime() / 1000);
|
||
if (f.to) q.set("until", new Date(f.to).getTime() / 1000);
|
||
const base = admin ? "/api/history" : "/api/me/history";
|
||
const data = await api(`${base}?${q}&limit=${limit}&offset=${offset}`);
|
||
const sel = (name, opts) => `<select name="${name}"><option value="">${name === "username" ? "Alle Benutzer" : name === "status" ? "Alle Status" : "Alle Protokolle"}</option>${opts.map(([v, l]) => `<option value="${esc(v)}" ${f[name] === v ? "selected" : ""}>${esc(l)}</option>`).join("")}</select>`;
|
||
main.innerHTML = head(admin ? "Verlauf" : "Mein Verlauf", "Wer hat wann was aufgerufen",
|
||
admin ? `<button class="btn" id="csv">CSV-Export</button><button class="btn danger" id="clear">Verlauf leeren</button>` : "") +
|
||
`<form class="toolbar" id="filter">
|
||
${admin ? sel("username", state.users.map((u) => [u.username, u.username])) : ""}
|
||
<input name="q" placeholder="Suche Host, URL, IP …" value="${esc(f.q)}">
|
||
${sel("status", Object.entries(STATUS).map(([k, v]) => [k, v[1]]))}
|
||
${admin ? sel("proto", Object.entries(PROTO)) : ""}
|
||
<label>Von<input type="datetime-local" name="from" value="${esc(f.from)}"></label>
|
||
<label>Bis<input type="datetime-local" name="to" value="${esc(f.to)}"></label>
|
||
<button class="btn primary">Filtern</button>
|
||
<a class="btn ghost" href="#${admin ? "history" : "myhistory"}">Zurücksetzen</a>
|
||
</form>
|
||
<div class="table-wrap"><table><thead><tr><th>Zeit</th>${admin ? "<th>Benutzer</th>" : ""}<th>Client</th><th>Protokoll</th><th>Ziel</th><th>Status</th><th class="num">↑</th><th class="num">↓</th><th class="num">Dauer</th></tr></thead><tbody>
|
||
${data.items.map((r) => `<tr>
|
||
<td style="white-space:nowrap">${fmtTime(r.ts)}</td>
|
||
${admin ? `<td>${r.username ? `<a href="#history?username=${encodeURIComponent(r.username)}">${esc(r.username)}</a>` : '<span class="hint">–</span>'}</td>` : ""}
|
||
<td class="mono">${esc(r.client_ip)}</td>
|
||
<td>${protoBadge(r.proto)}</td>
|
||
<td class="wrap"><span class="mono">${esc(r.method && r.proto === "http" ? r.method + " " : "")}${esc(r.url || (r.host ? `${r.host}:${r.port}` : ""))}</span>${r.detail ? `<div class="hint">${esc(r.detail)}</div>` : ""}</td>
|
||
<td>${statusBadge(r.status)}</td>
|
||
<td class="num">${fmtBytes(r.bytes_up)}</td><td class="num">${fmtBytes(r.bytes_down)}</td><td class="num">${fmtDur(r.duration)}</td>
|
||
</tr>`).join("") || `<tr><td colspan="9" class="empty">Keine Einträge</td></tr>`}
|
||
</tbody></table></div>
|
||
<div class="pager">${fmtNum(data.total)} Einträge · Seite ${Math.floor(offset / limit) + 1} von ${Math.max(1, Math.ceil(data.total / limit))}
|
||
<button class="btn small" id="prev" ${offset <= 0 ? "disabled" : ""}>‹ Zurück</button>
|
||
<button class="btn small" id="next" ${offset + limit >= data.total ? "disabled" : ""}>Weiter ›</button></div>`;
|
||
const go = (extra = {}) => {
|
||
const p = new URLSearchParams();
|
||
const form = new FormData($("#filter"));
|
||
for (const [k, v] of form.entries()) if (v) p.set(k, v);
|
||
for (const [k, v] of Object.entries(extra)) p.set(k, v);
|
||
location.hash = `${admin ? "history" : "myhistory"}?${p}`;
|
||
};
|
||
$("#filter").onsubmit = (e) => { e.preventDefault(); go(); };
|
||
$("#prev").onclick = () => go({ offset: Math.max(0, offset - limit) });
|
||
$("#next").onclick = () => go({ offset: offset + limit });
|
||
if (admin) {
|
||
$("#csv").onclick = async () => {
|
||
try {
|
||
const res = await api(`/api/history/export.csv?${q}`, { raw: true });
|
||
const url = URL.createObjectURL(await res.blob());
|
||
const a = Object.assign(document.createElement("a"), { href: url, download: "proxy-verlauf.csv" });
|
||
document.body.append(a); a.click(); a.remove();
|
||
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||
} catch (e) { toast(e.message, true); }
|
||
};
|
||
$("#clear").onclick = async () => {
|
||
if (await confirmBox("Den kompletten Verlauf unwiderruflich löschen?", "Alles löschen")) {
|
||
const r = await api("/api/history", { method: "DELETE" }); toast(`${fmtNum(r.deleted)} Einträge gelöscht`); route();
|
||
}
|
||
};
|
||
}
|
||
};
|
||
}
|
||
VIEWS.history = historyView(true);
|
||
VIEWS.myhistory = historyView(false);
|
||
|
||
// ---- Live-Verbindungen
|
||
VIEWS.connections = async (main) => {
|
||
main.innerHTML = head("Live-Verbindungen", "Aktualisiert sich alle 3 Sekunden") + `<div id="conns"></div>`;
|
||
const draw = async () => {
|
||
const list = await api("/api/connections");
|
||
const el = $("#conns");
|
||
if (!el) return;
|
||
el.innerHTML = `<div class="table-wrap"><table><thead><tr><th>Seit</th><th>Benutzer</th><th>Client</th><th>Protokoll</th><th>Ziel</th><th class="num">↑</th><th class="num">↓</th><th></th></tr></thead><tbody>
|
||
${list.map((c) => `<tr><td>${fmtDur(Date.now() / 1000 - c.started)}</td><td>${esc(c.username || "–")}</td><td class="mono">${esc(c.client_ip)}</td>
|
||
<td>${protoBadge(c.proto)}</td><td class="wrap mono">${esc(c.url || `${c.host}:${c.port}`)}</td>
|
||
<td class="num">${fmtBytes(c.bytes_up)}</td><td class="num">${fmtBytes(c.bytes_down)}</td>
|
||
<td class="actions"><button class="btn small danger" data-kill="${c.id}">Trennen</button></td></tr>`).join("") || `<tr><td colspan="8" class="empty">Keine aktiven Verbindungen</td></tr>`}
|
||
</tbody></table></div>`;
|
||
};
|
||
await draw();
|
||
state.timer = setInterval(() => draw().catch(() => {}), 3000);
|
||
main.onclick = async (e) => {
|
||
if (e.target.dataset.kill) { await api(`/api/connections/${e.target.dataset.kill}`, { method: "DELETE" }).catch((x) => toast(x.message, true)); draw(); }
|
||
};
|
||
};
|
||
|
||
// ---- Einstellungen
|
||
function setupHelp(s) {
|
||
const host = s.public_host || location.hostname;
|
||
return `<div class="card"><h2>Clients einrichten</h2>
|
||
<dl class="kv">
|
||
<dt>HTTP/HTTPS-Proxy</dt><dd class="mono">${esc(host)}:${s.ports.http}</dd>
|
||
<dt>SOCKS5-Proxy</dt><dd class="mono">${esc(host)}:${s.ports.socks}</dd>
|
||
<dt>Auto-Konfiguration (PAC)</dt><dd class="mono">http://${esc(host)}:${s.ports.web}/proxy.pac</dd>
|
||
<dt>SOCKS5 UDP-Ports</dt><dd class="mono">${esc(s.ports.socks_udp)}</dd>
|
||
<dt>Weiterleitungs-Ports</dt><dd class="mono">${esc(s.ports.forwards)}</dd>
|
||
</dl>
|
||
<p class="hint" style="margin-top:12px">Beide Proxy-Ports verstehen HTTP und SOCKS5. Browser: HTTP-Proxy oder PAC-URL eintragen.
|
||
Thunderbird & Co. (SMTP, IMAP, POP3): SOCKS5 eintragen. Outlook/Exchange: läuft über HTTPS (Systemproxy);
|
||
für SMTP/IMAP in Outlook oder WireGuard eine <a href="#forwards">Weiterleitung</a> anlegen.
|
||
Kommandozeile: <code>export https_proxy=http://user:pass@${esc(host)}:${s.ports.http}</code></p></div>`;
|
||
}
|
||
|
||
VIEWS.settings = async (main) => {
|
||
const s = await api("/api/settings");
|
||
main.innerHTML = head("Einstellungen") + `<div class="stack">
|
||
<form class="card stack" id="settings">
|
||
<label>Globale Standardrichtlinie
|
||
<select name="default_policy">
|
||
<option value="allow" ${s.default_policy === "allow" ? "selected" : ""}>Alles erlauben, Sperrliste anwenden (Blacklist-Modus)</option>
|
||
<option value="deny" ${s.default_policy === "deny" ? "selected" : ""}>Alles sperren, nur Freigaben erlauben (Whitelist-Modus)</option>
|
||
</select></label>
|
||
<div class="form-grid">
|
||
<label>Verlauf aufbewahren (Tage)<input type="number" name="log_retention_days" min="0" value="${s.log_retention_days}"><span class="hint">0 = unbegrenzt</span></label>
|
||
<label>Öffentlicher Hostname<input name="public_host" value="${esc(s.public_host)}" placeholder="proxy.firma.local"><span class="hint">Für PAC-Datei und Anleitung</span></label>
|
||
<label>Anmelde-Bereich (Realm)<input name="auth_realm" value="${esc(s.auth_realm)}"></label>
|
||
</div>
|
||
<label>Netze ohne Anmeldung<input name="anonymous_networks" value="${esc(s.anonymous_networks)}" placeholder="z. B. 192.168.10.0/24">
|
||
<span class="hint">Clients aus diesen Netzen dürfen ohne Login surfen (erscheinen als „anonym“, nur globale Regeln). Leer = Anmeldung immer Pflicht.</span></label>
|
||
<label class="check"><input type="checkbox" name="log_enabled" ${s.log_enabled ? "checked" : ""}> Verlauf protokollieren</label>
|
||
<label class="check"><input type="checkbox" name="block_loopback" ${s.block_loopback ? "checked" : ""}> Zugriff auf Loopback (127.0.0.1, ::1) sperren – schützt diese Oberfläche</label>
|
||
<div><button class="btn primary">Speichern</button></div>
|
||
</form>
|
||
${setupHelp(s)}
|
||
</div>`;
|
||
$("#settings").onsubmit = async (e) => {
|
||
e.preventDefault();
|
||
const el = e.target.elements;
|
||
try {
|
||
await api("/api/settings", { method: "PUT", body: {
|
||
default_policy: el.default_policy.value, log_retention_days: Number(el.log_retention_days.value),
|
||
public_host: el.public_host.value, auth_realm: el.auth_realm.value, anonymous_networks: el.anonymous_networks.value,
|
||
log_enabled: el.log_enabled.checked, block_loopback: el.block_loopback.checked } });
|
||
toast("Einstellungen gespeichert");
|
||
} catch (err) { toast(err.message, true); }
|
||
};
|
||
};
|
||
|
||
VIEWS.help = async (main) => {
|
||
const host = location.hostname;
|
||
main.innerHTML = head("Einrichtung") + `<div class="card"><dl class="kv">
|
||
<dt>HTTP/HTTPS-Proxy</dt><dd class="mono">${esc(host)}:3128</dd>
|
||
<dt>SOCKS5</dt><dd class="mono">${esc(host)}:1080</dd>
|
||
<dt>PAC-Datei</dt><dd class="mono">http://${esc(host)}:${esc(location.port || 80)}/proxy.pac</dd>
|
||
<dt>Anmeldung</dt><dd>Dein Benutzername und Passwort</dd></dl></div>`;
|
||
};
|
||
|
||
// ---- API
|
||
VIEWS.api = async (main) => {
|
||
const tokens = await api("/api/tokens");
|
||
main.innerHTML = head("API", `Alle Funktionen der Oberfläche sind per REST-API nutzbar. <a href="/api/docs" target="_blank">Interaktive API-Doku öffnen ↗</a>`,
|
||
`<button class="btn primary" id="add">+ API-Token</button>`) + `<div class="stack">
|
||
<div class="table-wrap"><table><thead><tr><th>Name</th><th>Besitzer</th><th>Erstellt</th><th>Läuft ab</th><th>Zuletzt benutzt</th><th></th></tr></thead><tbody>
|
||
${tokens.map((t) => `<tr><td><b>${esc(t.name)}</b></td><td>${esc(t.username)}</td><td>${fmtTime(t.created_at)}</td><td>${t.expires_at ? fmtTime(t.expires_at) : "nie"}</td><td>${ago(t.last_used)}</td>
|
||
<td class="actions"><button class="btn small danger" data-del="${t.id}">Widerrufen</button></td></tr>`).join("") || `<tr><td colspan="6" class="empty">Noch keine API-Tokens</td></tr>`}
|
||
</tbody></table></div>
|
||
<div class="card"><h2>Beispiele</h2><pre class="mono" style="white-space:pre-wrap;margin:0"># Benutzer anlegen
|
||
curl -X POST ${esc(location.origin)}/api/users \\
|
||
-H "Authorization: Bearer <TOKEN>" -H "Content-Type: application/json" \\
|
||
-d '{"username":"max","password":"geheim123"}'
|
||
|
||
# Domain global sperren
|
||
curl -X POST ${esc(location.origin)}/api/rules \\
|
||
-H "Authorization: Bearer <TOKEN>" -H "Content-Type: application/json" \\
|
||
-d '{"action":"deny","host":"facebook.com"}'
|
||
|
||
# Verlauf eines Benutzers
|
||
curl "${esc(location.origin)}/api/history?username=max&limit=50" -H "Authorization: Bearer <TOKEN>"</pre></div></div>`;
|
||
$("#add").onclick = async () => {
|
||
const out = await modalLoop("API-Token erstellen", `<label>Name<input name="name" required placeholder="z. B. Monitoring"></label>
|
||
<label>Gültigkeit (Tage)<input name="days" type="number" min="1" placeholder="leer = unbegrenzt"></label>`,
|
||
(d) => api("/api/tokens", { method: "POST", body: { name: d.name, days: d.days ? Number(d.days) : null } }), { submit: "Erstellen" });
|
||
if (out) {
|
||
await modal("Token erstellt", `<p>Bitte jetzt kopieren – er wird nicht noch einmal angezeigt:</p><div class="secret mono">${esc(out.token)}</div>
|
||
<p class="hint">Verwendung: Header <code>Authorization: Bearer <Token></code> oder <code>X-API-Key: <Token></code></p>`, { submit: "" });
|
||
route();
|
||
}
|
||
};
|
||
main.onclick = async (e) => {
|
||
if (e.target.dataset.del && await confirmBox("Token widerrufen?", "Widerrufen")) {
|
||
await api(`/api/tokens/${e.target.dataset.del}`, { method: "DELETE" }); toast("Widerrufen"); route();
|
||
}
|
||
};
|
||
};
|
||
|
||
// ------------------------------------------------------------------ Start / Login
|
||
function logout(silent) {
|
||
if (!silent && state.token) api("/api/auth/logout", { method: "POST" }).catch(() => {});
|
||
state.token = null;
|
||
state.me = null;
|
||
try { localStorage.removeItem("proxy_token"); } catch (_) { /* ignore */ }
|
||
clearInterval(state.timer);
|
||
$("#app").hidden = true;
|
||
$("#login").hidden = false;
|
||
}
|
||
|
||
async function start() {
|
||
if (!state.token) { logout(true); return; }
|
||
try { state.me = await api("/api/me"); } catch (_) { return; }
|
||
$("#login").hidden = true;
|
||
$("#app").hidden = false;
|
||
$("#whoami").innerHTML = `Angemeldet als <b>${esc(state.me.username)}</b>`;
|
||
route();
|
||
}
|
||
|
||
$("#login-form").onsubmit = async (e) => {
|
||
e.preventDefault();
|
||
const f = e.target.elements;
|
||
$("#login-error").textContent = "";
|
||
try {
|
||
const r = await api("/api/auth/login", { method: "POST", body: { username: f.username.value, password: f.password.value } });
|
||
state.token = r.token;
|
||
try { localStorage.setItem("proxy_token", r.token); } catch (_) { /* ignore */ }
|
||
f.password.value = "";
|
||
start();
|
||
} catch (err) { $("#login-error").textContent = err.message; }
|
||
};
|
||
$("#btn-logout").onclick = () => logout(false);
|
||
$("#btn-password").onclick = async () => {
|
||
const ok = await modalLoop("Passwort ändern", `<label>Aktuelles Passwort<input type="password" name="old_password" required autocomplete="current-password"></label>
|
||
<label>Neues Passwort<input type="password" name="new_password" required minlength="6" autocomplete="new-password"></label>`,
|
||
(d) => api("/api/me/password", { method: "POST", body: d }));
|
||
if (ok) toast("Passwort geändert");
|
||
};
|
||
window.addEventListener("hashchange", () => state.me && route());
|
||
start();
|