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

453 lines
19 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 { 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>