Skip to content

Commit e1a9c48

Browse files
committed
feat(ui): GitHub-style type-to-confirm on every delete (v2.1.21)
Every destructive action now requires typing a confirmation phrase before the red button activates — a misclick can't delete the wrong thing. - Single named resource (container/image/volume/network/server/key/registry/ file/compose project) → retype that resource's own name/id. - Bulk / prune / clear-all (no single name) → type the word 'delete'. New shared showDeleteConfirm() helper in api.js; swept all 14 page files plus edge-notifier-panel. Also gated 3 notification-settings clears that previously deleted saved config with no confirmation at all, and replaced the notifier agent's bare browser confirm() with the typed gate. Server-context banner still shows where the delete lands. Non-destructive actions (start/stop/restart, grant, update, save, hide) intentionally stay one-click. Verified e2e in a container: keystone gate (disabled until exact match) and a real Delete Server (keyed on the id) both pass.
1 parent 4e6bbec commit e1a9c48

19 files changed

Lines changed: 147 additions & 73 deletions

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@
22

33
---
44

5+
## [2.1.21] - 2026-06-25
6+
7+
### Added — GitHub-style "type to confirm" on every delete
8+
- Every destructive action now requires you to **type a confirmation phrase** before the red button activates — the same safety pattern GitHub uses for deleting a repo. A misclick can no longer delete the wrong thing: the **Delete** button stays disabled until what you type matches.
9+
- **For a single named resource** (a container, image, volume, network, server, SSH key, registry, file, or compose project) you retype that resource's **own name/id** — so you literally confirm *which* one you're deleting. **For bulk / prune / clear-all** actions (no single name) you type the word **`delete`**.
10+
- New shared `showDeleteConfirm()` helper in the UI; all confirmation dialogs were swept to use it. Coverage: Remove Container (single + bulk), Remove/Force-Remove Image, Remove Volume(s), Remove Network(s), Delete Server (Servers list + Settings), Delete SSH key, Delete registry, Delete file(s), Compose **Down** and **Delete project** (the project-delete modal keeps its volumes/files options and the server banner, now gated on the project name), Build-record deletes / Clear-all / Prune build cache, Cleanup **Prune** and **Full System Prune**, Clear audit log, and the notifier-agent **Remove** (was a bare browser `confirm()` — now the typed gate, keyed on the server id). The notification-settings **Clear SMTP / Clear Telegram / Clear notification-log** buttons — which previously deleted saved config with *no* confirmation at all — are now gated too.
11+
- The existing **server-context banner** (🖥 Local / 🔐 remote host) still shows at the top of each dialog, so you also see *where* the delete will land. Non-destructive actions (Start/Stop/Restart, Grant Docker, Update, Save config, Hide-from-history) are intentionally left as one-click confirms.
12+
- Built and audited by a parallel sweep across all 14 page files plus a completeness pass; verified e2e in a container — the keystone gate (disabled → stays disabled on a wrong phrase → enables on exact match → fires → closes) and a real wired delete (Delete Server, keyed on the server id, actually removes the server) both pass.
13+
14+
---
15+
516
## [2.1.20] - 2026-06-25
617

718
### Added — Per-server access password (a 2nd gate to switch servers)

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
<p align="center">
2-
<img src="https://img.shields.io/badge/DockGate-v2.1.20-00d4aa?style=for-the-badge&logo=docker&logoColor=white" alt="DockGate">
2+
<img src="https://img.shields.io/badge/DockGate-v2.1.21-00d4aa?style=for-the-badge&logo=docker&logoColor=white" alt="DockGate">
33
<img src="https://img.shields.io/badge/Node.js-18-339933?style=for-the-badge&logo=nodedotjs&logoColor=white" alt="Node.js">
44
<img src="https://img.shields.io/badge/License-MIT-blue?style=for-the-badge" alt="License">
5-
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/Changelog-v2.1.20-orange?style=for-the-badge" alt="Changelog"></a>
5+
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/Changelog-v2.1.21-orange?style=for-the-badge" alt="Changelog"></a>
66
<img src="https://img.shields.io/badge/CPU-≤0.5_core-brightgreen?style=for-the-badge" alt="CPU">
77
<img src="https://img.shields.io/badge/RAM-<256MB-success?style=for-the-badge" alt="RAM">
88
</p>

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "dockgate",
3-
"version": "2.1.20",
3+
"version": "2.1.21",
44
"description": "DockGate — A lightweight, browser-based Docker control panel",
55
"main": "server/index.js",
66
"author": "Ali Zeynalli",

public/js/api.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,43 @@ function showConfirm(title, message, onConfirm, danger = false) {
186186
]);
187187
}
188188

189+
// GitHub-style delete confirmation: the destructive button stays DISABLED until the user types the exact
190+
// confirmation phrase. A misclick can't delete the wrong thing — you must read and retype what's at stake.
191+
// opts.message — HTML describing what will be deleted (shown under the server-context banner)
192+
// opts.phrase — the exact text the user must type. For a single named resource pass its name/id
193+
// (true GitHub feel); for bulk/prune/clear (no single name) pass the literal 'delete'.
194+
// opts.confirmLabel — destructive button text (default 'Delete')
195+
// opts.onConfirm — runs once the typed phrase matches and the button (or Enter) is pressed
196+
function showDeleteConfirm(title, opts = {}) {
197+
const { message = '', phrase = 'delete', confirmLabel = 'Delete', onConfirm = () => {} } = opts;
198+
const phraseStr = String(phrase).trim() || 'delete';
199+
const m = showModal(title, `
200+
${serverContextBanner()}
201+
<p style="color: var(--text-secondary)">${message}</p>
202+
<label class="text-xs text-muted" style="display:block;margin-top:4px">Type <code style="background:var(--bg-primary);padding:1px 6px;border-radius:4px;border:1px solid var(--border);font-weight:600">${escapeHtml(phraseStr)}</code> to confirm:</label>
203+
<input class="input" id="dg-del-confirm" type="text" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" placeholder="${escapeHtml(phraseStr)}" style="width:100%;margin-top:6px" />
204+
`, [{ label: 'Cancel', className: 'btn btn-secondary' }]);
205+
// Scope queries to THIS overlay — modals can stack and ids repeat.
206+
const scope = m.overlay;
207+
const input = scope.querySelector('#dg-del-confirm');
208+
const footer = scope.querySelector('#modal-footer');
209+
const delBtn = document.createElement('button');
210+
delBtn.className = 'btn btn-danger';
211+
delBtn.textContent = confirmLabel;
212+
delBtn.disabled = true;
213+
delBtn.style.opacity = '0.5';
214+
delBtn.style.cursor = 'not-allowed';
215+
footer.appendChild(delBtn);
216+
const matches = () => input.value.trim() === phraseStr;
217+
const sync = () => { const ok = matches(); delBtn.disabled = !ok; delBtn.style.opacity = ok ? '1' : '0.5'; delBtn.style.cursor = ok ? 'pointer' : 'not-allowed'; };
218+
const fire = () => { if (!matches()) return; m.close(); try { const r = onConfirm(); if (r && typeof r.catch === 'function') r.catch(e => showToast(e.message || String(e), 'error')); } catch (e) { showToast(e.message || String(e), 'error'); } };
219+
input.addEventListener('input', sync);
220+
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); fire(); } });
221+
delBtn.addEventListener('click', fire);
222+
setTimeout(() => input.focus(), 50);
223+
return m;
224+
}
225+
189226
// Utility functions
190227
function formatBytes(bytes, decimals = 1) {
191228
if (bytes === 0) return '0 B';

public/js/edge-notifier-panel.js

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -231,9 +231,15 @@
231231
const { jobId } = await API.post('/agent/' + act, { serverId: sid });
232232
openJobModal(jobId, load);
233233
} else if (act === 'remove') {
234-
if (!confirm(`Remove the notifier agent from ${sid}? DockGate's central monitor will resume watching it.`)) return;
235-
await API.post('/agent/remove', { serverId: sid });
236-
showToast('Agent removed'); load();
234+
showDeleteConfirm('Remove notifier agent', {
235+
message: `Remove the notifier agent from <strong>${escapeHtml(sid)}</strong>? DockGate's central monitor will resume watching it.`,
236+
phrase: sid, confirmLabel: 'Remove',
237+
onConfirm: async () => {
238+
try { await API.post('/agent/remove', { serverId: sid }); showToast('Agent removed'); load(); }
239+
catch (e) { showToast(e.message, 'error'); }
240+
},
241+
});
242+
return;
237243
} else if (act === 'start' || act === 'stop') {
238244
await API.post('/agent/power', { serverId: sid, action: act });
239245
showToast('Agent ' + (act === 'stop' ? 'stopped' : 'started')); load();

public/js/pages/audit.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,13 +135,13 @@ Router.register('audit', async (content) => {
135135
});
136136

137137
document.getElementById('audit-clear')?.addEventListener('click', () => {
138-
showConfirm('Clear audit log', 'All audit records will be deleted. Continue?', async () => {
138+
showDeleteConfirm('Clear audit log', { message: 'All audit records will be deleted. Continue?', phrase: 'delete', onConfirm: async () => {
139139
try {
140140
await API.del('/meta/activity');
141141
showToast('Audit log cleared');
142142
render();
143143
} catch (err) { showToast(err.message, 'error'); }
144-
}, true);
144+
} });
145145
});
146146

147147
await load();

public/js/pages/builds.js

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -195,13 +195,13 @@ Router.register('builds', async (content) => {
195195
// Delete button
196196
el.querySelector('.hide-docker-build')?.addEventListener('click', (e) => {
197197
e.stopPropagation();
198-
showConfirm('Remove from History', `Remove "${img.tag}" from build history? (Image will not be deleted)`, async () => {
198+
showDeleteConfirm('Remove from History', { message: `Remove "${img.tag}" from build history? (Image will not be deleted)`, phrase: 'delete', onConfirm: async () => {
199199
try {
200200
await API.post('/builds/docker-history/hide', { imageId: img.imageId });
201201
showToast('Removed from history', 'success');
202202
render();
203203
} catch (err) { showToast(err.message, 'error'); }
204-
}, true);
204+
} });
205205
});
206206
});
207207

@@ -257,32 +257,32 @@ Router.register('builds', async (content) => {
257257

258258
// Bulk delete panel builds
259259
document.getElementById('bulk-delete-panel')?.addEventListener('click', () => {
260-
showConfirm('Delete Selected', `Delete ${selectedPanelIds.size} build record(s)?`, async () => {
260+
showDeleteConfirm('Delete Selected', { message: `Delete ${selectedPanelIds.size} build record(s)?`, phrase: 'delete', onConfirm: async () => {
261261
await bulkRun([...selectedPanelIds], (id) => API.del(`/builds/detail/${id}`), 'Deleted');
262262
selectedPanelIds.clear();
263263
render();
264-
}, true);
264+
} });
265265
});
266266
document.getElementById('bulk-clear-panel')?.addEventListener('click', () => { selectedPanelIds.clear(); render(); });
267267

268268
listEl.querySelectorAll('.delete-build').forEach(btn => {
269269
btn.addEventListener('click', (e) => {
270270
e.stopPropagation();
271-
showConfirm('Delete Build', 'Delete this build record?', async () => {
271+
showDeleteConfirm('Delete Build', { message: 'Delete this build record?', phrase: 'delete', onConfirm: async () => {
272272
try {
273273
await API.del(`/builds/detail/${btn.dataset.id}`);
274274
showToast('Build deleted', 'success');
275275
render();
276276
} catch (err) { showToast(err.message, 'error'); }
277-
}, true);
277+
} });
278278
});
279279
});
280280

281281
document.getElementById('clear-history')?.addEventListener('click', () => {
282-
showConfirm('Clear All', 'Delete all panel build history?', async () => {
282+
showDeleteConfirm('Clear All', { message: 'Delete all panel build history?', phrase: 'delete', onConfirm: async () => {
283283
try { await API.del('/builds'); showToast('Cleared', 'success'); render(); }
284284
catch (err) { showToast(err.message, 'error'); }
285-
}, true);
285+
} });
286286
});
287287
}
288288

@@ -338,13 +338,13 @@ Router.register('builds', async (content) => {
338338

339339
document.getElementById('back-to-list')?.addEventListener('click', () => { selectedBuildId = null; render(); });
340340
document.getElementById('delete-this-build')?.addEventListener('click', () => {
341-
showConfirm('Delete Build', 'Delete this build record?', async () => {
341+
showDeleteConfirm('Delete Build', { message: 'Delete this build record?', phrase: 'delete', onConfirm: async () => {
342342
try {
343343
await API.del(`/builds/detail/${build.id}`);
344344
showToast('Build deleted', 'success');
345345
selectedBuildId = null; render();
346346
} catch (err) { showToast(err.message, 'error'); }
347-
}, true);
347+
} });
348348
});
349349

350350
tabContent.querySelectorAll('.detail-tab').forEach(btn => {
@@ -716,15 +716,15 @@ Router.register('builds', async (content) => {
716716
});
717717

718718
document.getElementById('prune-cache')?.addEventListener('click', () => {
719-
showConfirm('Prune Build Cache', 'Remove all build cache? Next builds may take longer.', async () => {
719+
showDeleteConfirm('Prune Build Cache', { message: 'Remove all build cache? Next builds may take longer.', phrase: 'delete', onConfirm: async () => {
720720
try {
721721
showToast('Pruning...', 'info');
722722
const res = await API.post('/builds/cache/prune');
723723
let space = res.SpaceReclaimedStr ? ` (${res.SpaceReclaimedStr.replace('Total reclaimed space:','').trim()})` : '';
724724
showToast(`Cache pruned${space}`, 'success');
725725
renderCache();
726726
} catch (err) { showToast(err.message, 'error'); }
727-
}, true);
727+
} });
728728
});
729729
} catch (err) {
730730
tabContent.innerHTML = `<div class="empty-state"><h3>Error</h3><p>${escapeHtml(err.message)}</p></div>`;

public/js/pages/cleanup.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,28 +105,28 @@ async function renderCleanupInto(content, { embedded = false } = {}) {
105105
content.querySelectorAll('[data-prune]').forEach(btn => {
106106
btn.addEventListener('click', () => {
107107
const type = btn.dataset.prune;
108-
showConfirm(`Prune ${type}`, `Are you sure you want to remove all unused ${type}?`, async () => {
108+
showDeleteConfirm(`Prune ${type}`, { message: `Are you sure you want to remove all unused ${type}?`, phrase: 'delete', onConfirm: async () => {
109109
try {
110110
showToast(`Cleaning ${type}...`, 'info');
111111
const res = await API.post(`/cleanup/${type}`);
112112
let space = res.SpaceReclaimed ? ` (${formatBytes(res.SpaceReclaimed)})` : (res.SpaceReclaimedStr ? ` (${res.SpaceReclaimedStr.replace('Total reclaimed space:', '').trim()})` : '');
113113
showToast(`Cleanup successful${space}`);
114114
render();
115115
} catch (err) { showToast(err.message, 'error'); }
116-
}, type === 'volumes' || type === 'images');
116+
} });
117117
});
118118
});
119119

120120
document.getElementById('full-prune-btn').addEventListener('click', () => {
121121
const includeVols = document.getElementById('prune-volumes').checked;
122-
showConfirm(`Full System Prune`, `Are you absolutely sure? This will delete ALL unused data${includeVols ? ' INCLUDING VOLUMES.' : '.'}`, async () => {
122+
showDeleteConfirm(`Full System Prune`, { message: `Are you absolutely sure? This will delete ALL unused data${includeVols ? ' INCLUDING VOLUMES.' : '.'}`, phrase: 'delete', onConfirm: async () => {
123123
try {
124124
showToast(`Starting full system prune...`, 'warn');
125125
const res = await API.post(`/cleanup/system?volumes=${includeVols}`);
126126
showToast(`System pruned successfully`);
127127
render();
128128
} catch(err) { showToast(err.message, 'error'); }
129-
}, true);
129+
} });
130130
});
131131

132132
} catch (err) { content.innerHTML = `<div class="empty-state"><h3>Error</h3><p>${escapeHtml(err.message)}</p></div>`; }

public/js/pages/compose.js

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ Router.register('compose', async (content) => {
131131
run(svc.length ? `?services=${encodeURIComponent(svc.join(','))}` : '');
132132
// down/restart are disruptive — confirm first (down removes containers; restart interrupts)
133133
} else if (action === 'down') {
134-
showConfirm('Compose Down', `Stop and remove all containers in "${project}"?`, () => run(), true);
134+
showDeleteConfirm('Compose Down', { message: `Stop and remove all containers in "${project}"?`, phrase: project, onConfirm: () => run() });
135135
} else if (action === 'restart') {
136136
showConfirm('Compose Restart', `Restart all services in "${project}"? They will be briefly interrupted.`, () => run(), true);
137137
} else {
@@ -402,12 +402,22 @@ Router.register('compose', async (content) => {
402402
<label style="display:flex;gap:8px;align-items:flex-start;font-weight:400"><input type="checkbox" id="del-down" checked disabled> Stop &amp; remove containers (<code>docker compose down</code>)</label>
403403
<label style="display:flex;gap:8px;align-items:flex-start;font-weight:400"><input type="checkbox" id="del-files" checked> Remove the project files ${isRemote ? '(the folder on the remote server)' : '(DockGate-managed files)'}</label>
404404
<label style="display:flex;gap:8px;align-items:flex-start;font-weight:400;color:var(--danger,#f85149)"><input type="checkbox" id="del-vols"> Also delete data volumes — <strong>irreversible data loss</strong></label>
405+
<label class="text-xs text-muted" style="display:block;margin-top:4px">Type <code style="background:var(--bg-primary);padding:1px 6px;border-radius:4px;border:1px solid var(--border);font-weight:600">${escapeHtml(project)}</code> to confirm:</label>
406+
<input class="input" id="del-confirm" type="text" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" placeholder="${escapeHtml(project)}" style="width:100%;margin-top:6px" />
405407
</div>`;
406408
const m = showModal('Delete project', body, [{ label: 'Cancel', className: 'btn btn-secondary' }]);
407409
const root = m.overlay;
408410
const btn = document.createElement('button'); btn.className = 'btn btn-danger'; btn.textContent = 'Delete';
411+
btn.disabled = true; btn.style.opacity = '0.5'; btn.style.cursor = 'not-allowed';
409412
root.querySelector('#modal-footer').appendChild(btn);
413+
const delInput = root.querySelector('#del-confirm');
414+
const delMatches = () => delInput.value.trim() === project;
415+
const delSync = () => { const ok = delMatches(); btn.disabled = !ok; btn.style.opacity = ok ? '1' : '0.5'; btn.style.cursor = ok ? 'pointer' : 'not-allowed'; };
416+
delInput.addEventListener('input', delSync);
417+
delInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && delMatches()) { e.preventDefault(); btn.click(); } });
418+
setTimeout(() => delInput.focus(), 50);
410419
btn.addEventListener('click', async () => {
420+
if (!delMatches()) return;
411421
btn.disabled = true; btn.textContent = 'Deleting…';
412422
const vols = root.querySelector('#del-vols').checked ? 1 : 0;
413423
const files = root.querySelector('#del-files').checked ? 1 : 0;
@@ -506,10 +516,10 @@ Router.register('compose', async (content) => {
506516
el.querySelectorAll('[data-cd]').forEach(a => a.addEventListener('click', (ev) => { ev.preventDefault(); cwd = cwd ? cwd + '/' + a.dataset.cd : a.dataset.cd; list(); }));
507517
el.querySelectorAll('[data-edit-file]').forEach(b => b.addEventListener('click', () => editFileFromTree(project, b.dataset.editFile, list)));
508518
el.querySelectorAll('[data-del-file]').forEach(b => b.addEventListener('click', () => {
509-
showConfirm('Delete file', `Delete "${escapeHtml(b.dataset.delFile)}"?`, async () => {
519+
showDeleteConfirm('Delete file', { message: `Delete "${escapeHtml(b.dataset.delFile)}"?`, phrase: b.dataset.delFile, onConfirm: async () => {
510520
try { await API.del(`/compose/${project}/filecontent?path=${encodeURIComponent(b.dataset.delFile)}`); showToast('Deleted'); list(); }
511521
catch (e) { showToast(e.message, 'error'); }
512-
}, true);
522+
} });
513523
}));
514524
}
515525
list();

public/js/pages/container-detail.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,10 +146,10 @@ Router.register('container-detail', async (content, params) => {
146146
btn.addEventListener('click', async () => {
147147
const action = btn.dataset.action;
148148
if (action === 'remove') {
149-
showConfirm('Remove Container', `Remove <strong>${escapeHtml(name)}</strong>?`, async () => {
149+
showDeleteConfirm('Remove Container', { message: `Remove <strong>${escapeHtml(name)}</strong>?`, phrase: name, onConfirm: async () => {
150150
try { await API.post(`/containers/${id}/remove`, { force: true }); showToast(`Removed ${name}`); Router.navigate('resources',{tab:'containers'}); }
151151
catch (err) { showToast(err.message, 'error'); }
152-
}, true);
152+
} });
153153
return;
154154
}
155155
try { await API.post(`/containers/${id}/${action}`); showToast(`${action}${name}`); render(); }

0 commit comments

Comments
 (0)