453 lines
19 KiB
HTML
453 lines
19 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 { color-scheme: light dark; }
|
||
body { font-family: system-ui, sans-serif; max-width: 1150px; margin: 0 auto; padding: 1rem; }
|
||
header { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #555; padding-bottom: .5rem; margin-bottom: 1rem; }
|
||
h1 { margin: 0; font-size: 1.25rem; }
|
||
.tabs { display: flex; gap: .25rem; margin-bottom: 1rem; }
|
||
.tabs button { padding: .4rem .9rem; border: 1px solid #555; background: transparent; color: inherit; border-radius: 6px 6px 0 0; cursor: pointer; }
|
||
.tabs button.active { background: rgba(0,120,255,.15); border-color: #0078ff; }
|
||
input, select, button { padding: .45rem .55rem; border-radius: 6px; border: 1px solid #888; background: transparent; color: inherit; font: inherit; }
|
||
button.primary { background: #0078ff; color: #fff; border-color: #0078ff; cursor: pointer; }
|
||
button.danger { border-color: #e74c3c; color: #e74c3c; cursor: pointer; background: transparent; }
|
||
button { cursor: pointer; }
|
||
table { width: 100%; border-collapse: collapse; }
|
||
th, td { text-align: left; padding: .4rem .5rem; border-bottom: 1px solid #444; vertical-align: top; font-size: .92rem; }
|
||
th { font-size: .78rem; text-transform: uppercase; color: #aaa; }
|
||
.actions button { margin-right: .2rem; }
|
||
code { font-size: .85rem; word-break: break-all; }
|
||
.small { font-size: .8rem; color: #999; }
|
||
form.inline { display: grid; grid-template-columns: repeat(5, auto) 1fr; gap: .4rem; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; }
|
||
.center { display: flex; align-items: center; justify-content: center; min-height: 80vh; }
|
||
.card { border: 1px solid #555; border-radius: 10px; padding: 1.5rem; max-width: 420px; width: 100%; }
|
||
.card h2 { margin-top: 0; }
|
||
.card label { display: block; margin: .6rem 0 .2rem; font-size: .9rem; }
|
||
.card input { width: 100%; box-sizing: border-box; }
|
||
.err { color: #e74c3c; margin-top: .6rem; }
|
||
.modal { position: fixed; inset: 0; background: rgba(0,0,0,.55); display: none; align-items: center; justify-content: center; z-index: 10; }
|
||
.modal.open { display: flex; }
|
||
.modal-inner { background: var(--bg, #222); color: inherit; border: 1px solid #555; border-radius: 10px; padding: 1.25rem; max-width: 620px; width: 100%; max-height: 80vh; overflow: auto; }
|
||
@media (prefers-color-scheme: light) { .modal-inner { background: #fff; } }
|
||
.access-row { display: grid; grid-template-columns: 1fr auto auto auto; gap: .5rem; align-items: center; padding: .3rem 0; border-bottom: 1px solid #333; }
|
||
.access-row label { display: inline-flex; gap: .3rem; align-items: center; font-size: .9rem; }
|
||
.muted-row td { color: #888; }
|
||
.pill { display: inline-block; padding: .1rem .45rem; border-radius: 999px; font-size: .75rem; border: 1px solid #888; }
|
||
.pill.admin { color: #0078ff; border-color: #0078ff; }
|
||
.pill.write { color: #2ecc71; border-color: #2ecc71; }
|
||
.pill.read { color: #888; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- SETUP VIEW -->
|
||
<div id="view-setup" class="center" style="display:none">
|
||
<div class="card">
|
||
<h2>Erstmal einrichten</h2>
|
||
<p class="small">Lege den ersten Admin-Account an. Dieser kann später weitere Benutzer anlegen.</p>
|
||
<form id="setupForm">
|
||
<label>Benutzername</label>
|
||
<input name="username" required minlength="2" pattern="[A-Za-z0-9._-]+" />
|
||
<label>Passwort (min. 6 Zeichen)</label>
|
||
<input name="password" type="password" required minlength="6" />
|
||
<div style="margin-top:1rem"><button class="primary" type="submit">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">
|
||
<h2>Login</h2>
|
||
<form id="loginForm">
|
||
<label>Benutzername</label>
|
||
<input name="username" required autocomplete="username" />
|
||
<label>Passwort</label>
|
||
<input name="password" type="password" required autocomplete="current-password" />
|
||
<div style="margin-top:1rem"><button class="primary" type="submit">Einloggen</button></div>
|
||
<div class="err" id="loginErr"></div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- MAIN APP -->
|
||
<div id="view-app" style="display:none">
|
||
<header>
|
||
<h1>Adminportal</h1>
|
||
<div>
|
||
<span id="whoami" class="small"></span>
|
||
<button id="logoutBtn">Logout</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="tabs">
|
||
<button data-tab="customers" class="active">Kunden</button>
|
||
<button data-tab="users" id="tabUsersBtn">Benutzer</button>
|
||
<button data-tab="settings" id="tabSettingsBtn">Einstellungen</button>
|
||
</div>
|
||
|
||
<!-- CUSTOMERS TAB -->
|
||
<section id="tab-customers">
|
||
<form id="createCustomerForm" class="inline" style="display:none">
|
||
<input name="name" placeholder="Kundenname" required />
|
||
<input name="password" placeholder="Passwort (optional)" />
|
||
<input name="expires_at" placeholder="Ablauf YYYY-MM-DD HH:MM" />
|
||
<button class="primary" type="submit">Kunden anlegen</button>
|
||
</form>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Name</th><th>Slug</th><th>Upload-Link</th><th>PW</th><th>Ablauf</th><th>Uploads</th><th>Aktionen</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="customerRows"></tbody>
|
||
</table>
|
||
<p class="small">WebDAV-Server: <code id="webdavUrl"></code> · Login mit eigenem Benutzer (Rollen-/ACL-abhängig).</p>
|
||
</section>
|
||
|
||
<!-- USERS TAB -->
|
||
<section id="tab-users" style="display:none">
|
||
<form id="createUserForm" class="inline">
|
||
<input name="username" placeholder="Benutzername" required pattern="[A-Za-z0-9._-]+" />
|
||
<input name="password" placeholder="Passwort (min. 6)" required minlength="6" />
|
||
<select name="role">
|
||
<option value="staff">Sachbearbeiter</option>
|
||
<option value="admin">Admin</option>
|
||
</select>
|
||
<button class="primary" type="submit">Benutzer anlegen</button>
|
||
</form>
|
||
<table>
|
||
<thead>
|
||
<tr><th>Benutzername</th><th>Rolle</th><th>Angelegt</th><th>Aktionen</th></tr>
|
||
</thead>
|
||
<tbody id="userRows"></tbody>
|
||
</table>
|
||
</section>
|
||
</div>
|
||
|
||
<!-- SETTINGS TAB -->
|
||
<section id="tab-settings" style="display:none">
|
||
<form id="settingsForm" style="max-width:600px">
|
||
<label style="display:block; margin-top:1rem">Öffentliche Basis-URL (für Kunden-Upload-Links)</label>
|
||
<input name="public_base_url" style="width:100%" placeholder="z. B. https://upload.example.com" />
|
||
<p class="small">Leer lassen, um aus jedem Request die aktuelle URL zu nutzen.</p>
|
||
|
||
<label style="display:block; margin-top:1rem">Cron-Intervall (Minuten)</label>
|
||
<input name="janitor_interval_minutes" type="number" min="1" style="width:8rem" />
|
||
<p class="small">Periodischer DB-Abgleich mit dem Dateisystem: entfernt verwaiste DB-Einträge (via WebDAV gelöscht) und erfasst neu per WebDAV hochgeladene Dateien.</p>
|
||
|
||
<div style="margin-top:1rem; display:flex; gap:.5rem">
|
||
<button class="primary" type="submit">Speichern</button>
|
||
<button type="button" id="runJanitor">Abgleich jetzt ausführen</button>
|
||
<span id="janitorMsg" class="small"></span>
|
||
</div>
|
||
</form>
|
||
</section>
|
||
|
||
<!-- ACCESS MODAL -->
|
||
<div class="modal" id="accessModal">
|
||
<div class="modal-inner">
|
||
<h3 id="accessTitle">Zugriff verwalten</h3>
|
||
<p class="small">Wähle Sachbearbeiter aus, die auf die Kundendateien per WebDAV zugreifen dürfen.</p>
|
||
<div id="accessList"></div>
|
||
<div style="margin-top:1rem; display:flex; gap:.5rem; justify-content:flex-end">
|
||
<button id="accessCancel">Abbrechen</button>
|
||
<button id="accessSave" class="primary">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, data });
|
||
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; }
|
||
|
||
async function bootstrap() {
|
||
const status = await api.get('/status');
|
||
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('createCustomerForm').style.display = 'none';
|
||
} else {
|
||
document.getElementById('createCustomerForm').style.display = '';
|
||
}
|
||
document.getElementById('webdavUrl').textContent = `webdav://${location.hostname}:1900/`;
|
||
show('view-app');
|
||
loadCustomers();
|
||
}
|
||
|
||
// --- Setup ---
|
||
document.getElementById('setupForm').addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const fd = new FormData(e.target);
|
||
try {
|
||
await api.send('POST', '/setup', Object.fromEntries(fd.entries()));
|
||
location.reload();
|
||
} catch (ex) { document.getElementById('setupErr').textContent = ex.message; }
|
||
});
|
||
|
||
// --- Login / Logout ---
|
||
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const fd = new FormData(e.target);
|
||
try {
|
||
await api.send('POST', '/login', Object.fromEntries(fd.entries()));
|
||
location.reload();
|
||
} catch (ex) { document.getElementById('loginErr').textContent = 'Login fehlgeschlagen'; }
|
||
});
|
||
document.getElementById('logoutBtn').addEventListener('click', async () => {
|
||
await api.send('POST', '/logout');
|
||
location.reload();
|
||
});
|
||
|
||
// --- Tabs ---
|
||
document.querySelectorAll('.tabs button').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
document.querySelectorAll('.tabs button').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');
|
||
rows.innerHTML = '';
|
||
for (const c of data) {
|
||
const tr = document.createElement('tr');
|
||
const link = c.upload_url || '–';
|
||
const adminOnly = me.role === 'admin';
|
||
tr.innerHTML = `
|
||
<td>${c.name}</td>
|
||
<td><code>${c.slug}</code></td>
|
||
<td>${adminOnly ? `<code>${link}</code> <div><button data-copy="${link}">Kopieren</button></div>` : '<span class="small">nur Admin</span>'}</td>
|
||
<td>${c.has_password ? '🔒' : '–'}</td>
|
||
<td>${c.expires_at ? new Date(c.expires_at).toLocaleString() : '–'}</td>
|
||
<td>${c.upload_count} <span class="small">(${fmtSize(c.total_size)})</span></td>
|
||
<td class="actions">
|
||
${adminOnly ? `
|
||
<button data-access="${c.id}" data-slug="${c.slug}" data-name="${c.name}">Zugriff</button>
|
||
<button data-edit="${c.id}">Bearbeiten</button>
|
||
<button data-regen="${c.id}">Neuer Link</button>
|
||
<button class="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;
|
||
if (t.dataset.copy) {
|
||
navigator.clipboard.writeText(t.dataset.copy);
|
||
const orig = t.textContent; t.textContent = 'Kopiert!';
|
||
setTimeout(() => t.textContent = orig, 1200);
|
||
}
|
||
if (t.dataset.del) {
|
||
if (!confirm('Kunde wirklich löschen? (Hochgeladene Dateien bleiben auf Disk.)')) return;
|
||
await api.send('DELETE', `/customers/${t.dataset.del}`);
|
||
loadCustomers();
|
||
}
|
||
if (t.dataset.regen) {
|
||
if (!confirm('Neuen Link erzeugen? Alter Link wird ungültig.')) return;
|
||
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);
|
||
const body = {
|
||
name: fd.get('name'),
|
||
password: fd.get('password') || undefined,
|
||
expires_at: parseDate(fd.get('expires_at')) || undefined,
|
||
};
|
||
try { await api.send('POST', '/customers', body); 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>${u.username}${u.id === me.id ? ' <span class="small">(du)</span>' : ''}</td>
|
||
<td><span class="pill ${u.role === 'admin' ? 'admin' : ''}">${u.role}</span></td>
|
||
<td class="small">${new Date(u.created_at).toLocaleDateString()}</td>
|
||
<td class="actions">
|
||
<button data-pw="${u.id}">Passwort</button>
|
||
<button data-role="${u.id}" data-current="${u.role}">Rolle</button>
|
||
<button class="danger" data-deluser="${u.id}" data-name="${u.username}">Löschen</button>
|
||
</td>
|
||
`;
|
||
rows.appendChild(tr);
|
||
}
|
||
}
|
||
|
||
document.getElementById('userRows').addEventListener('click', async (e) => {
|
||
const t = e.target;
|
||
if (t.dataset.pw) {
|
||
const pw = prompt('Neues Passwort (min. 6 Zeichen):', '');
|
||
if (!pw) return;
|
||
try { await api.send('PATCH', `/users/${t.dataset.pw}`, { password: pw }); loadUsers(); }
|
||
catch (ex) { alert('Fehler: ' + ex.message); }
|
||
}
|
||
if (t.dataset.role) {
|
||
const newRole = t.dataset.current === 'admin' ? 'staff' : 'admin';
|
||
if (!confirm(`Rolle auf "${newRole}" ändern?`)) return;
|
||
try { await api.send('PATCH', `/users/${t.dataset.role}`, { role: newRole }); loadUsers(); }
|
||
catch (ex) { alert('Fehler: ' + ex.message); }
|
||
}
|
||
if (t.dataset.deluser) {
|
||
if (!confirm(`Benutzer "${t.dataset.name}" löschen?`)) return;
|
||
try { await api.send('DELETE', `/users/${t.dataset.deluser}`); loadUsers(); }
|
||
catch (ex) { alert('Fehler: ' + ex.message); }
|
||
}
|
||
});
|
||
|
||
document.getElementById('createUserForm').addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const fd = new FormData(e.target);
|
||
try {
|
||
await api.send('POST', '/users', Object.fromEntries(fd.entries()));
|
||
e.target.reset();
|
||
loadUsers();
|
||
} 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>${s.username}</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;
|
||
const uid = picked.name.replace('acc-', '');
|
||
entries.push({ user_id: parseInt(uid, 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); }
|
||
};
|
||
|
||
// --- Settings ---
|
||
async function loadSettings() {
|
||
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;
|
||
}
|
||
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),
|
||
});
|
||
document.getElementById('janitorMsg').textContent = 'Gespeichert.';
|
||
setTimeout(() => document.getElementById('janitorMsg').textContent = '', 2000);
|
||
} catch (ex) { alert('Fehler: ' + ex.message); }
|
||
});
|
||
document.getElementById('runJanitor').addEventListener('click', async () => {
|
||
const msg = document.getElementById('janitorMsg');
|
||
msg.textContent = 'läuft …';
|
||
try {
|
||
const r = await api.send('POST', '/janitor/run');
|
||
msg.textContent = `fertig: +${r.added} hinzugefügt, -${r.removed} entfernt`;
|
||
} catch (ex) { msg.textContent = 'Fehler: ' + ex.message; }
|
||
});
|
||
|
||
bootstrap().catch(e => {
|
||
document.getElementById('loginErr').textContent = 'Fehler: ' + e.message;
|
||
show('view-login');
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|