Commit e2dec5d9ff

e2dec5d9ff2cd5dd54f68adec4190d8bafeaf302

parent: 5b3973e138

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-12 03:57 UTC

web: type the name to confirm a destructive control

Release delete, snippet delete and file remove, team delete, label
remove, and SSH key, email and PGP key removal ask for the object's
name in a text field; the handler refuses a mismatch with a flash.
Reversible controls keep a plain button.

Ref #182
docs/specs/2026-09-11-web-ux-sweep-design.md +3 −2
@@ -12,8 +12,9 @@ in, and the rules chosen to fix them.
1212 No JavaScript: the instance CSP is `script-src 'none'`. Covered: release
1313 delete (the tag), snippet delete (the id), snippet file remove (the file
1414 name), team delete (the team name), label remove (the label), SSH key
15 remove (the key's label), email remove (the address), PGP key remove
16 (the first 8 characters of the fingerprint). Reversible state keeps a
15 remove (the 8 characters after `SHA256:` in the fingerprint; a label
16 can be empty), email remove (the address), PGP key remove (the first
17 8 characters of the fingerprint). Reversible state keeps a
1718 plain button: close/reopen, merge, protect/unprotect, attach/detach,
1819 resolve, cancel, make primary, org member remove.
1920- **Refusal wording.** Control-command messages a web form can trigger
e2e/accountweb_test.go +14 −2
@@ -72,10 +72,22 @@ func TestAccountSettingsWeb(t *testing.T) {
7272 t.Error("git-scoped key ran a control command")
7373 }
7474
75 // Removing it through the form removes it for SSH too.
75 // Removing it through the form needs the fingerprint's prefix typed
76 // to confirm; a bare post leaves the key in place.
7677 fp := gitScopedFingerprint(t, out)
77 if status, _ := browserPost(t, browser, inst.base()+"/settings", url.Values{
78 prefix := strings.TrimPrefix(fp, "SHA256:")[:8]
79 _, body = browserPost(t, browser, inst.base()+"/settings", url.Values{
7880 "field": {"key-remove"}, "fingerprint": {fp},
81 })
82 if !strings.Contains(body, "to confirm") {
83 t.Fatalf("unconfirmed key remove was not refused:\n%s", body)
84 }
85 out, _, _ = inst.ssh(t, aliceKey, "", "keys", "list", "--json")
86 if !strings.Contains(out, fp) {
87 t.Fatalf("key removed without confirmation: %s", out)
88 }
89 if status, _ := browserPost(t, browser, inst.base()+"/settings", url.Values{
90 "field": {"key-remove"}, "fingerprint": {fp}, "confirm": {prefix},
7991 }); status != 303 && status != 200 {
8092 t.Fatalf("key remove: %d", status)
8193 }
e2e/labelweb_test.go +11 −2
@@ -51,9 +51,18 @@ func TestLabelsWeb(t *testing.T) {
5151 t.Errorf("bad colour accepted:\n%s", body)
5252 }
5353
54 // Removing takes the label off the issue too.
54 // Removing a label needs its name typed; a bare post is refused and
55 // the label stays.
56 _, body = browserPost(t, alice, base+"/labels", url.Values{
57 "action": {"remove"}, "name": {"bug"}})
58 if !strings.Contains(body, "type bug to confirm") {
59 t.Fatalf("unconfirmed remove was not refused:\n%s", body)
60 }
61 if out, _, _ := inst.ssh(t, aliceKey, "", "label", "list", "alice/app", "--json"); !strings.Contains(out, `"name":"bug"`) {
62 t.Fatalf("label removed without confirmation: %s", out)
63 }
5564 if status, _ := browserPost(t, alice, base+"/labels", url.Values{
56 "action": {"remove"}, "name": {"bug"}}); status != 200 {
65 "action": {"remove"}, "name": {"bug"}, "confirm": {"bug"}}); status != 200 {
5766 t.Fatal("label remove failed")
5867 }
5968 if out, _, _ := inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "1", "--json"); strings.Contains(out, `"bug"`) {
e2e/orgweb_test.go +19
@@ -85,6 +85,25 @@ func TestOrgManagementWeb(t *testing.T) {
8585 browserPost(t, alice, inst.base()+"/acme", url.Values{
8686 "field": {"team-revoke"}, "team": {"builders"}, "repo": {"acme/widget"},
8787 })
88
89 // Deleting the team needs its name typed; a bare post is refused and
90 // the team stays.
91 _, body = browserPost(t, alice, inst.base()+"/acme", url.Values{
92 "field": {"team-delete"}, "team": {"builders"},
93 })
94 if !strings.Contains(body, "type builders to confirm") {
95 t.Fatalf("unconfirmed team delete was not refused:\n%s", body)
96 }
97 if _, _, code := inst.ssh(t, aliceKey, "", "org", "team", "show", "acme", "builders", "--json"); code != 0 {
98 t.Fatal("team deleted without confirmation")
99 }
100 browserPost(t, alice, inst.base()+"/acme", url.Values{
101 "field": {"team-delete"}, "team": {"builders"}, "confirm": {"builders"},
102 })
103 if _, _, code := inst.ssh(t, aliceKey, "", "org", "team", "show", "acme", "builders", "--json"); code != 3 {
104 t.Fatalf("team not deleted: exit %d", code)
105 }
106
88107 browserPost(t, alice, inst.base()+"/acme", url.Values{
89108 "field": {"member-remove"}, "user": {"bob"},
90109 })
e2e/releaseweb_test.go +10 −1
@@ -98,8 +98,17 @@ func TestReleaseAndBuildWeb(t *testing.T) {
9898 if _, p := browserGet(t, alice, base+"/releases"); !strings.Contains(p, "Delete release") {
9999 t.Fatalf("owner is not offered the delete control:\n%s", p)
100100 }
101 _, body := browserPost(t, alice, base+"/releases", url.Values{
102 "action": {"delete"}, "tag": {"v1.0"}})
103 if !strings.Contains(body, "type v1.0 to confirm") {
104 t.Fatalf("unconfirmed delete was not refused:\n%s", body)
105 }
106 out, _, _ = inst.ssh(t, aliceKey, "", "release", "list", "alice/app", "--json")
107 if !strings.Contains(out, "v1.0") {
108 t.Fatalf("release removed without confirmation: %s", out)
109 }
101110 if status, _ := browserPost(t, alice, base+"/releases", url.Values{
102 "action": {"delete"}, "tag": {"v1.0"}}); status != 200 {
111 "action": {"delete"}, "tag": {"v1.0"}, "confirm": {"v1.0"}}); status != 200 {
103112 t.Fatal("release delete failed")
104113 }
105114 out, _, _ = inst.ssh(t, aliceKey, "", "release", "list", "alice/app", "--json")
e2e/snippetweb_test.go +14 −3
@@ -138,14 +138,16 @@ func TestSnippetsWeb(t *testing.T) {
138138 if status, _ := browserPost(t, alice, page+"/file", url.Values{"name": {"b.txt"}, "content": {"b\n"}}); status != 200 {
139139 t.Fatal("file add failed")
140140 }
141 if status, _ := browserPost(t, alice, page+"/file/remove", url.Values{"name": {"b.txt"}}); status != 200 {
141 if status, _ := browserPost(t, alice, page+"/file/remove", url.Values{"name": {"b.txt"}, "confirm": {"b.txt"}}); status != 200 {
142142 t.Fatal("file remove failed")
143143 }
144144 if _, _, code := inst.ssh(t, aliceKey, "", "snippet", "file", "get", created, "b.txt"); code != 3 {
145145 t.Fatalf("b.txt after web remove: exit %d", code)
146146 }
147147 // A refusal comes back on the page as a message, not a bare error.
148 _, body = browserPost(t, alice, page+"/file/remove", url.Values{"name": {"notes.md"}})
148 // The confirmation matches, so the refusal under test is still the
149 // command's last-file rule.
150 _, body = browserPost(t, alice, page+"/file/remove", url.Values{"name": {"notes.md"}, "confirm": {"notes.md"}})
149151 if !strings.Contains(body, `class="error"`) || !strings.Contains(body, "at least one file") {
150152 t.Fatalf("last-file refusal on the page:\n%s", body)
151153 }
@@ -170,7 +172,16 @@ func TestSnippetsWeb(t *testing.T) {
170172 if status, _ := browserPost(t, bob, inst.base()+"/alice/-/snippets/"+public+"/edit", url.Values{"description": {"x"}, "visibility": {"public"}}); status != 403 {
171173 t.Fatalf("bob editing alice's snippet: %d", status)
172174 }
173 if status, _ := browserPost(t, alice, page+"/delete", nil); status != 200 {
175 // Deleting needs the public id typed to confirm; a bare post leaves
176 // the snippet in place.
177 _, body = browserPost(t, alice, page+"/delete", nil)
178 if !strings.Contains(body, "type "+created+" to confirm") {
179 t.Fatalf("unconfirmed delete was not refused:\n%s", body)
180 }
181 if _, _, code := inst.ssh(t, aliceKey, "", "snippet", "show", created); code != 0 {
182 t.Fatalf("snippet deleted without confirmation: exit %d", code)
183 }
184 if status, _ := browserPost(t, alice, page+"/delete", url.Values{"confirm": {created}}); status != 200 {
174185 t.Fatal("delete failed")
175186 }
176187 if _, _, code := inst.ssh(t, aliceKey, "", "snippet", "show", created); code != 3 {
internal/httpd/account.go +36 −4
@@ -20,6 +20,7 @@ type accountKey struct {
2020 Algo string
2121 Scope string
2222 Label string
23 Confirm string // the 8 characters after SHA256: — a label can be empty
2324}
2425
2526type accountPGP struct {
@@ -27,6 +28,7 @@ type accountPGP struct {
2728 UIDs []string
2829 Expired bool
2930 Revoked bool
31 Confirm string // the fingerprint's first 8 characters
3032}
3133
3234// accountForm renders the account's own settings: keys, addresses, and the
@@ -35,7 +37,11 @@ func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.Use
3537 var keys []accountKey
3638 if list, err := s.st.ListSSHKeys(u.ID); err == nil {
3739 for _, k := range list {
38 keys = append(keys, accountKey{Fingerprint: k.Fingerprint, Algo: k.Algo, Scope: k.Scope, Label: k.Label})
40 confirm := strings.TrimPrefix(k.Fingerprint, "SHA256:")
41 if len(confirm) > 8 {
42 confirm = confirm[:8]
43 }
44 keys = append(keys, accountKey{Fingerprint: k.Fingerprint, Algo: k.Algo, Scope: k.Scope, Label: k.Label, Confirm: confirm})
3945 }
4046 }
4147 var pgp []accountPGP
@@ -43,9 +49,13 @@ func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.Use
4349 for _, k := range list {
4450 var uids []string
4551 json.Unmarshal([]byte(k.UIDsJSON), &uids)
52 confirm := k.Fingerprint
53 if len(confirm) > 8 {
54 confirm = confirm[:8]
55 }
4656 pgp = append(pgp, accountPGP{
4757 Fingerprint: k.Fingerprint, UIDs: uids,
48 Expired: k.ExpiresAt != nil, Revoked: k.RevokedAt != nil,
58 Expired: k.ExpiresAt != nil, Revoked: k.RevokedAt != nil, Confirm: confirm,
4959 })
5060 }
5161 }
@@ -153,6 +163,14 @@ func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.U
153163 }
154164 back("", "key registered")
155165 case "key-remove":
166 want := strings.TrimPrefix(r.FormValue("fingerprint"), "SHA256:")
167 if len(want) > 8 {
168 want = want[:8]
169 }
170 if ok, msg := confirmed(r, want); !ok {
171 back(msg, "")
172 return
173 }
156174 if _, msg, ok := s.runControl(u, []string{"keys", "remove", r.FormValue("fingerprint")}); !ok {
157175 back(msg, "")
158176 return
@@ -170,7 +188,16 @@ func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.U
170188 }
171189 back("", "PGP key registered")
172190 case "pgp-remove":
173 if _, msg, ok := s.runControl(u, []string{"pgp", "remove", r.FormValue("fingerprint")}); !ok {
191 fp := r.FormValue("fingerprint")
192 want := fp
193 if len(want) > 8 {
194 want = want[:8]
195 }
196 if ok, msg := confirmed(r, want); !ok {
197 back(msg, "")
198 return
199 }
200 if _, msg, ok := s.runControl(u, []string{"pgp", "remove", fp}); !ok {
174201 back(msg, "")
175202 return
176203 }
@@ -188,7 +215,12 @@ func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.U
188215 }
189216 back("", "address verified")
190217 case "email-remove":
191 if _, msg, ok := s.runControl(u, []string{"email", "remove", r.FormValue("address")}); !ok {
218 address := r.FormValue("address")
219 if ok, msg := confirmed(r, address); !ok {
220 back(msg, "")
221 return
222 }
223 if _, msg, ok := s.runControl(u, []string{"email", "remove", address}); !ok {
192224 back(msg, "")
193225 return
194226 }
internal/httpd/confirm.go added +17
@@ -0,0 +1,17 @@
1package httpd
2
3import (
4 "net/http"
5 "strings"
6)
7
8// confirmed reports whether the form typed want into its confirm field.
9// It guards controls that destroy data nothing else holds; the person
10// is already authorised, so this is a check against a slip, not a
11// permission.
12func confirmed(r *http.Request, want string) (bool, string) {
13 if strings.TrimSpace(r.FormValue("confirm")) == want {
14 return true, ""
15 }
16 return false, "type " + want + " to confirm"
17}
internal/httpd/labels.go +4
@@ -49,6 +49,10 @@ func (s *Server) labelSubmit(w http.ResponseWriter, r *http.Request, u store.Use
4949 }
5050 argv := []string{"label", "set", repo, name, "--color", strings.TrimSpace(r.FormValue("color"))}
5151 if r.FormValue("action") == "remove" {
52 if ok, msg := confirmed(r, name); !ok {
53 s.backTo(w, r, "labels", msg)
54 return
55 }
5256 argv = []string{"label", "remove", repo, name}
5357 }
5458 _, msg, code := s.runControlCode(u, argv)
internal/httpd/orgweb.go +4
@@ -80,6 +80,10 @@ func (s *Server) orgSubmit(w http.ResponseWriter, r *http.Request, u store.User)
8080 case "team-create":
8181 argv = []string{"org", "team", "create", owner, team}
8282 case "team-delete":
83 if ok, msg := confirmed(r, team); !ok {
84 back(msg)
85 return
86 }
8387 argv = []string{"org", "team", "delete", owner, team}
8488 case "team-add":
8589 argv = append([]string{"org", "team", "add", owner, team}, strings.Fields(user)...)
internal/httpd/releaseactions.go +6 −3
@@ -28,10 +28,13 @@ func (s *Server) releaseSubmit(w http.ResponseWriter, r *http.Request, u store.U
2828 return
2929 }
3030 back := func(w http.ResponseWriter, r *http.Request, msg string) { s.backTo(w, r, "releases", msg) }
31 // The CLI's --yes guards against a mistyped tag; here the tag comes from
32 // the page and the button sits behind a disclosure, so the click is the
33 // deliberate act.
31 // The CLI's --yes guards against a mistyped tag; here the tag comes
32 // from the page, so the browser's own confirm field stands in.
3433 if r.FormValue("action") == "delete" {
34 if ok, msg := confirmed(r, tag); !ok {
35 back(w, r, msg)
36 return
37 }
3538 _, msg, code := s.runControlCode(u, []string{"release", "delete", repo, tag, "--yes"})
3639 s.done(w, r, code, msg, back)
3740 return
internal/httpd/snippets.go +14 −2
@@ -213,7 +213,13 @@ func (s *Server) snippetEditSubmit(w http.ResponseWriter, r *http.Request, u sto
213213}
214214
215215func (s *Server) snippetDeleteSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
216 s.snippetAction(w, r, u, []string{"snippet", "delete", r.PathValue("id")}, "",
216 id := r.PathValue("id")
217 if ok, msg := confirmed(r, id); !ok {
218 s.setFlash(w, msg)
219 http.Redirect(w, r, "/"+r.PathValue("owner")+"/-/snippets/"+id, http.StatusSeeOther)
220 return
221 }
222 s.snippetAction(w, r, u, []string{"snippet", "delete", id}, "",
217223 "/"+r.PathValue("owner")+"/-/snippets")
218224}
219225
@@ -225,5 +231,11 @@ func (s *Server) snippetFileSubmit(w http.ResponseWriter, r *http.Request, u sto
225231}
226232
227233func (s *Server) snippetFileRemoveSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
228 s.snippetAction(w, r, u, []string{"snippet", "file", "remove", r.PathValue("id"), strings.TrimSpace(r.FormValue("name"))}, "", "")
234 name := strings.TrimSpace(r.FormValue("name"))
235 if ok, msg := confirmed(r, name); !ok {
236 s.setFlash(w, msg)
237 http.Redirect(w, r, "/"+r.PathValue("owner")+"/-/snippets/"+r.PathValue("id"), http.StatusSeeOther)
238 return
239 }
240 s.snippetAction(w, r, u, []string{"snippet", "file", "remove", r.PathValue("id"), name}, "", "")
229241}
internal/web/static/style.css +1
@@ -367,6 +367,7 @@ button.btn {
367367button.btn:hover { border-color: var(--accent); color: var(--accent); }
368368button.btn[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); }
369369form.inline { display: inline; }
370input[name="confirm"] { width: auto; margin-right: var(--sp-2); }
370371
371372nav.tabs {
372373 display: flex;
internal/web/templates/account.html +3 −3
@@ -31,7 +31,7 @@ a CI checkout wants.</p>
3131 <td class="mono">{{.Fingerprint}}</td>
3232 <td>{{.Algo}}</td>
3333 <td>{{.Scope}}</td>
34 <td class="act"><form method="post" action="/settings"><input type="hidden" name="field" value="key-remove"><input type="hidden" name="fingerprint" value="{{.Fingerprint}}"><button type="submit" class="linklike">Remove</button></form></td>
34 <td class="act"><form method="post" action="/settings"><input type="hidden" name="field" value="key-remove"><input type="hidden" name="fingerprint" value="{{.Fingerprint}}">{{template "confirmfield" .Confirm}} <button type="submit" class="linklike">Remove</button></form></td>
3535</tr>
3636{{end}}</table></div>
3737{{else}}<p class="none">No SSH keys — which cannot be right, since you signed in.</p>{{end}}
@@ -61,7 +61,7 @@ account, and where notifications go.</p>
6161 {{if .Verified}}<span class="badge badge-verified">verified{{with .VerifiedBy}} · {{.}}{{end}}</span>
6262 {{else}}<span class="badge badge-unsigned">unverified</span>{{end}}
6363 {{if not .Primary}}{{if .Verified}}<form method="post" action="/settings" class="inline"><input type="hidden" name="field" value="email-primary"><input type="hidden" name="address" value="{{.Address}}"><button type="submit" class="linklike">Make primary</button></form>{{end}}
64 <form method="post" action="/settings" class="inline"><input type="hidden" name="field" value="email-remove"><input type="hidden" name="address" value="{{.Address}}"><button type="submit" class="linklike">Remove</button></form>{{end}}</li>
64 <form method="post" action="/settings" class="inline"><input type="hidden" name="field" value="email-remove"><input type="hidden" name="address" value="{{.Address}}">{{template "confirmfield" .Address}} <button type="submit" class="linklike">Remove</button></form>{{end}}</li>
6565{{end}}</ul>{{end}}
6666<details class="editbox">
6767 <summary>Add an address</summary>
@@ -87,7 +87,7 @@ account, and where notifications go.</p>
8787{{range .PGP}}<tr>
8888 <td class="mono">{{.Fingerprint}}</td>
8989 <td>{{range .UIDs}}{{.}}<br>{{end}}{{if .Revoked}}<span class="badge badge-bad_signature">revoked</span>{{else if .Expired}}<span class="badge badge-signed_key_expired">expired</span>{{end}}</td>
90 <td class="act"><form method="post" action="/settings"><input type="hidden" name="field" value="pgp-remove"><input type="hidden" name="fingerprint" value="{{.Fingerprint}}"><button type="submit" class="linklike">Remove</button></form></td>
90 <td class="act"><form method="post" action="/settings"><input type="hidden" name="field" value="pgp-remove"><input type="hidden" name="fingerprint" value="{{.Fingerprint}}">{{template "confirmfield" .Confirm}} <button type="submit" class="linklike">Remove</button></form></td>
9191</tr>
9292{{end}}</table></div>
9393{{else}}<p class="none">No OpenPGP keys.</p>{{end}}
internal/web/templates/labels.html +1
@@ -15,6 +15,7 @@
1515 <td class="act">{{if and $.CanWrite (not .Org)}}<form method="post" action="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/labels" class="inline">
1616 <input type="hidden" name="action" value="remove">
1717 <input type="hidden" name="name" value="{{.Name}}">
18 {{template "confirmfield" .Name}}
1819 <button type="submit" class="linklike">Remove</button>
1920 </form>{{else if .Org}}<a href="/{{$.Repo.OwnerName}}/-/labels">org</a>{{end}}</td>
2021</tr>
internal/web/templates/layout.html +4
@@ -137,6 +137,10 @@
137137 </select>
138138</p>{{end}}
139139
140{{/* confirmfield is the typed-name check beside a destructive control.
141 The argument is the text the person must type. */}}
142{{define "confirmfield"}}<input type="text" name="confirm" aria-label="Type {{.}} to confirm" placeholder="type {{.}} to confirm" size="{{len .}}" autocomplete="off">{{end}}
143
140144{{/* difffiles renders a parsed diff: one foldable section per file, with
141145 line-number gutters and review threads inline. Base is the MR's
142146 endpoint and Viewer the signed-in account; the commit page passes
internal/web/templates/owner.html +1
@@ -98,6 +98,7 @@ of a team get its role on every repository it is granted.</p>
9898 <form method="post" action="/{{$org}}" class="setform">
9999 <input type="hidden" name="field" value="team-delete">
100100 <input type="hidden" name="team" value="{{.Name}}">
101 {{template "confirmfield" .Name}}
101102 <button type="submit" class="linklike">Delete this team</button>
102103 </form>
103104 </div>
internal/web/templates/releases.html +1 −1
@@ -36,7 +36,7 @@
3636 <input type="hidden" name="action" value="delete">
3737 <input type="hidden" name="tag" value="{{$rel.Tag}}">
3838 <p class="meta">Deleting is permanent and takes the assets with it. The tag stays.</p>
39 <p><button type="submit" class="linklike">Delete release</button></p>
39 <p>{{template "confirmfield" $rel.Tag}} <button type="submit" class="linklike">Delete release</button></p>
4040 </form>{{end}}</details>{{end}}
4141 {{if $rel.Assets}}<table class="assets">
4242 <thead><tr><th scope="col">File</th><th scope="col">Bytes</th><th scope="col">SHA-256</th></tr></thead>
internal/web/templates/snippet.html +2 −2
@@ -21,7 +21,7 @@
2121</form>
2222<form method="post" action="/{{$.Owner}}/-/snippets/{{$.Snippet.PublicID}}/file/remove">
2323<input type="hidden" name="name" value="{{.Name}}">
24<p><button type="submit">Remove {{.Name}}</button></p>
24<p>{{template "confirmfield" .Name}} <button type="submit">Remove {{.Name}}</button></p>
2525</form>
2626</details>{{end}}
2727</section>
@@ -44,7 +44,7 @@
4444<p><button type="submit">Save</button></p>
4545</form>
4646<form method="post" action="/{{.Owner}}/-/snippets/{{.Snippet.PublicID}}/delete">
47<p><button type="submit">Delete snippet</button></p>
47<p>{{template "confirmfield" .Snippet.PublicID}} <button type="submit">Delete snippet</button></p>
4848</form>
4949</details>
5050{{end}}