ReviewOS

also looking at this

stacks/ts-cloud

fix(dashboard): confirm destructive actions that ran on first click

#130
Merged glennmichael123 wants to merge fix/dashboard-destructive-confirms into main
4 files +99 -10
packages/ui/pages/server/actions.stxmodified+27-4
Changes to packages/ui/pages/server/actions.stx
@@ -39,12 +39,29 @@ const serverCmd = state('')
3939const cmdOut = state('')
4040const cmdShown = state(false)
4141const cmdBusy = state(false)
42async 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.
47const cmdPending = state(null)
48const cmdTyped = state('')
49const cmdCanRun = derived(() => cmdPending() !== null && cmdTyped().trim() === 'run')
50function askServerCmd() {
4351 const c = serverCmd().trim()
4452 if (!c) return
53 cmdPending.set(c); cmdTyped.set('')
54}
55function cancelServerCmd() { cmdPending.set(null); cmdTyped.set('') }
56
57async function runServerCmd() {
58 const c = cmdPending()
59 const confirm = cmdTyped().trim()
60 if (!c || confirm !== 'run') return
61 cmdPending.set(null); cmdTyped.set('')
4562 cmdBusy.set(true); cmdShown.set(true); cmdOut.set('Running ' + c + '...')
4663 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 }) })
4865 const b = await res.json()
4966 cmdOut.set((b.ok ? 'OK ' : 'FAILED ') + (b.command || c) + '\n\n' + (b.stdout || '') + (b.stderr ? '\n' + b.stderr : '') + (b.error ? '\n' + b.error : ''))
5067 } catch (e) { cmdOut.set('FAILED ' + c + '\n\n' + ((e && e.message) || e)) }
@@ -81,8 +98,14 @@ async function runServerCmd() {
8198 <h2>Run a command on the server</h2>
8299 <div class="panel">
83100 <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>
86109 </div>
87110 <pre class="action-output" @show="cmdShown()">{{ cmdOut() }}</pre>
88111 <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>
packages/ui/pages/server/ssh-keys.stxmodified+22-1
Changes to packages/ui/pages/server/ssh-keys.stx
@@ -38,6 +38,21 @@ async function removeKey(n) {
3838 keys.set(data.keys || [])
3939 setMessage('Removed from cloud config. Run cloud deploy to apply.', 'ok')
4040}
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.
44const pendingRemove = state(null)
45const typedRemove = state('')
46const canRemove = derived(() => { const k = pendingRemove(); return k !== null && typedRemove() === String(k.name) })
47function askRemove(key) { pendingRemove.set(key); typedRemove.set('') }
48function cancelRemove() { pendingRemove.set(null); typedRemove.set('') }
49async 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
4156onMount(() => { load() })
4257</script>
4358<!DOCTYPE html>
@@ -79,12 +94,18 @@ onMount(() => { load() })
7994 <td class="mono" style="color:var(--txt3)">{{ k.type }}</td>
8095 <td class="mono">{{ k.fingerprint }}</td>
8196 <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>
8398 </tr>
8499 </template>
85100 </tbody>
86101 </table>
87102 <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>
88109 <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>
89110 </div>
90111 </div>
packages/ui/pages/server/team.stxmodified+23-1
Changes to packages/ui/pages/server/team.stx
@@ -67,6 +67,22 @@ async function revoke(name) {
6767 load()
6868}
6969
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.
74const pendingRevoke = state(null)
75const typedRevoke = state('')
76const canRevoke = derived(() => { const u = pendingRevoke(); return u !== null && typedRevoke() === String(u.username) })
77function askRevoke(user) { pendingRevoke.set(user); typedRevoke.set('') }
78function cancelRevoke() { pendingRevoke.set(null); typedRevoke.set('') }
79async 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
7086onMount(() => { load() })
7187</script>
7288<!DOCTYPE html>
@@ -135,12 +151,18 @@ onMount(() => { load() })
135151 <td><b>{{ u.name }}</b><span class="mono uname">{{ u.username }}</span></td>
136152 <td><span class="tag">{{ u.role === 'admin' ? 'box owner' : 'member' }}</span></td>
137153 <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>
139155 </tr>
140156 </template>
141157 </tbody>
142158 </table>
143159 <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>
144166 <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>
145167 </div>
146168 </div>
packages/ui/pages/serverless.stxmodified+27-4
Changes to packages/ui/pages/serverless.stx
@@ -87,12 +87,29 @@ const appCmd = state('')
8787const cmdOut = state('')
8888const cmdShown = state(false)
8989const cmdBusy = state(false)
90async 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.
95const cmdPending = state(null)
96const cmdTyped = state('')
97const cmdCanRun = derived(() => cmdPending() !== null && cmdTyped().trim() === 'run')
98function askAppCmd() {
9199 const c = appCmd().trim()
92100 if (!c) return
101 cmdPending.set(c); cmdTyped.set('')
102}
103function cancelAppCmd() { cmdPending.set(null); cmdTyped.set('') }
104
105async function runAppCmd() {
106 const c = cmdPending()
107 const confirm = cmdTyped().trim()
108 if (!c || confirm !== 'run') return
109 cmdPending.set(null); cmdTyped.set('')
93110 cmdBusy.set(true); cmdShown.set(true); cmdOut.set('Running ' + c + '...')
94111 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 }) })
96113 const b = await res.json()
97114 cmdOut.set((b.ok ? 'OK ' : 'FAILED ') + (b.command || c) + '\n\n' + (b.stdout || '') + (b.error ? '\n' + b.error : ''))
98115 } catch (e) { cmdOut.set('FAILED ' + c + '\n\n' + ((e && e.message) || e)) }
@@ -273,8 +290,14 @@ async function runAppCmd() {
273290 <h2>Run a command</h2>
274291 <div class="panel">
275292 <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>
278301 </div>
279302 <pre class="action-output" @show="cmdShown()">{{ cmdOut() }}</pre>
280303 <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>