873 lines
39 KiB
HTML
873 lines
39 KiB
HTML
<!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; }
|
||
|
||
.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. 6 Zeichen)</label>
|
||
<input name="password" type="password" required minlength="6" 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="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> </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="6" 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> </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/svg+xml,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, SVG, 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_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">Leer lassen, um aus jedem Request die aktuelle URL zu nutzen.</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>
|
||
|
||
<!-- 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 => ({'&':'&','<':'<','>':'>','"':'"'}[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 = `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(); };
|
||
|
||
// 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
|
||
async function loadCustomers() {
|
||
const data = await api.get('/customers');
|
||
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 em = prompt('E-Mail (leer = unverändert, "-" = entfernen):', ''); if (em === null) return;
|
||
const pw = prompt('Neues Passwort (leer = unverändert, "-" = entfernen):', ''); if (pw === null) return;
|
||
const exp = prompt('Neuer Ablauf (YYYY-MM-DD HH:MM, leer = unverändert, "-" = entfernen):', ''); if (exp === null) return;
|
||
const body = {};
|
||
if (em === '-') body.email = ''; else if (em) body.email = em;
|
||
if (pw === '-') body.clear_password = true; else if (pw) body.password = pw;
|
||
if (exp === '-') body.expires_at = null; else if (exp) body.expires_at = parseDate(exp);
|
||
await api.send('PATCH', `/customers/${t.dataset.edit}`, body); loadCustomers();
|
||
}
|
||
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');
|
||
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><span class="pill ${u.role === 'admin' ? 'admin' : 'staff'}">${u.role}</span></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" data-role="${u.id}" data-current="${u.role}">Rolle</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. 6 Zeichen):', ''); if (!pw) return;
|
||
await api.send('PATCH', `/users/${t.dataset.pw}`, { password: pw }); loadUsers();
|
||
}
|
||
if (t.dataset.role) {
|
||
const newRole = t.dataset.current === 'admin' ? 'staff' : 'admin';
|
||
if (!confirm(`Rolle auf "${newRole}" ändern?`)) return;
|
||
await api.send('PATCH', `/users/${t.dataset.role}`, { role: newRole }); 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('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.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;
|
||
}
|
||
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') || '',
|
||
janitor_interval_minutes: parseInt(fd.get('janitor_interval_minutes') || '30', 10),
|
||
logo_width_px: getSlider('logoWidth'),
|
||
logo_height_px: getSlider('logoHeight'),
|
||
});
|
||
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'),
|
||
};
|
||
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('');
|
||
};
|
||
|
||
// 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>
|