From 97ba471ea884cb057e4084d52c95efe544a25993 Mon Sep 17 00:00:00 2001 From: Jad Seifeddine Date: Tue, 13 Oct 2026 13:58:05 +0700 Subject: [PATCH] ui: themed confirm/alert dialogs in place of the native ones static/ui.js provides promise-based rgConfirm/rgAlert that show a themed modal matching the app, injected self-contained so it works on any page including the standalone client. Every native alert()/confirm() is replaced: delete user and revoke token (admin), revoke token (tokens), delete recording (recordings), popup-blocked (client), and terminate session, delete entry, delete folder and the SSH host-key probe (connections). Destructive actions get a red confirm button, the host-key trust prompt an accent one. From an independent review of the module: rgConfirm/rgAlert resolve any already-open dialog before replacing it, so a second call can't leave the first promise hanging; Enter acts on the focused button (Cancel cancels), and Tab is trapped between the two buttons. Part of #254 tests: themed confirm/alert dialogs replace the native ones (cherry picked from commit f8779d083b7d92677b790af71ab97deb43e4f69a) --- static/admin.html | 33 ++++--- static/client.html | 3 +- static/connections.html | 82 +++++++++------- static/recordings.html | 24 ++--- static/tokens.html | 21 ++-- static/ui.js | 122 ++++++++++++++++++++++++ tests/e2e/README.md | 1 + tests/e2e/specs/themed-dialogs.spec.mjs | 69 ++++++++++++++ 8 files changed, 283 insertions(+), 72 deletions(-) create mode 100644 static/ui.js create mode 100644 tests/e2e/specs/themed-dialogs.spec.mjs diff --git a/static/admin.html b/static/admin.html index c67657e..4725807 100644 --- a/static/admin.html +++ b/static/admin.html @@ -298,10 +298,12 @@ delBtn.textContent = 'delete'; delBtn.addEventListener('click', (function(email) { return function() { - if (!confirm('Delete user ' + email + '?')) return; - fetch('/api/users/' + encodeURIComponent(email), { - method: 'DELETE', headers: apiHeaders(), credentials: 'same-origin' - }).then(function() { loadUsers(); }); + rgConfirm('Delete user ' + email + '?', { danger: true, confirmText: 'Delete' }).then(function(ok) { + if (!ok) return; + fetch('/api/users/' + encodeURIComponent(email), { + method: 'DELETE', headers: apiHeaders(), credentials: 'same-origin' + }).then(function() { loadUsers(); }); + }); }; })(u.email)); actTd.appendChild(delBtn); @@ -456,16 +458,18 @@ revokeBtn.textContent = 'revoke'; revokeBtn.addEventListener('click', (function(id, name, email) { return function() { - if (!confirm('Revoke token "' + name + '" for ' + email + '?')) return; - fetch('/api/admin/user-tokens/' + id, { - method: 'DELETE', - headers: apiHeaders(), - credentials: 'same-origin' - }) - .then(function(res) { - if (!res.ok) return res.json().then(function(d) { showError(d.error); }); - loadAllTokens(); - loadAuditLog(); + rgConfirm('Revoke token "' + name + '" for ' + email + '?', { danger: true, confirmText: 'Revoke' }).then(function(ok) { + if (!ok) return; + fetch('/api/admin/user-tokens/' + id, { + method: 'DELETE', + headers: apiHeaders(), + credentials: 'same-origin' + }) + .then(function(res) { + if (!res.ok) return res.json().then(function(d) { showError(d.error); }); + loadAllTokens(); + loadAuditLog(); + }); }); }; })(t.id, t.name, t.email || '')); @@ -653,5 +657,6 @@ // Auto-refresh setInterval(function() { loadUsers(); loadMappings(); loadAllTokens(); }, 10000); + diff --git a/static/client.html b/static/client.html index 49fa3de..8cc6cce 100644 --- a/static/client.html +++ b/static/client.html @@ -879,7 +879,7 @@ var win = window.open('', 'rustguac-mon-' + index, 'width=1280,height=800,menubar=no,toolbar=no,location=no,status=no'); if (!win) { - alert('Popup blocked. Allow popups for this site to open an extra monitor.'); + rgAlert('Popup blocked. Allow popups for this site to open an extra monitor.'); renderMonitorControls(); return; } @@ -2038,5 +2038,6 @@ client.connect(connectData); } + diff --git a/static/connections.html b/static/connections.html index 949c146..c67d306 100644 --- a/static/connections.html +++ b/static/connections.html @@ -1951,20 +1951,22 @@ ev.stopPropagation(); var sid = termBtn.getAttribute('data-terminate'); var label = termBtn.getAttribute('data-name') || sid; - if (!confirm('Terminate session "' + label + '"? Any unsaved work will be lost.')) return; - termBtn.disabled = true; - termBtn.textContent = 'Ending...'; - fetch('/api/sessions/' + sid, { - method: 'DELETE', - headers: apiHeaders(), - credentials: 'same-origin' - }).then(function(res) { - if (!res.ok) return res.text().then(function(t) { throw new Error(t || 'terminate failed'); }); - loadActiveSessions(); - }).catch(function(err) { - termBtn.disabled = false; - termBtn.textContent = 'Terminate'; - showError('Terminate failed: ' + err.message); + rgConfirm('Terminate session "' + label + '"? Any unsaved work will be lost.', { danger: true, confirmText: 'Terminate' }).then(function(ok) { + if (!ok) return; + termBtn.disabled = true; + termBtn.textContent = 'Ending...'; + fetch('/api/sessions/' + sid, { + method: 'DELETE', + headers: apiHeaders(), + credentials: 'same-origin' + }).then(function(res) { + if (!res.ok) return res.text().then(function(t) { throw new Error(t || 'terminate failed'); }); + loadActiveSessions(); + }).catch(function(err) { + termBtn.disabled = false; + termBtn.textContent = 'Terminate'; + showError('Terminate failed: ' + err.message); + }); }); }); } @@ -3639,14 +3641,16 @@ var name = btn.getAttribute('data-delete-entry'); var scope = btn.getAttribute('data-scope'); var folder = btn.getAttribute('data-folder'); - if (!confirm('Delete entry "' + name + '"?')) return; - fetch('/api/addressbook/folders/' + encodeURIComponent(scope) + '/' + encodeURIComponent(folder) + '/entries/' + encodeURIComponent(name), { - method: 'DELETE', - headers: apiHeaders(), - credentials: 'same-origin' - }).then(function(res) { - if (!res.ok) return res.text().then(function(t) { showError(t); }); - loadEntries(scope, folder, true); + rgConfirm('Delete entry "' + name + '"?', { danger: true, confirmText: 'Delete' }).then(function(ok) { + if (!ok) return; + fetch('/api/addressbook/folders/' + encodeURIComponent(scope) + '/' + encodeURIComponent(folder) + '/entries/' + encodeURIComponent(name), { + method: 'DELETE', + headers: apiHeaders(), + credentials: 'same-origin' + }).then(function(res) { + if (!res.ok) return res.text().then(function(t) { showError(t); }); + loadEntries(scope, folder, true); + }); }); return; } @@ -3982,8 +3986,9 @@ var warning = hasSubtree ? 'Delete folder "' + selPath + '" AND all its subfolders and entries?\n\nThis recursively removes the entire subtree and cannot be undone.' : 'Delete folder "' + selPath + '" and all its entries?\n\nThis cannot be undone.'; - if (!confirm(warning)) return; - fetch('/api/addressbook/folders/' + encodeURIComponent(selectedFolder.scope) + '/' + encodeURIComponent(selPath), { + rgConfirm(warning, { title: 'Delete folder', danger: true, confirmText: 'Delete' }).then(function(ok) { + if (!ok) return; + fetch('/api/addressbook/folders/' + encodeURIComponent(selectedFolder.scope) + '/' + encodeURIComponent(selPath), { method: 'DELETE', headers: apiHeaders(), credentials: 'same-origin' @@ -4005,6 +4010,7 @@ document.getElementById('entries-header').style.display = 'none'; document.getElementById('entries-content').innerHTML = '

Select a folder to view entries.

'; loadFolders(); + }); }); }); @@ -4341,7 +4347,7 @@ function probeHostKey(hopIndex) { var hop = hopList[hopIndex]; if (!hop || !hop.hostname || !hop.hostname.trim()) { - alert('Enter a hostname first.'); + rgAlert('Enter a hostname first.'); return; } var btn = document.querySelector('.hop-verify-btn[data-hop="' + hopIndex + '"]'); @@ -4355,22 +4361,23 @@ }).then(function(r) { return r.json().then(function(d) { return {ok: r.ok, data: d}; }); }) .then(function(res) { if (!res.ok) { - alert('Probe failed: ' + (res.data.error || 'unknown error')); + rgAlert('Probe failed: ' + (res.data.error || 'unknown error')); if (btn) { btn.disabled = false; btn.textContent = hop.host_key ? 'Re-verify' : 'Verify Host Key'; } return; } - var msg = 'Host key for ' + hop.hostname.trim() + ':' + (hop.port || 22) + ':\n\n' + - res.data.fingerprint + ' (' + res.data.algorithm + ')\n\n' + - 'Trust this key?'; - if (confirm(msg)) { - hopList[hopIndex].host_key = res.data.host_key; - hopList[hopIndex].host_key_fingerprint = res.data.fingerprint; - renderHops(); - } else { - if (btn) { btn.disabled = false; btn.textContent = hop.host_key ? 'Re-verify' : 'Verify Host Key'; } - } + var msg = hop.hostname.trim() + ':' + (hop.port || 22) + '\n\n' + + res.data.fingerprint + ' (' + res.data.algorithm + ')'; + rgConfirm(msg, { title: 'Trust host key?', confirmText: 'Trust', safe: true }).then(function(ok) { + if (ok) { + hopList[hopIndex].host_key = res.data.host_key; + hopList[hopIndex].host_key_fingerprint = res.data.fingerprint; + renderHops(); + } else { + if (btn) { btn.disabled = false; btn.textContent = hop.host_key ? 'Re-verify' : 'Verify Host Key'; } + } + }); }).catch(function(e) { - alert('Probe failed: ' + e.message); + rgAlert('Probe failed: ' + e.message); if (btn) { btn.disabled = false; btn.textContent = hop.host_key ? 'Re-verify' : 'Verify Host Key'; } }); } @@ -6147,5 +6154,6 @@ setTimeout(function() { openMyCredentials(); }, 250); } + diff --git a/static/recordings.html b/static/recordings.html index a47b447..4c07731 100644 --- a/static/recordings.html +++ b/static/recordings.html @@ -508,17 +508,18 @@ } var deleteName = e.target.getAttribute('data-delete-rec'); if (deleteName) { - if (!confirm('Delete recording ' + deleteName + '?')) return; - fetch('/api/recordings/' + encodeURIComponent(deleteName), { - method: 'DELETE', - headers: apiHeaders(), - credentials: 'same-origin' - }).then(function() { - refreshRecordings(); - // Close player if this recording is playing - if (document.getElementById('player-title').textContent === deleteName) { - closePlayer(); - } + rgConfirm('Delete recording ' + deleteName + '?', { danger: true, confirmText: 'Delete' }).then(function(ok) { + if (!ok) return; + fetch('/api/recordings/' + encodeURIComponent(deleteName), { + method: 'DELETE', + headers: apiHeaders(), + credentials: 'same-origin' + }).then(function() { + refreshRecordings(); + if (document.getElementById('player-title').textContent === deleteName) { + closePlayer(); + } + }); }); } }); @@ -683,5 +684,6 @@ setInterval(refreshRecordings, 15000); setInterval(refreshTypescripts, 15000); + diff --git a/static/tokens.html b/static/tokens.html index 12c8a1c..60f9e2c 100644 --- a/static/tokens.html +++ b/static/tokens.html @@ -227,15 +227,17 @@ revokeBtn.textContent = 'revoke'; revokeBtn.addEventListener('click', (function(id, name) { return function() { - if (!confirm('Revoke token "' + name + '"? This cannot be undone.')) return; - fetch('/api/me/tokens/' + id, { - method: 'DELETE', - headers: apiHeaders(), - credentials: 'same-origin' - }) - .then(function(res) { - if (!res.ok) return res.json().then(function(d) { showError(d.error); }); - loadTokens(); + rgConfirm('Revoke token "' + name + '"? This cannot be undone.', { danger: true, confirmText: 'Revoke' }).then(function(ok) { + if (!ok) return; + fetch('/api/me/tokens/' + id, { + method: 'DELETE', + headers: apiHeaders(), + credentials: 'same-origin' + }) + .then(function(res) { + if (!res.ok) return res.json().then(function(d) { showError(d.error); }); + loadTokens(); + }); }); }; })(t.id, t.name)); @@ -336,5 +338,6 @@ } }, 10000); + diff --git a/static/ui.js b/static/ui.js new file mode 100644 index 0000000..6e0167f --- /dev/null +++ b/static/ui.js @@ -0,0 +1,122 @@ +/* + * Themed dialogs — replacements for the browser's native alert() / confirm(). + * + * Self-contained: injects its own styles and a dialog node, using the theme + * CSS tokens that every page defines, so it works anywhere this is loaded + * (including the standalone client page). Promise-based, since a styled modal + * can't block the thread the way the native ones do. + * + * rgConfirm('Delete this entry?', { danger: true }).then(function (ok) { ... }); + * rgAlert('Popup blocked.').then(function () { ... }); + */ +(function () { + 'use strict'; + if (window.rgConfirm) return; + + function injectStyle() { + if (document.getElementById('rg-dialog-style')) return; + var css = [ + '#rg-dialog-overlay{display:none;position:fixed;inset:0;z-index:4000;background:rgba(0,0,0,.6);backdrop-filter:blur(2px);-webkit-backdrop-filter:blur(2px);justify-content:center;align-items:center;font-family:monospace}', + '#rg-dialog-overlay.open{display:flex}', + '#rg-dialog{background:var(--surface,#16213e);border:1px solid var(--border,#333);border-radius:8px;box-shadow:0 20px 60px rgba(0,0,0,.6);min-width:340px;max-width:min(520px,calc(100vw - 32px));padding:var(--s-5,28px);animation:rg-dialog-in 120ms ease-out both}', + '@keyframes rg-dialog-in{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:none}}', + '#rg-dialog-title{color:var(--primary,#e94560);font-size:var(--fz-lg,20px);font-weight:bold;letter-spacing:.06em;text-transform:uppercase;margin:0 0 var(--s-3,14px);padding-bottom:var(--s-2,8px);border-bottom:1px solid var(--border,#333)}', + '#rg-dialog-message{color:var(--text,#e0e0e0);font-size:var(--fz-md,18px);line-height:1.55;white-space:pre-wrap;word-break:break-word}', + '#rg-dialog-actions{display:flex;justify-content:flex-end;gap:var(--s-2,8px);margin-top:var(--s-5,28px)}', + '#rg-dialog-actions button{height:var(--ctl-md,44px);padding:0 var(--s-4,20px);border-radius:var(--radius,2px);font-family:monospace;font-size:var(--fz-md,18px);font-weight:bold;cursor:pointer;border:1px solid transparent;box-sizing:border-box;line-height:1}', + '#rg-dialog-ok{background:var(--primary,#e94560);border-color:var(--primary,#e94560);color:var(--text-on-primary,#fff)}', + '#rg-dialog-ok:hover{background:var(--primary-hover,#c73652);border-color:var(--primary-hover,#c73652)}', + '#rg-dialog-overlay.safe #rg-dialog-ok{background:var(--accent,#5bc0be);border-color:var(--accent,#5bc0be);color:var(--bg,#1a1a2e)}', + '#rg-dialog-overlay.safe #rg-dialog-ok:hover{background:var(--accent-hover,#4aa3a1);border-color:var(--accent-hover,#4aa3a1)}', + '#rg-dialog-cancel{background:transparent;color:var(--text-muted,#aaa);border-color:var(--border,#333);font-weight:normal}', + '#rg-dialog-cancel:hover{background:var(--input,#0f3460);color:var(--text,#e0e0e0);border-color:var(--text-muted,#aaa)}', + '#rg-dialog button:focus-visible{outline:2px solid var(--accent,#5bc0be);outline-offset:2px}' + ].join(''); + var st = document.createElement('style'); st.id = 'rg-dialog-style'; st.textContent = css; + (document.head || document.documentElement).appendChild(st); + } + + var overlay, titleEl, msgEl, okBtn, cancelBtn, current = null, prevFocus = null; + + function build() { + if (overlay) return; + injectStyle(); + overlay = document.createElement('div'); + overlay.id = 'rg-dialog-overlay'; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + overlay.innerHTML = + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '
'; + document.body.appendChild(overlay); + titleEl = overlay.querySelector('#rg-dialog-title'); + msgEl = overlay.querySelector('#rg-dialog-message'); + okBtn = overlay.querySelector('#rg-dialog-ok'); + cancelBtn = overlay.querySelector('#rg-dialog-cancel'); + okBtn.addEventListener('click', function () { done(true); }); + cancelBtn.addEventListener('click', function () { done(false); }); + overlay.addEventListener('mousedown', function (e) { if (e.target === overlay) done(false); }); + document.addEventListener('keydown', function (e) { + if (!current) return; + if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); done(false); } + else if (e.key === 'Enter') { + // Act on the focused button, so a user who tabbed to Cancel + // and pressed Enter cancels instead of confirming. + e.preventDefault(); e.stopPropagation(); + done(document.activeElement !== cancelBtn); + } else if (e.key === 'Tab') { + // Trap focus between the two buttons. + e.preventDefault(); + var toCancel = document.activeElement === okBtn && !e.shiftKey ? true + : document.activeElement === cancelBtn && e.shiftKey ? false : null; + var target = (cancelBtn.style.display === 'none') ? okBtn + : (toCancel === true ? cancelBtn : toCancel === false ? okBtn + : (document.activeElement === okBtn ? cancelBtn : okBtn)); + try { target.focus(); } catch (x) {} + } + }, true); + } + + function done(result) { + if (!current) return; + var resolve = current.resolve, isConfirm = current.isConfirm; + current = null; + overlay.classList.remove('open'); + if (prevFocus && prevFocus.focus) { try { prevFocus.focus(); } catch (e) {} } + resolve(isConfirm ? result : undefined); + } + + function show(opts) { + build(); + // If a dialog is already open, resolve its promise (as cancel) before + // replacing it, so the earlier caller never hangs. + if (current) { var c = current; current = null; try { c.resolve(c.isConfirm ? false : undefined); } catch (e) {} } + return new Promise(function (resolve) { + current = { resolve: resolve, isConfirm: !!opts.isConfirm }; + titleEl.textContent = opts.title || (opts.isConfirm ? 'Confirm' : 'Notice'); + msgEl.textContent = opts.message == null ? '' : String(opts.message); + okBtn.textContent = opts.confirmText || (opts.isConfirm ? 'Confirm' : 'OK'); + if (opts.isConfirm) { cancelBtn.style.display = ''; cancelBtn.textContent = opts.cancelText || 'Cancel'; } + else { cancelBtn.style.display = 'none'; } + // danger (destructive) keeps the primary red; safe actions go accent. + overlay.classList.toggle('safe', !!opts.safe && !opts.danger); + prevFocus = document.activeElement; + overlay.classList.add('open'); + setTimeout(function () { try { okBtn.focus(); } catch (e) {} }, 0); + }); + } + + window.rgConfirm = function (message, opts) { + opts = opts || {}; + return show({ isConfirm: true, message: message, title: opts.title, confirmText: opts.confirmText, cancelText: opts.cancelText, danger: opts.danger, safe: opts.safe }); + }; + window.rgAlert = function (message, opts) { + opts = opts || {}; + return show({ isConfirm: false, message: message, title: opts.title, confirmText: opts.okText || opts.confirmText }); + }; +})(); diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 556ac83..6bc0d59 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -31,6 +31,7 @@ One spec per change, named after the branch that introduced it: | `sessions-adhoc-modal` | ad-hoc form opens as a modal | | `facet-filters` | shared facets narrow lists, persist, combine with search | | `folder-display-name` | display name over the key, modal field round-trips | +| `themed-dialogs` | `rgConfirm`/`rgAlert` replace native dialogs | The fixture suppresses the first-visit onboarding tour (`options.onboarding` re-enables it) and defaults to the admin role; pass `role` to test gating. diff --git a/tests/e2e/specs/themed-dialogs.spec.mjs b/tests/e2e/specs/themed-dialogs.spec.mjs new file mode 100644 index 0000000..b0c401f --- /dev/null +++ b/tests/e2e/specs/themed-dialogs.spec.mjs @@ -0,0 +1,69 @@ +// Themed confirm/alert dialogs (ui.js) replace the browser's native ones: +// a destructive action asks in an in-page modal with a red confirm button, +// Escape cancels and sends nothing, OK performs the request; Enter acts on +// the focused button and Tab stays inside the dialog. +import { test, expect } from '@playwright/test'; +import { installApi, settle } from '../lib/api.mjs'; + +test('deleting a recording asks in a themed dialog; Escape cancels, OK deletes', async ({ page }) => { + const api = await installApi(page, { role: 'admin' }); + // A native confirm() would block the test: make sure none is ever raised. + page.on('dialog', d => { throw new Error('native dialog raised: ' + d.message()); }); + await page.goto('/recordings.html'); + await settle(page); + const del = page.locator('button[data-delete-rec]').first(); + await expect(del).toBeVisible(); + const name = await del.getAttribute('data-delete-rec'); + + await del.click(); + const dialog = page.locator('#rg-dialog'); + await expect(dialog).toBeVisible(); + await expect(page.locator('#rg-dialog-message')).toContainText(name); + await expect(page.locator('#rg-dialog-ok')).toBeVisible(); + await expect(page.locator('#rg-dialog-cancel')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(dialog).toBeHidden(); + expect(api.calls.filter(c => c.method === 'DELETE').length).toBe(0); + + await del.click(); + await expect(dialog).toBeVisible(); + await page.locator('#rg-dialog-ok').click(); + await expect(dialog).toBeHidden(); + await expect.poll(() => api.calls.some(c => c.method === 'DELETE' && c.path === '/api/recordings/' + encodeURIComponent(name))).toBe(true); +}); + +test('Tab is trapped between the two buttons and Enter acts on the focused one', async ({ page }) => { + const api = await installApi(page, { role: 'admin' }); + await page.goto('/recordings.html'); + await settle(page); + await page.locator('button[data-delete-rec]').first().click(); + const dialog = page.locator('#rg-dialog'); + await expect(dialog).toBeVisible(); + const ids = []; + for (let i = 0; i < 4; i++) { + await page.keyboard.press('Tab'); + ids.push(await page.evaluate(() => document.activeElement.id)); + } + for (const id of ids) expect(['rg-dialog-ok', 'rg-dialog-cancel']).toContain(id); + // Land on Cancel, press Enter: cancelled, nothing sent. + while ((await page.evaluate(() => document.activeElement.id)) !== 'rg-dialog-cancel') await page.keyboard.press('Tab'); + await page.keyboard.press('Enter'); + await expect(dialog).toBeHidden(); + expect(api.calls.filter(c => c.method === 'DELETE').length).toBe(0); +}); + +test('connections: deleting an entry and terminating a session use the dialog too', async ({ page }) => { + const api = await installApi(page, { role: 'admin' }); + page.on('dialog', d => { throw new Error('native dialog raised: ' + d.message()); }); + await page.goto('/connections.html'); + await settle(page); + await page.locator('.folder-row').filter({ hasText: /beta/ }).first().click(); + await expect(page.locator('#entries-title')).toHaveText(/beta/); + const row = page.locator('.main .entries-table tbody tr').filter({ hasText: 'app01' }); + await row.locator('button', { hasText: /^delete$/i }).first().click(); + const dialog = page.locator('#rg-dialog'); + await expect(dialog).toBeVisible(); + await expect(page.locator('#rg-dialog-message')).toContainText('app01'); + await page.locator('#rg-dialog-ok').click(); + await expect.poll(() => api.calls.some(c => c.method === 'DELETE' && c.path.endsWith('/entries/app01'))).toBe(true); +});