simple-web-file-upload/public/admin/index.html

984 lines
44 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Adminportal</title>
<style>
:root {
--bg: #0b0f1a;
--bg-raise: #111827;
--bg-card: #151d2e;
--border: #1f2937;
--border-strong: #374151;
--text: #e5e7eb;
--text-muted: #9ca3af;
--text-dim: #6b7280;
--primary: #6366f1;
--primary-hover: #818cf8;
--primary-fg: #ffffff;
--danger: #ef4444;
--danger-hover: #f87171;
--success: #10b981;
--warn: #f59e0b;
--radius: 12px;
--radius-sm: 8px;
--shadow: 0 8px 32px rgba(0,0,0,.3);
color-scheme: dark;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f8fafc;
--bg-raise: #ffffff;
--bg-card: #ffffff;
--border: #e2e8f0;
--border-strong: #cbd5e1;
--text: #0f172a;
--text-muted: #475569;
--text-dim: #94a3b8;
--shadow: 0 4px 16px rgba(15,23,42,.08);
color-scheme: light;
}
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0; font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
background: var(--bg); color: var(--text); line-height: 1.5;
}
a { color: var(--primary); text-decoration: none; }
a:hover { text-decoration: underline; }
code { font-family: "JetBrains Mono", ui-monospace, monospace; font-size: .85em;
background: var(--bg-raise); padding: 2px 6px; border-radius: 4px; }
/* Layout */
.nav {
position: sticky; top: 0; z-index: 5;
background: var(--bg-raise); border-bottom: 1px solid var(--border);
padding: .75rem 1.5rem;
display: flex; align-items: center; justify-content: space-between; gap: 1rem;
}
.nav-left { display: flex; align-items: center; gap: 1rem; }
.nav-logo { max-height: 40px; width: auto; object-fit: contain; }
.nav-title { font-weight: 600; font-size: 1.05rem; }
.nav-right { display: flex; align-items: center; gap: .75rem; font-size: .9rem; color: var(--text-muted); }
.container { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
/* Tabs */
.tabs {
display: flex; gap: .25rem; margin-bottom: 1.5rem;
border-bottom: 1px solid var(--border);
}
.tab {
padding: .6rem 1.1rem; border: none; background: transparent;
color: var(--text-muted); font: inherit; cursor: pointer;
border-bottom: 2px solid transparent; margin-bottom: -1px;
transition: color .15s, border-color .15s;
}
.tab:hover { color: var(--text); }
.tab.active { color: var(--text); border-bottom-color: var(--primary); font-weight: 500; }
/* Cards */
.card {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius); padding: 1.25rem; box-shadow: var(--shadow);
}
.card + .card { margin-top: 1rem; }
.card-title { font-size: 1rem; font-weight: 600; margin: 0 0 .25rem; }
.card-sub { color: var(--text-muted); font-size: .85rem; margin: 0 0 1rem; }
/* Forms */
label { display: block; font-size: .85rem; color: var(--text-muted); margin-bottom: .35rem; font-weight: 500; }
input, select, textarea {
width: 100%; padding: .55rem .75rem; font: inherit;
background: var(--bg); color: var(--text);
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
transition: border-color .15s, box-shadow .15s;
}
input:focus, select:focus, textarea:focus {
outline: none; border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 25%, transparent);
}
input[type="file"] { padding: .35rem; }
input[type="range"] { padding: 0; border: none; background: transparent; }
/* Buttons */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: .4rem;
padding: .55rem 1rem; border: 1px solid var(--border-strong);
background: transparent; color: var(--text);
border-radius: var(--radius-sm); font: inherit; cursor: pointer;
transition: background .15s, border-color .15s, transform .05s;
white-space: nowrap;
}
.btn:hover { background: var(--bg); border-color: var(--primary); }
.btn:active { transform: translateY(1px); }
.btn.primary { background: var(--primary); border-color: var(--primary); color: var(--primary-fg); }
.btn.primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); }
.btn.danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 40%, transparent); }
.btn.danger:hover { background: color-mix(in srgb, var(--danger) 12%, transparent); border-color: var(--danger); }
.btn.ghost { border-color: transparent; color: var(--text-muted); }
.btn.ghost:hover { background: var(--bg); color: var(--text); }
.btn.sm { padding: .35rem .65rem; font-size: .85rem; }
/* Form grid */
.form-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: .75rem; align-items: end; }
.form-row .wide { grid-column: 1 / -1; }
/* Tables */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
thead th {
text-align: left; padding: .7rem .8rem;
font-size: .75rem; text-transform: uppercase; letter-spacing: .03em;
color: var(--text-muted); font-weight: 600;
border-bottom: 1px solid var(--border);
}
tbody td {
padding: .75rem .8rem; border-bottom: 1px solid var(--border);
vertical-align: middle; font-size: .92rem;
}
tbody tr:hover { background: color-mix(in srgb, var(--primary) 5%, transparent); }
tbody tr:last-child td { border-bottom: none; }
.cell-actions { text-align: right; white-space: nowrap; }
.cell-actions .btn { margin-left: .25rem; }
/* Pills */
.pill {
display: inline-flex; align-items: center; gap: .3rem;
padding: .12rem .55rem; border-radius: 999px;
font-size: .75rem; font-weight: 500; border: 1px solid var(--border-strong);
}
.pill.admin { color: var(--primary); border-color: color-mix(in srgb, var(--primary) 50%, transparent);
background: color-mix(in srgb, var(--primary) 10%, transparent); }
.pill.staff { color: var(--text-muted); }
.pill.write { color: var(--success); border-color: color-mix(in srgb, var(--success) 50%, transparent);
background: color-mix(in srgb, var(--success) 10%, transparent); }
.pill.read { color: var(--text-muted); }
.pill.lock { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 50%, transparent);
background: color-mix(in srgb, var(--warn) 10%, transparent); }
.pill.count { background: var(--bg-raise); color: var(--text-muted); }
.pill.archived { color: var(--text-dim); border-color: var(--border-strong);
background: color-mix(in srgb, var(--text-dim) 10%, transparent); }
tr.archived td:not(.cell-actions) { opacity: .55; }
select.role-select { padding: .25rem .5rem; font-size: .85rem; min-width: 9rem; }
.small { font-size: .8rem; color: var(--text-muted); }
.muted { color: var(--text-muted); }
/* Login/Setup center view */
.center { min-height: 100vh; display: grid; place-items: center; padding: 1.5rem; }
.auth-card { width: 100%; max-width: 420px; }
.auth-card h2 { margin: 0 0 .25rem; font-size: 1.4rem; }
.auth-card p.small { margin: 0 0 1.25rem; }
.auth-card .field + .field { margin-top: .85rem; }
.err { color: var(--danger); margin-top: .75rem; font-size: .88rem; min-height: 1em; }
.ok-msg { color: var(--success); font-size: .85rem; }
/* Modal */
.modal { position: fixed; inset: 0; background: rgba(0,0,0,.6); backdrop-filter: blur(3px);
display: none; align-items: center; justify-content: center; padding: 1rem; z-index: 20; }
.modal.open { display: flex; }
.modal-inner {
background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius);
padding: 1.5rem; max-width: 560px; width: 100%; max-height: 85vh; overflow: auto;
box-shadow: var(--shadow);
}
.modal-inner h3 { margin-top: 0; }
.access-row {
display: grid; grid-template-columns: 1fr auto auto auto; gap: .5rem;
align-items: center; padding: .55rem 0; border-bottom: 1px solid var(--border);
font-size: .92rem;
}
.access-row:last-child { border-bottom: none; }
.access-row label { display: inline-flex; gap: .3rem; align-items: center; margin: 0; cursor: pointer;
color: var(--text); font-weight: normal; font-size: .85rem; }
/* Logo upload widget */
.logo-preview {
display: flex; align-items: center; gap: 1rem; padding: 1rem;
border: 1px dashed var(--border-strong); border-radius: var(--radius-sm);
margin-bottom: .75rem; background: var(--bg);
}
.logo-preview img { max-height: 80px; max-width: 200px; object-fit: contain; border-radius: 4px; }
.logo-preview .placeholder {
width: 80px; height: 80px; border: 2px dashed var(--border-strong); border-radius: 8px;
display: grid; place-items: center; color: var(--text-dim); font-size: .75rem;
}
.range-row { display: flex; align-items: center; gap: .75rem; }
.range-row input[type=range] { flex: 1; }
.range-row .val { min-width: 3.5em; text-align: right; font-variant-numeric: tabular-nums; color: var(--text-muted); }
.copy-btn { cursor: pointer; }
</style>
</head>
<body>
<!-- SETUP VIEW -->
<div id="view-setup" class="center" style="display:none">
<div class="card auth-card">
<h2>Willkommen 👋</h2>
<p class="small">Lege den ersten Admin-Account an. Dieser kann später weitere Benutzer verwalten.</p>
<form id="setupForm">
<div class="field"><label>Benutzername</label>
<input name="username" required pattern="[A-Za-z0-9._-]+" autocomplete="username" /></div>
<div class="field"><label>Passwort (min. 8 Zeichen)</label>
<input name="password" type="password" required minlength="8" autocomplete="new-password" /></div>
<div style="margin-top: 1rem"><button class="btn primary" type="submit" style="width:100%">Admin anlegen & einloggen</button></div>
<div class="err" id="setupErr"></div>
</form>
</div>
</div>
<!-- LOGIN VIEW -->
<div id="view-login" class="center" style="display:none">
<div class="card auth-card">
<h2>Login</h2>
<p class="small">Melde dich mit deinem Account an.</p>
<form id="loginForm">
<div class="field"><label>Benutzername</label>
<input name="username" required autocomplete="username" /></div>
<div class="field"><label>Passwort</label>
<input name="password" type="password" required autocomplete="current-password" /></div>
<div style="margin-top: 1rem"><button class="btn primary" type="submit" style="width:100%">Einloggen</button></div>
<div class="err" id="loginErr"></div>
</form>
</div>
</div>
<!-- MAIN APP -->
<div id="view-app" style="display:none">
<nav class="nav">
<div class="nav-left">
<img id="navLogo" class="nav-logo" style="display:none" alt="Logo" />
<div class="nav-title">Adminportal</div>
</div>
<div class="nav-right">
<span id="whoami"></span>
<button id="changePwBtn" class="btn sm">Passwort ändern</button>
<button id="logoutBtn" class="btn sm">Logout</button>
</div>
</nav>
<div class="container">
<div class="tabs">
<button class="tab active" data-tab="customers">Kunden</button>
<button class="tab" data-tab="users" id="tabUsersBtn">Benutzer</button>
<button class="tab" data-tab="settings" id="tabSettingsBtn">Einstellungen</button>
</div>
<!-- CUSTOMERS TAB -->
<section id="tab-customers">
<div class="card" id="createCustomerCard" style="display:none">
<h3 class="card-title">Neuen Kunden anlegen</h3>
<form id="createCustomerForm" class="form-row">
<div><label>Name</label><input name="name" required placeholder="Firma ABC" /></div>
<div><label>E-Mail (optional)</label><input name="email" type="email" placeholder="kunde@firma.de" /></div>
<div><label>Passwort (optional)</label><input name="password" placeholder="Leer = offen" /></div>
<div><label>Ablaufdatum (optional)</label><input name="expires_at" placeholder="YYYY-MM-DD HH:MM" /></div>
<div><label>&nbsp;</label><button class="btn primary" type="submit">Anlegen</button></div>
</form>
</div>
<div class="card" style="padding: 0; margin-top: 1rem">
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Kunde</th><th>E-Mail</th><th>Slug</th><th>Upload-Link</th><th>Schutz</th>
<th>Ablauf</th><th>Uploads</th><th></th>
</tr>
</thead>
<tbody id="customerRows"></tbody>
</table>
</div>
</div>
<p class="small" style="margin-top:.75rem">WebDAV-Server: <code id="webdavUrl"></code> — Login mit deinem eigenen Benutzer.</p>
</section>
<!-- USERS TAB -->
<section id="tab-users" style="display:none">
<div class="card">
<h3 class="card-title">Benutzer anlegen</h3>
<form id="createUserForm" class="form-row">
<div><label>Benutzername</label><input name="username" required pattern="[A-Za-z0-9._-]+" /></div>
<div><label>Passwort</label><input name="password" required minlength="8" type="text" /></div>
<div><label>E-Mail (optional)</label><input name="email" type="email" placeholder="name@firma.de" /></div>
<div><label>Rolle</label>
<select name="role">
<option value="staff">Sachbearbeiter</option>
<option value="admin">Admin</option>
</select></div>
<div><label>&nbsp;</label><button class="btn primary" type="submit">Anlegen</button></div>
</form>
</div>
<div class="card" style="padding:0; margin-top:1rem">
<div class="table-wrap">
<table>
<thead>
<tr><th>Benutzername</th><th>E-Mail</th><th>Rolle</th><th>Angelegt</th><th></th></tr>
</thead>
<tbody id="userRows"></tbody>
</table>
</div>
</div>
</section>
<!-- SETTINGS TAB -->
<section id="tab-settings" style="display:none">
<div class="card">
<h3 class="card-title">Branding</h3>
<p class="card-sub">Logo wird im Adminportal und in der Kunden-Upload-Seite angezeigt.</p>
<div class="logo-preview">
<div id="logoPreviewBox">
<div class="placeholder">kein Logo</div>
</div>
<div style="flex:1">
<input type="file" id="logoFile" accept="image/png,image/jpeg,image/webp,image/gif" style="display:none" />
<button class="btn" id="logoUploadBtn" type="button">Logo hochladen</button>
<button class="btn danger" id="logoDeleteBtn" type="button" style="margin-left:.25rem">Entfernen</button>
<div class="small" style="margin-top:.5rem">Max 2 MB. PNG, JPG, WEBP, GIF.</div>
</div>
</div>
<div class="field"><label>Breite (px) — 0 = automatisch</label>
<div class="range-row">
<input type="range" id="logoWidth" min="0" max="600" step="5" />
<span class="val"><span id="logoWidthVal">0</span></span>
</div>
</div>
<div class="field"><label>Höhe (px) — 0 = automatisch</label>
<div class="range-row">
<input type="range" id="logoHeight" min="0" max="400" step="5" />
<span class="val"><span id="logoHeightVal">0</span></span>
</div>
</div>
<div class="field"><label>Proportionale Skalierung (%)</label>
<div class="range-row">
<input type="range" id="logoScale" min="25" max="300" step="5" value="100" />
<span class="val"><span id="logoScaleVal">100</span> %</span>
<button type="button" class="btn sm" id="logoReset">↺ Reset</button>
</div>
<p class="small" style="margin:.35rem 0 0">Setzt Breite & Höhe anhand der Originalmaße × Skalierung.</p>
</div>
</div>
<div class="card">
<h3 class="card-title">E-Mail-Benachrichtigungen (SMTP)</h3>
<p class="card-sub">Bestimmt den Versand der Upload-Benachrichtigungen. Kunden und zuständige Sachbearbeiter mit E-Mail-Adresse bekommen nach einem Upload eine Zusammenfassung.</p>
<form id="smtpForm">
<div class="form-row">
<div><label>SMTP-Host</label><input name="smtp_host" placeholder="smtp.mailserver.de" /></div>
<div><label>Port</label><input name="smtp_port" type="number" min="1" style="max-width:7rem" /></div>
<div><label style="display:flex; align-items:center; gap:.4rem; margin-top:1.5rem">
<input type="checkbox" name="smtp_secure" style="width:auto" />TLS (Port 465)
</label></div>
</div>
<div class="form-row">
<div><label>Benutzername</label><input name="smtp_user" /></div>
<div><label>Passwort</label><input name="smtp_pass" type="password" placeholder="unverändert lassen = nicht überschreiben" autocomplete="new-password" /></div>
<div><label>Absender (From)</label><input name="smtp_from" placeholder="noreply@firma.de" /></div>
</div>
<div class="field" style="margin-top:.75rem">
<label style="display:flex; align-items:center; gap:.4rem">
<input type="checkbox" name="smtp_notify_customer" style="width:auto" />
Kunden nach Upload eine Bestätigungsmail schicken (nur wenn E-Mail beim Kunden hinterlegt)
</label>
</div>
<div class="field" style="margin-top:.5rem">
<label style="display:flex; align-items:center; gap:.4rem">
<input type="checkbox" name="smtp_notify_admin" style="width:auto" />
Admins bei jedem Upload benachrichtigen (alle Admins mit E-Mail-Adresse)
</label>
</div>
<div style="margin-top:1rem; display:flex; gap:.5rem; align-items:center; flex-wrap:wrap">
<button class="btn primary" type="submit">Speichern</button>
<input type="email" id="testMailTo" placeholder="Test-Empfänger" style="max-width:240px" />
<button class="btn" type="button" id="sendTestMail">Test-Mail senden</button>
<span class="ok-msg" id="smtpMsg"></span>
</div>
</form>
</div>
<div class="card">
<h3 class="card-title">Allgemein</h3>
<form id="settingsForm">
<div class="field"><label>Öffentliche Basis-URL</label>
<input name="public_base_url" placeholder="z. B. https://upload.example.com" />
<p class="small" style="margin:.35rem 0 0">Wird in den Kunden-Upload-Links eingesetzt. Leer lassen = aus dem Request abgeleitet.</p>
</div>
<div class="field"><label>Private WebDAV-URL</label>
<input name="webdav_url" placeholder="z. B. webdav://upload.example.com:1900/" />
<p class="small" style="margin:.35rem 0 0">Wird im Adminportal unter der Kundenliste angezeigt. Leer lassen = <code>webdav://&lt;host&gt;:1900/</code>.</p>
</div>
<div class="field"><label>Cron-Intervall (Minuten)</label>
<input name="janitor_interval_minutes" type="number" min="1" style="max-width: 8rem" />
<p class="small" style="margin:.35rem 0 0">Periodischer DB/FS-Abgleich: entfernt verwaiste DB-Einträge, erfasst neu per WebDAV hochgeladene Dateien.</p>
</div>
<div style="margin-top: 1.25rem; display:flex; gap:.5rem; align-items:center">
<button class="btn primary" type="submit">Speichern</button>
<button class="btn" type="button" id="runJanitor">Abgleich jetzt ausführen</button>
<span class="ok-msg" id="settingsMsg"></span>
</div>
</form>
</div>
</section>
</div>
</div>
<!-- EDIT CUSTOMER MODAL -->
<div class="modal" id="editCustomerModal">
<div class="modal-inner">
<h3 id="editCustomerTitle">Kunde bearbeiten</h3>
<form id="editCustomerForm">
<div class="field"><label>E-Mail</label>
<input name="email" type="email" placeholder="leer = entfernen" /></div>
<div class="field"><label>Passwort (leer = unverändert, min. 8 Zeichen)</label>
<input name="password" type="text" minlength="8" autocomplete="new-password" /></div>
<label style="display:flex; gap:.4rem; align-items:center; font-weight:normal; color:var(--text); margin-top:.4rem">
<input type="checkbox" name="clear_password" style="width:auto" />
Vorhandenes Passwort entfernen
</label>
<div class="field" style="margin-top:1rem"><label>Ablaufdatum</label>
<input name="expires_at" type="datetime-local" /></div>
<label style="display:flex; gap:.4rem; align-items:center; font-weight:normal; color:var(--text); margin-top:.4rem">
<input type="checkbox" name="clear_expires" style="width:auto" />
Ablauf entfernen (unbegrenzt gültig)
</label>
<div style="margin-top:1.25rem; display:flex; gap:.5rem; justify-content:flex-end">
<button type="button" class="btn" id="editCustomerCancel">Abbrechen</button>
<button type="submit" class="btn primary">Speichern</button>
</div>
</form>
</div>
</div>
<!-- ACCESS MODAL -->
<div class="modal" id="accessModal">
<div class="modal-inner">
<h3 id="accessTitle">Zugriff verwalten</h3>
<p class="small" style="margin-top: 0">Wer darf per WebDAV auf die Kundendateien zugreifen?</p>
<div id="accessList"></div>
<div style="margin-top:1.25rem; display:flex; gap:.5rem; justify-content:flex-end">
<button class="btn" id="accessCancel">Abbrechen</button>
<button class="btn primary" id="accessSave">Speichern</button>
</div>
</div>
</div>
<script>
const api = {
async get(path) {
const r = await fetch(`/admin/api${path}`);
if (r.status === 401) throw Object.assign(new Error('unauth'), { status: 401 });
return r.json();
},
async send(method, path, body) {
const r = await fetch(`/admin/api${path}`, {
method, headers: {'Content-Type':'application/json'},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const data = await r.json().catch(() => ({}));
if (!r.ok) throw Object.assign(new Error(data.error || r.statusText), { status: r.status });
return data;
},
async upload(path, file, field = 'file') {
const fd = new FormData();
fd.append(field, file);
const r = await fetch(`/admin/api${path}`, { method: 'POST', body: fd });
const data = await r.json().catch(() => ({}));
if (!r.ok) throw Object.assign(new Error(data.error || r.statusText), { status: r.status });
return data;
},
};
let me = null;
function show(view) {
for (const id of ['view-setup','view-login','view-app']) {
document.getElementById(id).style.display = id === view ? '' : 'none';
}
}
function fmtSize(n) {
if (!n) return '0 B';
if (n < 1024) return n + ' B';
if (n < 1024*1024) return (n/1024).toFixed(1) + ' KB';
if (n < 1024*1024*1024) return (n/1024/1024).toFixed(1) + ' MB';
return (n/1024/1024/1024).toFixed(2) + ' GB';
}
function parseDate(s) { if (!s) return null; const t = Date.parse(String(s).replace(' ','T')); return isNaN(t)?null:t; }
function esc(s) { return String(s || '').replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
let naturalW = 0, naturalH = 0;
function applySizeStyle(el, w, h) {
el.style.width = w > 0 ? w + 'px' : '';
el.style.height = h > 0 ? h + 'px' : '';
if (!w && !h) { el.style.maxHeight = '40px'; el.style.maxWidth = '240px'; }
else { el.style.maxHeight = ''; el.style.maxWidth = ''; }
}
async function applyLogo(width, height) {
const img = document.getElementById('navLogo');
const src = '/logo?t=' + Date.now();
const r = await fetch(src, { method: 'HEAD' });
if (r.ok) { img.src = src; img.style.display = ''; applySizeStyle(img, width, height); }
else img.style.display = 'none';
}
function measureNatural() {
return new Promise((resolve) => {
const probe = new Image();
probe.onload = () => { naturalW = probe.naturalWidth; naturalH = probe.naturalHeight; resolve(); };
probe.onerror = () => { naturalW = naturalH = 0; resolve(); };
probe.src = '/logo?t=' + Date.now();
});
}
async function bootstrap() {
const [status, branding] = await Promise.all([api.get('/status'), api.get('/branding')]);
await measureNatural();
applyLogo(branding.logo_width_px, branding.logo_height_px);
if (status.setup_required) return show('view-setup');
if (!status.authenticated) return show('view-login');
me = status.user;
document.getElementById('whoami').textContent = `${me.username} · ${me.role}`;
if (me.role !== 'admin') {
document.getElementById('tabUsersBtn').style.display = 'none';
document.getElementById('tabSettingsBtn').style.display = 'none';
document.getElementById('createCustomerCard').style.display = 'none';
} else {
document.getElementById('createCustomerCard').style.display = '';
}
document.getElementById('webdavUrl').textContent =
(status.webdav_url || '').trim() || `webdav://${location.hostname}:1900/`;
show('view-app');
loadCustomers();
}
// Setup
document.getElementById('setupForm').addEventListener('submit', async (e) => {
e.preventDefault();
try { await api.send('POST', '/setup', Object.fromEntries(new FormData(e.target))); location.reload(); }
catch (ex) { document.getElementById('setupErr').textContent = ex.message; }
});
// Login
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
try { await api.send('POST', '/login', Object.fromEntries(new FormData(e.target))); location.reload(); }
catch { document.getElementById('loginErr').textContent = 'Login fehlgeschlagen'; }
});
document.getElementById('logoutBtn').onclick = async () => { await api.send('POST', '/logout'); location.reload(); };
document.getElementById('changePwBtn').onclick = async () => {
const oldPw = prompt('Aktuelles Passwort:');
if (!oldPw) return;
const newPw = prompt('Neues Passwort (min. 8 Zeichen):');
if (!newPw) return;
if (newPw.length < 8) return alert('Passwort zu kurz.');
const repeat = prompt('Neues Passwort wiederholen:');
if (newPw !== repeat) return alert('Eingaben stimmen nicht überein.');
try {
await api.send('POST', '/me/password', { old_password: oldPw, new_password: newPw });
alert('Passwort geändert. Andere Sitzungen wurden abgemeldet.');
} catch (ex) { alert('Fehler: ' + ex.message); }
};
// Tabs
document.querySelectorAll('.tab').forEach(btn => {
btn.onclick = () => {
document.querySelectorAll('.tab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const tab = btn.dataset.tab;
document.getElementById('tab-customers').style.display = tab === 'customers' ? '' : 'none';
document.getElementById('tab-users').style.display = tab === 'users' ? '' : 'none';
document.getElementById('tab-settings').style.display = tab === 'settings' ? '' : 'none';
if (tab === 'users') loadUsers();
if (tab === 'settings') loadSettings();
};
});
// Customers
let customersCache = [];
async function loadCustomers() {
const data = await api.get('/customers');
customersCache = data;
const rows = document.getElementById('customerRows');
const adminOnly = me.role === 'admin';
rows.innerHTML = '';
if (!data.length) {
rows.innerHTML = `<tr><td colspan="8" class="small" style="padding:1.5rem; text-align:center">Noch keine Kunden.</td></tr>`;
return;
}
for (const c of data) {
const link = c.upload_url || '';
const tr = document.createElement('tr');
if (c.archived) tr.classList.add('archived');
const nameCell = `<strong>${esc(c.name)}</strong>` + (c.archived ? ' <span class="pill archived">archiviert</span>' : '');
const linkCell = c.archived
? '<span class="small">— archiviert —</span>'
: (adminOnly
? `<div style="display:flex; gap:.35rem; align-items:center"><code style="max-width:26ch; overflow:hidden; text-overflow:ellipsis">${esc(link)}</code><button class="btn sm copy-btn" data-copy="${esc(link)}">📋</button></div>`
: '<span class="small"></span>');
const actions = !adminOnly
? `<span class="pill ${c.my_access}">${c.my_access}</span>`
: (c.archived
? `
<button class="btn sm" data-access="${c.id}" data-name="${esc(c.name)}">Zugriff</button>
<button class="btn sm" data-unarchive="${c.id}">Reaktivieren</button>
<button class="btn sm danger" data-purge="${c.id}" data-name="${esc(c.name)}">Dateien löschen</button>`
: `
<button class="btn sm" data-access="${c.id}" data-name="${esc(c.name)}">Zugriff</button>
<button class="btn sm" data-edit="${c.id}">Bearbeiten</button>
<button class="btn sm" data-regen="${c.id}">Neuer Link</button>
<button class="btn sm" data-archive="${c.id}">Deaktivieren</button>
<button class="btn sm danger" data-purge="${c.id}" data-name="${esc(c.name)}">Dateien löschen</button>`);
tr.innerHTML = `
<td>${nameCell}</td>
<td class="small">${c.email ? esc(c.email) : ''}</td>
<td><code>${esc(c.slug)}</code></td>
<td>${linkCell}</td>
<td>${c.has_password ? '<span class="pill lock">🔒 PW</span>' : '<span class="small"></span>'}</td>
<td class="small">${c.expires_at ? new Date(c.expires_at).toLocaleString() : ''}</td>
<td><span class="pill count">${c.upload_count} · ${fmtSize(c.total_size)}</span></td>
<td class="cell-actions">${actions}</td>`;
rows.appendChild(tr);
}
}
document.getElementById('customerRows').addEventListener('click', async (e) => {
const t = e.target.closest('button'); if (!t) return;
if (t.dataset.copy) {
navigator.clipboard.writeText(t.dataset.copy);
const orig = t.textContent; t.textContent = '✓'; setTimeout(() => t.textContent = orig, 1200);
}
if (t.dataset.archive && confirm('Kunden deaktivieren? Der Upload-Link wird ungültig, Einträge & Dateien bleiben bestehen.')) {
await api.send('POST', `/customers/${t.dataset.archive}/archive`); loadCustomers();
}
if (t.dataset.unarchive && confirm('Kunden wieder aktivieren? Der Upload-Link wird wieder gültig.')) {
await api.send('POST', `/customers/${t.dataset.unarchive}/unarchive`); loadCustomers();
}
if (t.dataset.purge) {
const name = t.dataset.name || '';
const confirmName = prompt(`⚠️ Unwiderruflich: Alle Dateien und der Eintrag von "${name}" werden gelöscht.\n\nZum Bestätigen "${name}" eingeben:`);
if (confirmName === name) {
await api.send('DELETE', `/customers/${t.dataset.purge}`); loadCustomers();
}
}
if (t.dataset.regen && confirm('Neuen Link erzeugen? Alter Link wird ungültig.')) {
await api.send('POST', `/customers/${t.dataset.regen}/regenerate-token`); loadCustomers();
}
if (t.dataset.edit) {
const c = customersCache.find(x => String(x.id) === t.dataset.edit);
if (c) openEditCustomer(c);
}
if (t.dataset.access) openAccessModal(t.dataset.access, t.dataset.name);
});
document.getElementById('createCustomerForm').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
try {
await api.send('POST', '/customers', {
name: fd.get('name'),
email: fd.get('email') || undefined,
password: fd.get('password') || undefined,
expires_at: parseDate(fd.get('expires_at')) || undefined,
});
e.target.reset(); loadCustomers();
} catch (ex) { alert('Fehler: ' + ex.message); }
});
// Users
async function loadUsers() {
const data = await api.get('/users');
const rows = document.getElementById('userRows');
rows.innerHTML = '';
for (const u of data) {
const tr = document.createElement('tr');
const roleSel = `
<select data-rolesel="${u.id}" data-prev="${u.role}" class="role-select">
<option value="staff" ${u.role === 'staff' ? 'selected' : ''}>Sachbearbeiter</option>
<option value="admin" ${u.role === 'admin' ? 'selected' : ''}>Admin</option>
</select>`;
tr.innerHTML = `
<td><strong>${esc(u.username)}</strong>${u.id === me.id ? ' <span class="small">(du)</span>' : ''}</td>
<td class="small">${u.email ? esc(u.email) : ''}</td>
<td>${roleSel}</td>
<td class="small">${new Date(u.created_at).toLocaleDateString()}</td>
<td class="cell-actions">
<button class="btn sm" data-email="${u.id}">E-Mail</button>
<button class="btn sm" data-pw="${u.id}">Passwort</button>
<button class="btn sm danger" data-deluser="${u.id}" data-name="${esc(u.username)}">Löschen</button>
</td>`;
rows.appendChild(tr);
}
}
document.getElementById('userRows').addEventListener('click', async (e) => {
const t = e.target.closest('button'); if (!t) return;
try {
if (t.dataset.email) {
const em = prompt('E-Mail (leer = entfernen):', '');
if (em === null) return;
await api.send('PATCH', `/users/${t.dataset.email}`, { email: em || '' }); loadUsers();
}
if (t.dataset.pw) {
const pw = prompt('Neues Passwort (min. 8 Zeichen):', ''); if (!pw) return;
await api.send('PATCH', `/users/${t.dataset.pw}`, { password: pw }); loadUsers();
}
if (t.dataset.deluser && confirm(`Benutzer "${t.dataset.name}" löschen?`)) {
await api.send('DELETE', `/users/${t.dataset.deluser}`); loadUsers();
}
} catch (ex) { alert('Fehler: ' + ex.message); }
});
document.getElementById('userRows').addEventListener('change', async (e) => {
const sel = e.target;
if (!sel.dataset || !sel.dataset.rolesel) return;
const newRole = sel.value;
const prev = sel.dataset.prev;
if (newRole === prev) return;
if (!confirm(`Rolle wirklich auf "${newRole}" ändern?`)) {
sel.value = prev;
return;
}
try {
await api.send('PATCH', `/users/${sel.dataset.rolesel}`, { role: newRole });
sel.dataset.prev = newRole;
} catch (ex) {
alert('Fehler: ' + ex.message);
sel.value = prev;
}
});
document.getElementById('createUserForm').addEventListener('submit', async (e) => {
e.preventDefault();
try { await api.send('POST', '/users', Object.fromEntries(new FormData(e.target))); e.target.reset(); loadUsers(); }
catch (ex) { alert('Fehler: ' + ex.message); }
});
// Settings
async function loadSettings() {
await measureNatural();
const s = await api.get('/settings');
const form = document.getElementById('settingsForm');
form.public_base_url.value = s.public_base_url || '';
form.webdav_url.value = s.webdav_url || '';
form.janitor_interval_minutes.value = s.janitor_interval_minutes || 30;
setSlider('logoWidth', s.logo_width_px || 0);
setSlider('logoHeight', s.logo_height_px || 0);
updateScaleFromWH();
updateLogoPreview(s.logo_filename);
const sf = document.getElementById('smtpForm');
sf.smtp_host.value = s.smtp_host || '';
sf.smtp_port.value = s.smtp_port || 587;
sf.smtp_secure.checked = !!s.smtp_secure;
sf.smtp_user.value = s.smtp_user || '';
sf.smtp_from.value = s.smtp_from || '';
sf.smtp_pass.value = '';
sf.smtp_pass.placeholder = s.smtp_pass_set
? 'gesetzt — leer = unverändert lassen'
: 'nicht gesetzt';
sf.smtp_notify_admin.checked = !!s.smtp_notify_admin;
sf.smtp_notify_customer.checked = s.smtp_notify_customer !== false;
}
function setSlider(id, val) {
document.getElementById(id).value = val;
document.getElementById(id + 'Val').textContent = val;
}
function getSlider(id) { return parseInt(document.getElementById(id).value, 10) || 0; }
function updateScaleFromWH() {
if (!naturalW || !naturalH) return;
const w = getSlider('logoWidth');
const h = getSlider('logoHeight');
let pct = 100;
if (w > 0) pct = Math.round((w / naturalW) * 100);
else if (h > 0) pct = Math.round((h / naturalH) * 100);
setSlider('logoScale', Math.max(25, Math.min(300, pct)));
}
function updateLogoPreview(filename) {
const box = document.getElementById('logoPreviewBox');
if (filename) {
const w = getSlider('logoWidth'), h = getSlider('logoHeight');
box.innerHTML = `<img id="previewLogo" src="/logo?t=${Date.now()}" alt="Logo"/>`;
applySizeStyle(box.querySelector('img'), w || 0, h || 0);
if (!w && !h) { const im = box.querySelector('img'); im.style.maxHeight = '80px'; im.style.maxWidth = '240px'; }
} else {
box.innerHTML = `<div class="placeholder">kein Logo</div>`;
}
applyLogo(getSlider('logoWidth'), getSlider('logoHeight'));
}
document.getElementById('settingsForm').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
try {
await api.send('PUT', '/settings', {
public_base_url: fd.get('public_base_url') || '',
webdav_url: fd.get('webdav_url') || '',
janitor_interval_minutes: parseInt(fd.get('janitor_interval_minutes') || '30', 10),
logo_width_px: getSlider('logoWidth'),
logo_height_px: getSlider('logoHeight'),
});
// Refresh main view in case the WebDAV-URL display needs an update.
document.getElementById('webdavUrl').textContent =
(fd.get('webdav_url') || '').trim() || `webdav://${location.hostname}:1900/`;
const msg = document.getElementById('settingsMsg');
msg.textContent = '✓ Gespeichert';
setTimeout(() => msg.textContent = '', 2000);
applyLogo(getSlider('logoWidth'), getSlider('logoHeight'));
} catch (ex) { alert('Fehler: ' + ex.message); }
});
document.getElementById('smtpForm').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const body = {
smtp_host: fd.get('smtp_host') || '',
smtp_port: parseInt(fd.get('smtp_port') || '587', 10),
smtp_secure: !!fd.get('smtp_secure'),
smtp_user: fd.get('smtp_user') || '',
smtp_from: fd.get('smtp_from') || '',
smtp_notify_admin: !!fd.get('smtp_notify_admin'),
smtp_notify_customer: !!fd.get('smtp_notify_customer'),
};
const pw = fd.get('smtp_pass');
if (pw) body.smtp_pass = pw;
try {
await api.send('PUT', '/settings', body);
const msg = document.getElementById('smtpMsg');
msg.textContent = '✓ Gespeichert';
setTimeout(() => msg.textContent = '', 2000);
loadSettings();
} catch (ex) { alert('Fehler: ' + ex.message); }
});
document.getElementById('sendTestMail').addEventListener('click', async () => {
const to = document.getElementById('testMailTo').value.trim();
const msg = document.getElementById('smtpMsg');
msg.textContent = '… sende';
try {
await api.send('POST', '/settings/test-mail', { to });
msg.textContent = '✓ Test-Mail versendet';
} catch (ex) { msg.textContent = 'Fehler: ' + ex.message; }
});
document.getElementById('runJanitor').addEventListener('click', async () => {
const msg = document.getElementById('settingsMsg'); msg.textContent = '… läuft';
try { const r = await api.send('POST', '/janitor/run'); msg.textContent = `✓ +${r.added} / -${r.removed}`; }
catch (ex) { msg.textContent = 'Fehler: ' + ex.message; }
});
document.getElementById('logoWidth').addEventListener('input', (e) => {
document.getElementById('logoWidthVal').textContent = e.target.value;
updateScaleFromWH();
updateLogoPreview(document.querySelector('#logoPreviewBox img') ? 'yes' : '');
});
document.getElementById('logoHeight').addEventListener('input', (e) => {
document.getElementById('logoHeightVal').textContent = e.target.value;
updateScaleFromWH();
updateLogoPreview(document.querySelector('#logoPreviewBox img') ? 'yes' : '');
});
document.getElementById('logoScale').addEventListener('input', (e) => {
const pct = parseInt(e.target.value, 10);
document.getElementById('logoScaleVal').textContent = pct;
if (naturalW && naturalH) {
setSlider('logoWidth', Math.round(naturalW * pct / 100));
setSlider('logoHeight', Math.round(naturalH * pct / 100));
updateLogoPreview(document.querySelector('#logoPreviewBox img') ? 'yes' : '');
}
});
document.getElementById('logoReset').addEventListener('click', () => {
setSlider('logoWidth', 0);
setSlider('logoHeight', 0);
setSlider('logoScale', 100);
updateLogoPreview(document.querySelector('#logoPreviewBox img') ? 'yes' : '');
});
document.getElementById('logoUploadBtn').onclick = () => document.getElementById('logoFile').click();
document.getElementById('logoFile').onchange = async (e) => {
const f = e.target.files[0]; if (!f) return;
try {
await api.upload('/logo', f, 'logo');
await measureNatural();
const s = await api.get('/settings');
updateLogoPreview(s.logo_filename);
} catch (ex) { alert('Fehler: ' + ex.message); }
};
document.getElementById('logoDeleteBtn').onclick = async () => {
if (!confirm('Logo entfernen?')) return;
await api.send('DELETE', '/logo');
naturalW = naturalH = 0;
updateLogoPreview('');
};
// Edit Customer modal
let editingCustomerId = null;
function dtLocal(ms) {
const d = new Date(ms);
const pad = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function openEditCustomer(c) {
editingCustomerId = c.id;
document.getElementById('editCustomerTitle').textContent = `Bearbeiten ${c.name}`;
const f = document.getElementById('editCustomerForm');
f.email.value = c.email || '';
f.password.value = '';
f.clear_password.checked = false;
f.clear_expires.checked = false;
f.expires_at.value = c.expires_at ? dtLocal(c.expires_at) : '';
document.getElementById('editCustomerModal').classList.add('open');
}
document.getElementById('editCustomerCancel').onclick = () =>
document.getElementById('editCustomerModal').classList.remove('open');
document.getElementById('editCustomerForm').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const body = { email: (fd.get('email') || '').trim() };
if (fd.get('clear_password')) body.clear_password = true;
else if (fd.get('password')) body.password = fd.get('password');
if (fd.get('clear_expires')) body.expires_at = null;
else if (fd.get('expires_at')) body.expires_at = new Date(fd.get('expires_at')).getTime();
try {
await api.send('PATCH', `/customers/${editingCustomerId}`, body);
document.getElementById('editCustomerModal').classList.remove('open');
loadCustomers();
} catch (ex) { alert('Fehler: ' + ex.message); }
});
// Access modal
let accessCustomerId = null;
async function openAccessModal(id, name) {
accessCustomerId = id;
document.getElementById('accessTitle').textContent = `Zugriff ${name}`;
const staff = await api.get(`/customers/${id}/access`);
const list = document.getElementById('accessList');
if (!staff.length) {
list.innerHTML = '<p class="small">Noch keine Sachbearbeiter angelegt. Lege welche im Tab <b>Benutzer</b> an.</p>';
} else {
list.innerHTML = staff.map(s => `
<div class="access-row">
<div><strong>${esc(s.username)}</strong></div>
<label><input type="radio" name="acc-${s.user_id}" value="" ${!s.access?'checked':''}> kein</label>
<label><input type="radio" name="acc-${s.user_id}" value="read" ${s.access==='read'?'checked':''}> lesen</label>
<label><input type="radio" name="acc-${s.user_id}" value="write" ${s.access==='write'?'checked':''}> schreiben</label>
</div>`).join('');
}
document.getElementById('accessModal').classList.add('open');
}
document.getElementById('accessCancel').onclick = () => document.getElementById('accessModal').classList.remove('open');
document.getElementById('accessSave').onclick = async () => {
const entries = [];
document.querySelectorAll('#accessList .access-row').forEach(row => {
const picked = row.querySelector('input[type=radio]:checked');
if (!picked || !picked.value) return;
entries.push({ user_id: parseInt(picked.name.replace('acc-',''), 10), access: picked.value });
});
try {
await api.send('PUT', `/customers/${accessCustomerId}/access`, { access: entries });
document.getElementById('accessModal').classList.remove('open');
} catch (ex) { alert('Fehler: ' + ex.message); }
};
bootstrap().catch(e => {
document.getElementById('loginErr').textContent = 'Fehler: ' + e.message;
show('view-login');
});
</script>
</body>
</html>