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

760 lines
33 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); }
.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>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>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>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>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">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 => ({'&':'&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 = `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="7" 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');
tr.innerHTML = `
<td><strong>${esc(c.name)}</strong></td>
<td><code>${esc(c.slug)}</code></td>
<td>${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>'}</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">${adminOnly ? `
<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 danger" data-del="${c.id}">Löschen</button>
` : `<span class="pill ${c.my_access}">${c.my_access}</span>`}</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.del && confirm('Kunde wirklich löschen? (Dateien bleiben auf Disk.)')) {
await api.send('DELETE', `/customers/${t.dataset.del}`); 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 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 (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'),
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><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-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.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);
}
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('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>