Fix broken action buttons and add delete confirmation modal

- shares.js, users.js: JSON.stringify() inside onclick="..." attributes
  produced unescaped " chars that broke HTML attribute parsing — all
  dynamic onclick JSON now passed through esc() so the browser decodes
  " back to " before executing the handler
- Replace browser confirm() dialogs with a styled Bootstrap modal
  (#deleteModal) for share and user deletion — reusable across all tabs
- users.js: rename shadow variable 'confirm' → 'pwConfirm' to avoid
  shadowing the now-removed confirm() call
- main.js: auto-refresh now reloads shares and users every 30 s,
  not just the dashboard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Sonnet 4.6 committed 2026-06-19 22:33:37 +02:00
1 parent 67dacd4044
commit 74737568b0
4 files changed
+72 -25

No files matched your search

+1 -1
View File
@@ -1,4 +1,4 @@
loadDashboard(); loadDashboard();
loadShares(); loadShares();
loadUsers(); loadUsers();
setInterval(loadDashboard, 30_000); setInterval(() => { loadDashboard(); loadShares(); loadUsers(); }, 30_000);
+30 -15
View File
@@ -9,21 +9,26 @@ async function loadShares() {
} else { } else {
sb.innerHTML = shares.smb.map(s => { sb.innerHTML = shares.smb.map(s => {
const enabled = s.enabled !== false; const enabled = s.enabled !== false;
const statusDot = `<span class="dot ${enabled ? 'dot-green' : 'dot-red'}" style="margin-right:0"></span>`; const dot = `<span class="dot ${enabled ? 'dot-green' : 'dot-red'}" style="margin-right:0"></span>`;
// esc() HTML-encodes JSON so " inside onclick="..." doesn't break the attribute
const shareJson = esc(JSON.stringify(s));
const nameJson = esc(JSON.stringify(s.name));
const pathJson = esc(JSON.stringify(s.path));
const usersJson = esc(JSON.stringify(s.valid_users));
return `<tr style="${enabled ? '' : 'opacity:.55'}"> return `<tr style="${enabled ? '' : 'opacity:.55'}">
<td>${esc(s.name)}</td> <td>${esc(s.name)}</td>
<td><code>${esc(s.path)}</code></td> <td><code>${esc(s.path)}</code></td>
<td style="color:var(--muted); font-size:.87em">${esc(s.valid_users || '—')}</td> <td style="color:var(--muted); font-size:.87em">${esc(s.valid_users || '—')}</td>
<td>${statusDot}</td> <td>${dot}</td>
<td> <td>
<button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)" <button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)"
onclick="openEditModal('smb',${JSON.stringify(s)})">Edit</button> onclick="openEditModal('smb',${shareJson})">Edit</button>
<button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)" <button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)"
onclick="openQuotaModal('smb',${JSON.stringify(s.name)},${JSON.stringify(s.path)},${JSON.stringify(s.valid_users)})">Quota</button> onclick="openQuotaModal('smb',${nameJson},${pathJson},${usersJson})">Quota</button>
<button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)" <button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)"
onclick="toggleShare('smb',${JSON.stringify(s.name)},${!enabled})">${enabled ? 'Disable' : 'Enable'}</button> onclick="toggleShare('smb',${nameJson},${!enabled})">${enabled ? 'Disable' : 'Enable'}</button>
<button class="btn btn-sm btn-danger" <button class="btn btn-sm btn-danger"
onclick="deleteShare('smb',${JSON.stringify(s.name)})">Delete</button> onclick="deleteShare('smb',${nameJson})">Delete</button>
</td> </td>
</tr>`; </tr>`;
}).join(''); }).join('');
@@ -35,20 +40,22 @@ async function loadShares() {
} else { } else {
nb.innerHTML = shares.nfs.map(s => { nb.innerHTML = shares.nfs.map(s => {
const enabled = s.enabled !== false; const enabled = s.enabled !== false;
const statusDot = `<span class="dot ${enabled ? 'dot-green' : 'dot-red'}" style="margin-right:0"></span>`; const dot = `<span class="dot ${enabled ? 'dot-green' : 'dot-red'}" style="margin-right:0"></span>`;
const shareJson = esc(JSON.stringify(s));
const pathJson = esc(JSON.stringify(s.path));
return `<tr style="${enabled ? '' : 'opacity:.55'}"> return `<tr style="${enabled ? '' : 'opacity:.55'}">
<td><code>${esc(s.path)}</code></td> <td><code>${esc(s.path)}</code></td>
<td style="color:var(--muted); font-size:.87em">${esc(s.clients)}</td> <td style="color:var(--muted); font-size:.87em">${esc(s.clients)}</td>
<td>${statusDot}</td> <td>${dot}</td>
<td> <td>
<button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)" <button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)"
onclick="openEditModal('nfs',${JSON.stringify(s)})">Edit</button> onclick="openEditModal('nfs',${shareJson})">Edit</button>
<button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)" <button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)"
onclick="openQuotaModal('nfs',null,${JSON.stringify(s.path)},null)">Quota</button> onclick="openQuotaModal('nfs',null,${pathJson},null)">Quota</button>
<button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)" <button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)"
onclick="toggleShare('nfs',${JSON.stringify(s.path)},${!enabled})">${enabled ? 'Disable' : 'Enable'}</button> onclick="toggleShare('nfs',${pathJson},${!enabled})">${enabled ? 'Disable' : 'Enable'}</button>
<button class="btn btn-sm btn-danger" <button class="btn btn-sm btn-danger"
onclick="deleteShare('nfs',${JSON.stringify(s.path)})">Delete</button> onclick="deleteShare('nfs',${pathJson})">Delete</button>
</td> </td>
</tr>`; </tr>`;
}).join(''); }).join('');
@@ -173,10 +180,16 @@ async function submitEdit() {
// ===== DELETE ===== // ===== DELETE =====
async function deleteShare(type, identifier) { function deleteShare(type, identifier) {
const label = type === 'smb' ? `SMB share "${identifier}"` : `NFS export "${identifier}"`; const label = type === 'smb' ? `SMB share "${identifier}"` : `NFS export "${identifier}"`;
if (!confirm(`Delete ${label}? This cannot be undone.`)) return; document.getElementById('deleteModalMsg').textContent =
`Are you sure you want to delete ${label}? This cannot be undone.`;
const modal = new bootstrap.Modal(document.getElementById('deleteModal'));
const btn = document.getElementById('deleteModalConfirm');
const handler = async () => {
btn.removeEventListener('click', handler);
modal.hide();
let res; let res;
if (type === 'smb') { if (type === 'smb') {
res = await apiFetch(`/api/share/smb/${encodeURIComponent(identifier)}`, { method: 'DELETE' }); res = await apiFetch(`/api/share/smb/${encodeURIComponent(identifier)}`, { method: 'DELETE' });
@@ -186,12 +199,14 @@ async function deleteShare(type, identifier) {
body: JSON.stringify({path: identifier}), body: JSON.stringify({path: identifier}),
}); });
} }
if (!res) return; if (!res) return;
const data = await res.json(); const data = await res.json();
if (!res.ok) return showToast('Error: ' + (data.error || res.status), 'danger'); if (!res.ok) return showToast('Error: ' + (data.error || res.status), 'danger');
showToast(`${label} deleted.`); showToast(`${label} deleted.`);
loadShares(); loadDashboard(); loadShares(); loadDashboard();
};
btn.addEventListener('click', handler);
modal.show();
} }
// ===== TOGGLE ===== // ===== TOGGLE =====
+22 -9
View File
@@ -7,8 +7,10 @@ async function loadUsers() {
tb.innerHTML = `<tr><td colspan="4" style="color:var(--muted)">No users found.</td></tr>`; tb.innerHTML = `<tr><td colspan="4" style="color:var(--muted)">No users found.</td></tr>`;
return; return;
} }
tb.innerHTML = users.map(u => ` tb.innerHTML = users.map(u => {
<tr> // esc() HTML-encodes JSON so " inside onclick="..." doesn't break the attribute
const nameJson = esc(JSON.stringify(u.name));
return `<tr>
<td>${esc(u.name)}</td> <td>${esc(u.name)}</td>
<td style="color:var(--muted)">${u.uid}</td> <td style="color:var(--muted)">${u.uid}</td>
<td> <td>
@@ -19,11 +21,12 @@ async function loadUsers() {
</td> </td>
<td> <td>
<button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)" <button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)"
onclick="openPwModal(${JSON.stringify(u.name)})">Password</button> onclick="openPwModal(${nameJson})">Password</button>
<button class="btn btn-sm btn-danger" <button class="btn btn-sm btn-danger"
onclick="deleteUser(${JSON.stringify(u.name)})">Delete</button> onclick="confirmDeleteUser(${nameJson})">Delete</button>
</td> </td>
</tr>`).join(''); </tr>`;
}).join('');
} }
async function createUser() { async function createUser() {
@@ -44,14 +47,24 @@ async function createUser() {
loadUsers(); loadUsers();
} }
async function deleteUser(name) { function confirmDeleteUser(name) {
if (!confirm(`Delete user "${name}"? This cannot be undone.`)) return; document.getElementById('deleteModalMsg').textContent =
`Are you sure you want to delete user "${name}"? This removes both the system account and SMB access.`;
const modal = new bootstrap.Modal(document.getElementById('deleteModal'));
const btn = document.getElementById('deleteModalConfirm');
const handler = async () => {
btn.removeEventListener('click', handler);
modal.hide();
const res = await apiFetch(`/api/users/${encodeURIComponent(name)}`, { method: 'DELETE' }); const res = await apiFetch(`/api/users/${encodeURIComponent(name)}`, { method: 'DELETE' });
if (!res) return; if (!res) return;
const data = await res.json(); const data = await res.json();
if (!res.ok) return showToast('Error: ' + (data.error || res.status), 'danger'); if (!res.ok) return showToast('Error: ' + (data.error || res.status), 'danger');
showToast(`User "${name}" deleted.`); showToast(`User "${name}" deleted.`);
loadUsers(); loadUsers();
};
btn.addEventListener('click', handler);
modal.show();
} }
function openPwModal(name) { function openPwModal(name) {
@@ -64,9 +77,9 @@ function openPwModal(name) {
async function submitPassword() { async function submitPassword() {
const name = document.getElementById('pwUsername').value; const name = document.getElementById('pwUsername').value;
const pw = document.getElementById('pwNew').value; const pw = document.getElementById('pwNew').value;
const confirm = document.getElementById('pwConfirm').value; const pwConfirm = document.getElementById('pwConfirm').value;
if (pw.length < 8) return showToast('Password must be at least 8 characters.', 'danger'); if (pw.length < 8) return showToast('Password must be at least 8 characters.', 'danger');
if (pw !== confirm) return showToast('Passwords do not match.', 'danger'); if (pw !== pwConfirm) return showToast('Passwords do not match.', 'danger');
const res = await apiFetch(`/api/users/${encodeURIComponent(name)}/password`, { const res = await apiFetch(`/api/users/${encodeURIComponent(name)}/password`, {
method: 'POST', headers: {'Content-Type': 'application/json'}, method: 'POST', headers: {'Content-Type': 'application/json'},
+19
View File
@@ -65,6 +65,25 @@
</div> </div>
</div> </div>
<!-- Delete confirm modal -->
<div class="modal fade" id="deleteModal" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title" style="color:var(--red)">Delete</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body pt-2">
<p id="deleteModalMsg" class="mb-0" style="color:var(--muted)"></p>
</div>
<div class="modal-footer border-0 pt-0">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger btn-sm" id="deleteModalConfirm">Delete</button>
</div>
</div>
</div>
</div>
<!-- Header --> <!-- Header -->
<div class="topbar d-flex justify-content-between align-items-center"> <div class="topbar d-flex justify-content-between align-items-center">
<span class="fw-semibold fs-5">MicroNAS</span> <span class="fw-semibold fs-5">MicroNAS</span>