Add email notifications, SMTP settings and customer archive lifecycle

- Optional email field on users and customers
- SMTP config in admin settings with test-mail button and an opt-in
  "notify admins on upload" toggle
- Debounced upload notifier sends one summary email per customer session
  to the customer, assigned staff and (optionally) admins
- Two-step customer lifecycle: "Deaktivieren" archives the link and
  keeps data, "Dateien löschen" purges files and the DB entry after a
  name-typed confirmation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker
2026-04-16 12:00:06 +02:00
parent 4567e93aa2
commit 3f86fca578
6 changed files with 389 additions and 44 deletions
+129 -16
View File
@@ -157,6 +157,9 @@
.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); }
@@ -267,6 +270,7 @@
<h3 class="card-title">Neuen Kunden anlegen</h3>
<form id="createCustomerForm" class="form-row">
<div><label>Name</label><input name="name" required placeholder="Firma ABC" /></div>
<div><label>E-Mail (optional)</label><input name="email" type="email" placeholder="kunde@firma.de" /></div>
<div><label>Passwort (optional)</label><input name="password" placeholder="Leer = offen" /></div>
<div><label>Ablaufdatum (optional)</label><input name="expires_at" placeholder="YYYY-MM-DD HH:MM" /></div>
<div><label>&nbsp;</label><button class="btn primary" type="submit">Anlegen</button></div>
@@ -278,7 +282,7 @@
<table>
<thead>
<tr>
<th>Kunde</th><th>Slug</th><th>Upload-Link</th><th>Schutz</th>
<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>
@@ -296,6 +300,7 @@
<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>
@@ -309,7 +314,7 @@
<div class="table-wrap">
<table>
<thead>
<tr><th>Benutzername</th><th>Rolle</th><th>Angelegt</th><th></th></tr>
<tr><th>Benutzername</th><th>E-Mail</th><th>Rolle</th><th>Angelegt</th><th></th></tr>
</thead>
<tbody id="userRows"></tbody>
</table>
@@ -355,6 +360,37 @@
</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">
@@ -513,27 +549,41 @@ async function loadCustomers() {
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>`;
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');
tr.innerHTML = `
<td><strong>${esc(c.name)}</strong></td>
<td><code>${esc(c.slug)}</code></td>
<td>${adminOnly
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>'}</td>
: '<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">${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>`;
<td class="cell-actions">${actions}</td>`;
rows.appendChild(tr);
}
}
@@ -543,16 +593,28 @@ document.getElementById('customerRows').addEventListener('click', async (e) => {
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.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();
@@ -565,6 +627,7 @@ document.getElementById('createCustomerForm').addEventListener('submit', async (
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,
});
@@ -581,9 +644,11 @@ async function loadUsers() {
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>
@@ -594,6 +659,11 @@ async function loadUsers() {
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();
@@ -625,6 +695,18 @@ async function loadSettings() {
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;
@@ -669,6 +751,37 @@ document.getElementById('settingsForm').addEventListener('submit', async (e) =>
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}`; }