Harden security, polish admin UI and document Windows WebDAV
- helmet, express-rate-limit (login/setup/customer-auth/me-password) - Constant-time login (bcrypt always runs against a dummy hash on miss) - Cookie secure flag follows req.protocol; trust proxy is env-gated to prevent X-Forwarded-For spoofing on direct exposure - Drop SVG from accepted logo types (same-origin XSS) and resolve the served logo path against LOGO_DIR as defense in depth - Self-service /me/password endpoint plus header button; bumps minimum password length to 8 across backend, prompts and edit modal - Multer 1.x → 2.x for current security backports - Customer edit modal replaces stacked prompts; user role is now an inline dropdown with a confirm-and-revert flow - Windows .reg helper plus README section for Basic-Auth-over-HTTP and the http:// vs \\HOST@PORT\DavWWWRoot\ mapping syntax Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+114
-21
@@ -160,6 +160,7 @@
|
||||
.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; }
|
||||
select.role-select { padding: .25rem .5rem; font-size: .85rem; min-width: 9rem; }
|
||||
|
||||
.small { font-size: .8rem; color: var(--text-muted); }
|
||||
.muted { color: var(--text-muted); }
|
||||
@@ -220,8 +221,8 @@
|
||||
<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 class="field"><label>Passwort (min. 8 Zeichen)</label>
|
||||
<input name="password" type="password" required minlength="8" 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>
|
||||
@@ -253,6 +254,7 @@
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<span id="whoami"></span>
|
||||
<button id="changePwBtn" class="btn sm">Passwort ändern</button>
|
||||
<button id="logoutBtn" class="btn sm">Logout</button>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -299,7 +301,7 @@
|
||||
<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>Passwort</label><input name="password" required minlength="8" 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">
|
||||
@@ -332,10 +334,10 @@
|
||||
<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" />
|
||||
<input type="file" id="logoFile" accept="image/png,image/jpeg,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 class="small" style="margin-top:.5rem">Max 2 MB. PNG, JPG, WEBP, GIF.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field"><label>Breite (px) — 0 = automatisch</label>
|
||||
@@ -413,6 +415,36 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- EDIT CUSTOMER MODAL -->
|
||||
<div class="modal" id="editCustomerModal">
|
||||
<div class="modal-inner">
|
||||
<h3 id="editCustomerTitle">Kunde bearbeiten</h3>
|
||||
<form id="editCustomerForm">
|
||||
<div class="field"><label>E-Mail</label>
|
||||
<input name="email" type="email" placeholder="leer = entfernen" /></div>
|
||||
|
||||
<div class="field"><label>Passwort (leer = unverändert, min. 8 Zeichen)</label>
|
||||
<input name="password" type="text" minlength="8" autocomplete="new-password" /></div>
|
||||
<label style="display:flex; gap:.4rem; align-items:center; font-weight:normal; color:var(--text); margin-top:.4rem">
|
||||
<input type="checkbox" name="clear_password" style="width:auto" />
|
||||
Vorhandenes Passwort entfernen
|
||||
</label>
|
||||
|
||||
<div class="field" style="margin-top:1rem"><label>Ablaufdatum</label>
|
||||
<input name="expires_at" type="datetime-local" /></div>
|
||||
<label style="display:flex; gap:.4rem; align-items:center; font-weight:normal; color:var(--text); margin-top:.4rem">
|
||||
<input type="checkbox" name="clear_expires" style="width:auto" />
|
||||
Ablauf entfernen (unbegrenzt gültig)
|
||||
</label>
|
||||
|
||||
<div style="margin-top:1.25rem; display:flex; gap:.5rem; justify-content:flex-end">
|
||||
<button type="button" class="btn" id="editCustomerCancel">Abbrechen</button>
|
||||
<button type="submit" class="btn primary">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ACCESS MODAL -->
|
||||
<div class="modal" id="accessModal">
|
||||
<div class="modal-inner">
|
||||
@@ -527,6 +559,19 @@ document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||
catch { document.getElementById('loginErr').textContent = 'Login fehlgeschlagen'; }
|
||||
});
|
||||
document.getElementById('logoutBtn').onclick = async () => { await api.send('POST', '/logout'); location.reload(); };
|
||||
document.getElementById('changePwBtn').onclick = async () => {
|
||||
const oldPw = prompt('Aktuelles Passwort:');
|
||||
if (!oldPw) return;
|
||||
const newPw = prompt('Neues Passwort (min. 8 Zeichen):');
|
||||
if (!newPw) return;
|
||||
if (newPw.length < 8) return alert('Passwort zu kurz.');
|
||||
const repeat = prompt('Neues Passwort wiederholen:');
|
||||
if (newPw !== repeat) return alert('Eingaben stimmen nicht überein.');
|
||||
try {
|
||||
await api.send('POST', '/me/password', { old_password: oldPw, new_password: newPw });
|
||||
alert('Passwort geändert. Andere Sitzungen wurden abgemeldet.');
|
||||
} catch (ex) { alert('Fehler: ' + ex.message); }
|
||||
};
|
||||
|
||||
// Tabs
|
||||
document.querySelectorAll('.tab').forEach(btn => {
|
||||
@@ -543,8 +588,10 @@ document.querySelectorAll('.tab').forEach(btn => {
|
||||
});
|
||||
|
||||
// Customers
|
||||
let customersCache = [];
|
||||
async function loadCustomers() {
|
||||
const data = await api.get('/customers');
|
||||
customersCache = data;
|
||||
const rows = document.getElementById('customerRows');
|
||||
const adminOnly = me.role === 'admin';
|
||||
rows.innerHTML = '';
|
||||
@@ -610,14 +657,8 @@ document.getElementById('customerRows').addEventListener('click', async (e) => {
|
||||
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();
|
||||
const c = customersCache.find(x => String(x.id) === t.dataset.edit);
|
||||
if (c) openEditCustomer(c);
|
||||
}
|
||||
if (t.dataset.access) openAccessModal(t.dataset.access, t.dataset.name);
|
||||
});
|
||||
@@ -642,15 +683,19 @@ async function loadUsers() {
|
||||
rows.innerHTML = '';
|
||||
for (const u of data) {
|
||||
const tr = document.createElement('tr');
|
||||
const roleSel = `
|
||||
<select data-rolesel="${u.id}" data-prev="${u.role}" class="role-select">
|
||||
<option value="staff" ${u.role === 'staff' ? 'selected' : ''}>Sachbearbeiter</option>
|
||||
<option value="admin" ${u.role === 'admin' ? 'selected' : ''}>Admin</option>
|
||||
</select>`;
|
||||
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>${roleSel}</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>
|
||||
</td>`;
|
||||
rows.appendChild(tr);
|
||||
@@ -665,19 +710,32 @@ document.getElementById('userRows').addEventListener('click', async (e) => {
|
||||
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;
|
||||
const pw = prompt('Neues Passwort (min. 8 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('userRows').addEventListener('change', async (e) => {
|
||||
const sel = e.target;
|
||||
if (!sel.dataset || !sel.dataset.rolesel) return;
|
||||
const newRole = sel.value;
|
||||
const prev = sel.dataset.prev;
|
||||
if (newRole === prev) return;
|
||||
if (!confirm(`Rolle wirklich auf "${newRole}" ändern?`)) {
|
||||
sel.value = prev;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.send('PATCH', `/users/${sel.dataset.rolesel}`, { role: newRole });
|
||||
sel.dataset.prev = newRole;
|
||||
} catch (ex) {
|
||||
alert('Fehler: ' + ex.message);
|
||||
sel.value = prev;
|
||||
}
|
||||
});
|
||||
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(); }
|
||||
@@ -829,6 +887,41 @@ document.getElementById('logoDeleteBtn').onclick = async () => {
|
||||
updateLogoPreview('');
|
||||
};
|
||||
|
||||
// Edit Customer modal
|
||||
let editingCustomerId = null;
|
||||
function dtLocal(ms) {
|
||||
const d = new Date(ms);
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
function openEditCustomer(c) {
|
||||
editingCustomerId = c.id;
|
||||
document.getElementById('editCustomerTitle').textContent = `Bearbeiten – ${c.name}`;
|
||||
const f = document.getElementById('editCustomerForm');
|
||||
f.email.value = c.email || '';
|
||||
f.password.value = '';
|
||||
f.clear_password.checked = false;
|
||||
f.clear_expires.checked = false;
|
||||
f.expires_at.value = c.expires_at ? dtLocal(c.expires_at) : '';
|
||||
document.getElementById('editCustomerModal').classList.add('open');
|
||||
}
|
||||
document.getElementById('editCustomerCancel').onclick = () =>
|
||||
document.getElementById('editCustomerModal').classList.remove('open');
|
||||
document.getElementById('editCustomerForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target);
|
||||
const body = { email: (fd.get('email') || '').trim() };
|
||||
if (fd.get('clear_password')) body.clear_password = true;
|
||||
else if (fd.get('password')) body.password = fd.get('password');
|
||||
if (fd.get('clear_expires')) body.expires_at = null;
|
||||
else if (fd.get('expires_at')) body.expires_at = new Date(fd.get('expires_at')).getTime();
|
||||
try {
|
||||
await api.send('PATCH', `/customers/${editingCustomerId}`, body);
|
||||
document.getElementById('editCustomerModal').classList.remove('open');
|
||||
loadCustomers();
|
||||
} catch (ex) { alert('Fehler: ' + ex.message); }
|
||||
});
|
||||
|
||||
// Access modal
|
||||
let accessCustomerId = null;
|
||||
async function openAccessModal(id, name) {
|
||||
|
||||
Reference in New Issue
Block a user