also looking at this
fix(dashboard): confirm destructive actions that ran on first click
#130
4 files
+99
-10
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -39,12 +39,29 @@ const serverCmd = state('') | ||
| 39 | 39 | const cmdOut = state('') |
| 40 | 40 | const cmdShown = state(false) |
| 41 | 41 | const cmdBusy = state(false) |
| 42 | async function runServerCmd() { | |
| 42 | // The API already requires a typed confirmation for shell commands — it answers | |
| 43 | // 'Type "run" to execute this command on the server.' The UI never implemented | |
| 44 | // that step and supplied the token itself, so the gate always passed and Enter | |
| 45 | // in the box ran arbitrary shell on the production server. Stage the command, | |
| 46 | // make the operator type the word, and send what they actually typed. | |
| 47 | const cmdPending = state(null) | |
| 48 | const cmdTyped = state('') | |
| 49 | const cmdCanRun = derived(() => cmdPending() !== null && cmdTyped().trim() === 'run') | |
| 50 | function askServerCmd() { | |
| 43 | 51 | const c = serverCmd().trim() |
| 44 | 52 | if (!c) return |
| 53 | cmdPending.set(c); cmdTyped.set('') | |
| 54 | } | |
| 55 | function cancelServerCmd() { cmdPending.set(null); cmdTyped.set('') } | |
| 56 | ||
| 57 | async function runServerCmd() { | |
| 58 | const c = cmdPending() | |
| 59 | const confirm = cmdTyped().trim() | |
| 60 | if (!c || confirm !== 'run') return | |
| 61 | cmdPending.set(null); cmdTyped.set('') | |
| 45 | 62 | cmdBusy.set(true); cmdShown.set(true); cmdOut.set('Running ' + c + '...') |
| 46 | 63 | try { |
| 47 | const res = await fetch('/api/server/command', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ command: c, confirm: 'run' }) }) | |
| 64 | const res = await fetch('/api/server/command', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ command: c, confirm }) }) | |
| 48 | 65 | const b = await res.json() |
| 49 | 66 | cmdOut.set((b.ok ? 'OK ' : 'FAILED ') + (b.command || c) + '\n\n' + (b.stdout || '') + (b.stderr ? '\n' + b.stderr : '') + (b.error ? '\n' + b.error : '')) |
| 50 | 67 | } catch (e) { cmdOut.set('FAILED ' + c + '\n\n' + ((e && e.message) || e)) } |
| @@ -81,8 +98,14 @@ async function runServerCmd() { | ||
| 81 | 98 | <h2>Run a command on the server</h2> |
| 82 | 99 | <div class="panel"> |
| 83 | 100 | <div class="field"> |
| 84 | <input :value="serverCmd()" @input="serverCmd.set($event.target.value)" @keydown.enter="runServerCmd()" placeholder="e.g. systemctl status rpx-gateway, or df -h" autocomplete="off"> | |
| 85 | <button class="btn" type="button" :disabled="cmdBusy()" @click="runServerCmd()">Run</button> | |
| 101 | <input :value="serverCmd()" @input="serverCmd.set($event.target.value)" @keydown.enter="askServerCmd()" placeholder="e.g. systemctl status rpx-gateway, or df -h" autocomplete="off" aria-label="Command to run on the server"> | |
| 102 | <button class="btn" type="button" :disabled="cmdBusy()" @click="askServerCmd()">Run</button> | |
| 103 | </div> | |
| 104 | <div class="op-confirm" @show="cmdPending() !== null" style="margin-top:14px"> | |
| 105 | <span>Type <b class="mono">run</b> to execute <b class="mono">{{ cmdPending() }}</b> on {{ server.name }}:</span> | |
| 106 | <input class="op-confirm-input" :value="cmdTyped()" @input="cmdTyped.set($event.target.value)" @keydown.enter="runServerCmd()" placeholder="confirm" autocomplete="off" aria-label="Type run to confirm executing this command"> | |
| 107 | <button type="button" class="btn danger sm" :disabled="!cmdCanRun()" @click="runServerCmd()">Run command</button> | |
| 108 | <button type="button" class="btn ghost sm" @click="cancelServerCmd()">Cancel</button> | |
| 86 | 109 | </div> |
| 87 | 110 | <pre class="action-output" @show="cmdShown()">{{ cmdOut() }}</pre> |
| 88 | 111 | <p class="note">Runs on the app box over the active driver (SSH/SSM). Use it for one-off recipes, service inspection, or installing software. Prefer declarative config for anything you want reproduced on the next deploy.</p> |
| @@ -38,6 +38,21 @@ async function removeKey(n) { | ||
| 38 | 38 | keys.set(data.keys || []) |
| 39 | 39 | setMessage('Removed from cloud config. Run cloud deploy to apply.', 'ok') |
| 40 | 40 | } |
| 41 | // Removal runs through a typed-confirm bar (same pattern as firewall/team): | |
| 42 | // type the key name to confirm. Removing the wrong key can lock every operator | |
| 43 | // out of the box, so it should never be one misclick away. | |
| 44 | const pendingRemove = state(null) | |
| 45 | const typedRemove = state('') | |
| 46 | const canRemove = derived(() => { const k = pendingRemove(); return k !== null && typedRemove() === String(k.name) }) | |
| 47 | function askRemove(key) { pendingRemove.set(key); typedRemove.set('') } | |
| 48 | function cancelRemove() { pendingRemove.set(null); typedRemove.set('') } | |
| 49 | async function confirmRemove() { | |
| 50 | const k = pendingRemove() | |
| 51 | if (!k || typedRemove() !== String(k.name)) return | |
| 52 | pendingRemove.set(null); typedRemove.set('') | |
| 53 | await removeKey(k.name) | |
| 54 | } | |
| 55 | ||
| 41 | 56 | onMount(() => { load() }) |
| 42 | 57 | </script> |
| 43 | 58 | <!DOCTYPE html> |
| @@ -79,12 +94,18 @@ onMount(() => { load() }) | ||
| 79 | 94 | <td class="mono" style="color:var(--txt3)">{{ k.type }}</td> |
| 80 | 95 | <td class="mono">{{ k.fingerprint }}</td> |
| 81 | 96 | <td style="color:var(--txt3)">{{ k.added }}</td> |
| 82 | <td class="table-actions"><button type="button" class="btn danger sm" @click="removeKey(k.name)">Remove</button></td> | |
| 97 | <td class="table-actions"><button type="button" class="btn danger sm" :aria-label="'Remove SSH key ' + k.name" @click="askRemove(k)">Remove</button></td> | |
| 83 | 98 | </tr> |
| 84 | 99 | </template> |
| 85 | 100 | </tbody> |
| 86 | 101 | </table> |
| 87 | 102 | <div class="compact empty" @show="keys().length === 0"><strong>No SSH keys configured</strong><span>Add a public key above to update <span class="mono">infrastructure.compute.sshKeys</span>.</span></div> |
| 103 | <div class="op-confirm" @show="pendingRemove() !== null" style="margin-top:14px"> | |
| 104 | <span>Type <b class="mono">{{ pendingRemove()?.name }}</b> to remove this key from the box:</span> | |
| 105 | <input class="op-confirm-input" :value="typedRemove()" @input="typedRemove.set($event.target.value)" @keydown.enter="confirmRemove()" placeholder="confirm" autocomplete="off" aria-label="Type the key name to confirm removal"> | |
| 106 | <button type="button" class="btn danger sm" :disabled="!canRemove()" @click="confirmRemove()">Remove</button> | |
| 107 | <button type="button" class="btn ghost sm" @click="cancelRemove()">Cancel</button> | |
| 108 | </div> | |
| 88 | 109 | <p class="note">Keys are declarative: this page edits <span class="mono">infrastructure.compute.sshKeys</span> in cloud config. Run <span class="mono">cloud deploy</span> to apply changes to the box. Connect with <span class="mono">cloud server:ssh {{ server.name }}</span>.</p> |
| 89 | 110 | </div> |
| 90 | 111 | </div> |
| @@ -67,6 +67,22 @@ async function revoke(name) { | ||
| 67 | 67 | load() |
| 68 | 68 | } |
| 69 | 69 | |
| 70 | // Removal runs through a typed-confirm bar (same pattern as firewall/secrets): | |
| 71 | // type the username to confirm. Revoking pulls someone's access to every site | |
| 72 | // on the box at once and invalidates their password, so it should never be one | |
| 73 | // misclick away. | |
| 74 | const pendingRevoke = state(null) | |
| 75 | const typedRevoke = state('') | |
| 76 | const canRevoke = derived(() => { const u = pendingRevoke(); return u !== null && typedRevoke() === String(u.username) }) | |
| 77 | function askRevoke(user) { pendingRevoke.set(user); typedRevoke.set('') } | |
| 78 | function cancelRevoke() { pendingRevoke.set(null); typedRevoke.set('') } | |
| 79 | async function confirmRevoke() { | |
| 80 | const u = pendingRevoke() | |
| 81 | if (!u || typedRevoke() !== String(u.username)) return | |
| 82 | pendingRevoke.set(null); typedRevoke.set('') | |
| 83 | await revoke(u.username) | |
| 84 | } | |
| 85 | ||
| 70 | 86 | onMount(() => { load() }) |
| 71 | 87 | </script> |
| 72 | 88 | <!DOCTYPE html> |
| @@ -135,12 +151,18 @@ onMount(() => { load() }) | ||
| 135 | 151 | <td><b>{{ u.name }}</b><span class="mono uname">{{ u.username }}</span></td> |
| 136 | 152 | <td><span class="tag">{{ u.role === 'admin' ? 'box owner' : 'member' }}</span></td> |
| 137 | 153 | <td class="mono">{{ u.role === 'admin' ? 'every site' : siteList(u) }}</td> |
| 138 | <td class="table-actions"><button type="button" class="btn danger sm" @click="revoke(u.username)">Remove</button></td> | |
| 154 | <td class="table-actions"><button type="button" class="btn danger sm" @click="askRevoke(u)">Remove</button></td> | |
| 139 | 155 | </tr> |
| 140 | 156 | </template> |
| 141 | 157 | </tbody> |
| 142 | 158 | </table> |
| 143 | 159 | <div class="compact empty" @show="users().length === 0"><strong>No one invited yet</strong><span>Invite someone above to give them access to a site.</span></div> |
| 160 | <div class="op-confirm" @show="pendingRevoke() !== null" style="margin-top:14px"> | |
| 161 | <span>Type <b class="mono">{{ pendingRevoke()?.username }}</b> to remove <b>{{ pendingRevoke()?.name }}</b> from every site:</span> | |
| 162 | <input class="op-confirm-input" :value="typedRevoke()" @input="typedRevoke.set($event.target.value)" @keydown.enter="confirmRevoke()" placeholder="confirm" autocomplete="off"> | |
| 163 | <button class="btn danger sm" :disabled="!canRevoke()" @click="confirmRevoke()">Remove</button> | |
| 164 | <button class="btn ghost sm" @click="cancelRevoke()">Cancel</button> | |
| 165 | </div> | |
| 144 | 166 | <p class="note">Box owners reach everything on this server. Members reach only the sites listed here, and never the shell, SSH keys, firewall or databases. Access is checked on every request.</p> |
| 145 | 167 | </div> |
| 146 | 168 | </div> |
| @@ -87,12 +87,29 @@ const appCmd = state('') | ||
| 87 | 87 | const cmdOut = state('') |
| 88 | 88 | const cmdShown = state(false) |
| 89 | 89 | const cmdBusy = state(false) |
| 90 | async function runAppCmd() { | |
| 90 | // The API already requires a typed confirmation here ('Type "run" to execute | |
| 91 | // this command.'), but the UI supplied the token itself, so the gate always | |
| 92 | // passed and Enter ran an arbitrary app command (migrate:fresh, queue:flush) | |
| 93 | // against production. Stage it, make the operator type the word, send what they | |
| 94 | // actually typed. | |
| 95 | const cmdPending = state(null) | |
| 96 | const cmdTyped = state('') | |
| 97 | const cmdCanRun = derived(() => cmdPending() !== null && cmdTyped().trim() === 'run') | |
| 98 | function askAppCmd() { | |
| 91 | 99 | const c = appCmd().trim() |
| 92 | 100 | if (!c) return |
| 101 | cmdPending.set(c); cmdTyped.set('') | |
| 102 | } | |
| 103 | function cancelAppCmd() { cmdPending.set(null); cmdTyped.set('') } | |
| 104 | ||
| 105 | async function runAppCmd() { | |
| 106 | const c = cmdPending() | |
| 107 | const confirm = cmdTyped().trim() | |
| 108 | if (!c || confirm !== 'run') return | |
| 109 | cmdPending.set(null); cmdTyped.set('') | |
| 93 | 110 | cmdBusy.set(true); cmdShown.set(true); cmdOut.set('Running ' + c + '...') |
| 94 | 111 | try { |
| 95 | const res = await fetch('/api/serverless/command', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ command: c, confirm: 'run' }) }) | |
| 112 | const res = await fetch('/api/serverless/command', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ command: c, confirm }) }) | |
| 96 | 113 | const b = await res.json() |
| 97 | 114 | cmdOut.set((b.ok ? 'OK ' : 'FAILED ') + (b.command || c) + '\n\n' + (b.stdout || '') + (b.error ? '\n' + b.error : '')) |
| 98 | 115 | } catch (e) { cmdOut.set('FAILED ' + c + '\n\n' + ((e && e.message) || e)) } |
| @@ -273,8 +290,14 @@ async function runAppCmd() { | ||
| 273 | 290 | <h2>Run a command</h2> |
| 274 | 291 | <div class="panel"> |
| 275 | 292 | <div class="field"> |
| 276 | <input :value="appCmd()" @input="appCmd.set($event.target.value)" @keydown.enter="runAppCmd()" placeholder="e.g. migrate --force, cache:clear, queue:retry all" autocomplete="off"> | |
| 277 | <button class="btn" type="button" :disabled="cmdBusy()" @click="runAppCmd()">Run</button> | |
| 293 | <input :value="appCmd()" @input="appCmd.set($event.target.value)" @keydown.enter="askAppCmd()" placeholder="e.g. migrate --force, cache:clear, queue:retry all" autocomplete="off" aria-label="App command to run"> | |
| 294 | <button class="btn" type="button" :disabled="cmdBusy()" @click="askAppCmd()">Run</button> | |
| 295 | </div> | |
| 296 | <div class="op-confirm" @show="cmdPending() !== null" style="margin-top:14px"> | |
| 297 | <span>Type <b class="mono">run</b> to execute <b class="mono">{{ cmdPending() }}</b> against production:</span> | |
| 298 | <input class="op-confirm-input" :value="cmdTyped()" @input="cmdTyped.set($event.target.value)" @keydown.enter="runAppCmd()" placeholder="confirm" autocomplete="off" aria-label="Type run to confirm executing this command"> | |
| 299 | <button type="button" class="btn danger sm" :disabled="!cmdCanRun()" @click="runAppCmd()">Run command</button> | |
| 300 | <button type="button" class="btn ghost sm" @click="cancelAppCmd()">Cancel</button> | |
| 278 | 301 | </div> |
| 279 | 302 | <pre class="action-output" @show="cmdShown()">{{ cmdOut() }}</pre> |
| 280 | 303 | <p class="note">Invokes the app command through the CLI Lambda function (RequestResponse), the same path as <span class="mono">cloud command "..."</span>. Output is clamped.</p> |