also looking at this
fix(dashboard): confirm destructive actions that ran on first click
#130
4 files
+99
-10
| @@ -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> |