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:
+129
-16
@@ -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> </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}`; }
|
||||
|
||||
Reference in New Issue
Block a user