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