Commit 9da25671b7

9da25671b7a83828140f2b94bc766383851576d9

parent: 378df4415e

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-05 19:02 UTC

web: profile set form on account settings

profile set carries description, website, about and links, and was
never offered as a form: it is not SSHOnly, and the JSON API already
runs it. The account settings page now has one form for the whole
command. Links are a textarea, one per line as "label|url" or a bare
url, matching --link's repeat-and-replace semantics; an empty field
clears rather than being skipped.

Closes #161

Layout: unified · split

e2e/profileweb_test.go added +110
@@ -0,0 +1,110 @@
1package e2e
2
3import (
4 "net/url"
5 "strings"
6 "testing"
7)
8
9// TestProfileSettingsWeb covers profile set from the account settings page
10// (#161): description, website, links and about round-trip through the
11// form, and emptying a field actually clears it rather than being skipped.
12func TestProfileSettingsWeb(t *testing.T) {
13 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
14 aliceKey := inst.newKey(t, "alice")
15 inst.admin(t, "admin", "user", "create", "alice",
16 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
17 alice := inst.login(t, aliceKey)
18 settingsURL := inst.base() + "/settings"
19
20 // The form is on the page, every input labelled.
21 _, body := browserGet(t, alice, settingsURL)
22 for _, want := range []string{
23 `<label for="p-description">`, `<label for="p-website">`,
24 `<label for="p-links">`, `<label for="p-about">`, `<label for="format">`,
25 } {
26 if !strings.Contains(body, want) {
27 t.Fatalf("profile form missing %q:\n%s", want, body)
28 }
29 }
30
31 // Setting every field lands where the CLI reads it.
32 status, _ := browserPost(t, alice, settingsURL, url.Values{
33 "field": {"profile"},
34 "description": {"builds small tools"},
35 "website": {"https://alice.example"},
36 "links": {"Mastodon|https://fosstodon.example/@alice\nhttps://alice.example/now"},
37 "about": {"hello there"},
38 "format": {"md"},
39 })
40 if status != 200 && status != 303 {
41 t.Fatalf("profile post: %d", status)
42 }
43 out, _, _ := inst.ssh(t, aliceKey, "", "profile", "show", "--json")
44 for _, want := range []string{
45 `"description":"builds small tools"`, `"website":"https://alice.example"`,
46 `"label":"Mastodon"`, `"url":"https://fosstodon.example/@alice"`,
47 `"url":"https://alice.example/now"`, `"about":"hello there"`,
48 } {
49 if !strings.Contains(out, want) {
50 t.Fatalf("profile set missing %q: %s", want, out)
51 }
52 }
53
54 // The page shows what was just saved.
55 _, body = browserGet(t, alice, settingsURL)
56 for _, want := range []string{
57 "builds small tools", "https://alice.example", "Mastodon|https://fosstodon.example/@alice",
58 "https://alice.example/now", "hello there",
59 } {
60 if !strings.Contains(body, want) {
61 t.Fatalf("settings page did not round-trip %q:\n%s", want, body)
62 }
63 }
64
65 // The whole form resubmits every time, the way a settings page does:
66 // each step below carries the fields already in place and changes one.
67 links := "Mastodon|https://fosstodon.example/@alice\nhttps://alice.example/now"
68
69 // The org choice is honoured on the profile page.
70 if status, _ := browserPost(t, alice, settingsURL, url.Values{
71 "field": {"profile"}, "description": {"builds small tools"},
72 "website": {"https://alice.example"}, "links": {links},
73 "about": {"a /note/ in org"}, "format": {"org"},
74 }); status != 200 && status != 303 {
75 t.Fatalf("profile post (org): %d", status)
76 }
77 if _, page := browserGet(t, alice, inst.base()+"/alice"); !strings.Contains(page, "<em>note</em>") {
78 t.Fatalf("about did not render as org:\n%s", page)
79 }
80
81 // Emptying the website clears it, not leaves it alone.
82 if status, _ := browserPost(t, alice, settingsURL, url.Values{
83 "field": {"profile"}, "description": {"builds small tools"},
84 "website": {""}, "links": {links}, "about": {"a /note/ in org"}, "format": {"org"},
85 }); status != 200 && status != 303 {
86 t.Fatalf("profile post (clear website): %d", status)
87 }
88 out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "--json")
89 if strings.Contains(out, "alice.example") && strings.Contains(out, `"website"`) {
90 t.Fatalf("website not cleared: %s", out)
91 }
92 if !strings.Contains(out, "builds small tools") {
93 t.Fatalf("clearing website clobbered the description: %s", out)
94 }
95 if !strings.Contains(out, `"label":"Mastodon"`) {
96 t.Fatalf("clearing website clobbered the links: %s", out)
97 }
98
99 // Emptying the links field clears the whole list.
100 if status, _ := browserPost(t, alice, settingsURL, url.Values{
101 "field": {"profile"}, "description": {"builds small tools"},
102 "links": {""}, "about": {"a /note/ in org"}, "format": {"org"},
103 }); status != 200 && status != 303 {
104 t.Fatalf("profile post (clear links): %d", status)
105 }
106 out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "--json")
107 if strings.Contains(out, "Mastodon") || strings.Contains(out, `"links"`) {
108 t.Fatalf("links not cleared: %s", out)
109 }
110}
internal/httpd/account.go +67 −10
@@ -6,6 +6,7 @@ import (
6 "net/url" 6 "net/url"
7 "strings" 7 "strings"
8 8
9 "gitbay.org/gitbay/internal/control"
9 "gitbay.org/gitbay/internal/store" 10 "gitbay.org/gitbay/internal/store"
10) 11)
11 12
@@ -46,21 +47,58 @@ func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.Use
46 } 47 }
47 emails, _ := s.st.ListEmails(u.ID) 48 emails, _ := s.st.ListEmails(u.ID)
48 49
50 var profile control.ProfileOut
51 s.runControlInto(u, []string{"profile", "show"}, &profile)
52
49 s.render(w, "account.html", struct { 53 s.render(w, "account.html", struct {
50 basePage 54 basePage
51 Tab string // marks the rail's Settings row as current 55 Tab string // marks the rail's Settings row as current
52 Keys []accountKey 56 Keys []accountKey
53 PGP []accountPGP 57 PGP []accountPGP
54 Emails []store.Email 58 Emails []store.Email
55 Host string 59 Profile control.ProfileOut
56 Notice string 60 LinksText string
57 Message string 61 Host string
58 }{s.baseFor(u), "account", keys, pgp, emails, s.cfg.SiteHost(), 62 Notice string
63 Message string
64 }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links), s.cfg.SiteHost(),
59 s.takeFlash(w, r), r.URL.Query().Get("m")}) 65 s.takeFlash(w, r), r.URL.Query().Get("m")})
60} 66}
61 67
62// accountSubmit routes the account forms to their commands. Everything 68// profileLinksText turns a profile's links into the form the textarea
63// here is a public key or an address — no secret is accepted over the web. 69// shows and reads back: one per line, "label|url" when there is a label
70// and the bare url otherwise.
71func profileLinksText(links []store.ProfileLink) string {
72 lines := make([]string, len(links))
73 for i, l := range links {
74 if l.Label != "" {
75 lines[i] = l.Label + "|" + l.URL
76 } else {
77 lines[i] = l.URL
78 }
79 }
80 return strings.Join(lines, "\n")
81}
82
83// profileLinkArgs turns the textarea back into the --link values profile
84// set expects: one per non-blank line, or a single empty one to clear the
85// list when the field was emptied.
86func profileLinkArgs(raw string) []string {
87 var links []string
88 for _, line := range strings.Split(raw, "\n") {
89 if line = strings.TrimSpace(line); line != "" {
90 links = append(links, line)
91 }
92 }
93 if links == nil {
94 return []string{""}
95 }
96 return links
97}
98
99// accountSubmit routes the account forms to their commands. Keys,
100// addresses and the profile are the whole surface — no secret is accepted
101// over the web.
64func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.User) { 102func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
65 back := func(msg, note string) { 103 back := func(msg, note string) {
66 q := "" 104 q := ""
@@ -122,6 +160,25 @@ func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.U
122 return 160 return
123 } 161 }
124 back("", "address verified") 162 back("", "address verified")
163 case "profile":
164 format := r.FormValue("format")
165 if format != "org" {
166 format = "md"
167 }
168 argv := []string{"profile", "set",
169 "--description", r.FormValue("description"),
170 "--website", r.FormValue("website"),
171 "--about-format", format,
172 "--file", "-",
173 }
174 for _, link := range profileLinkArgs(r.FormValue("links")) {
175 argv = append(argv, "--link", link)
176 }
177 if msg, ok := s.runControlStdin(u, argv, r.FormValue("about")); !ok {
178 back(msg, "")
179 return
180 }
181 back("", "profile updated")
125 default: 182 default:
126 back("unknown form", "") 183 back("unknown form", "")
127 } 184 }
internal/web/templates/account.html +16
@@ -4,6 +4,22 @@
4{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} 4{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
5{{if .Message}}<p class="notice" role="status">{{.Message}}</p>{{end}} 5{{if .Message}}<p class="notice" role="status">{{.Message}}</p>{{end}}
6 6
7<h2>Profile</h2>
8<p class="meta">Shown on your profile page, <a href="/{{.Viewer}}">/{{.Viewer}}</a>.</p>
9<form method="post" action="/settings" class="setform stack">
10 <input type="hidden" name="field" value="profile">
11 <label for="p-description">Description</label>
12 <input type="text" id="p-description" name="description" value="{{.Profile.Description}}" placeholder="one line, shown in listings">
13 <label for="p-website">Website</label>
14 <input type="text" id="p-website" name="website" value="{{.Profile.Website}}" placeholder="https://example.org">
15 <label for="p-links">Links</label>
16 <textarea id="p-links" name="links" rows="3" placeholder="one per line: label|https://... or a bare https://... (at most 5)">{{.LinksText}}</textarea>
17 <label for="p-about">About</label>
18 <textarea id="p-about" name="about" rows="8" placeholder="longer, shown below your repositories">{{.Profile.About}}</textarea>
19 {{template "formatpicker" .Profile.AboutFormat}}
20 <button type="submit">Save profile</button>
21</form>
22
7<h2>SSH keys</h2> 23<h2>SSH keys</h2>
8<p class="meta">Your keys are your identity here. A <code>full</code> key can run 24<p class="meta">Your keys are your identity here. A <code>full</code> key can run
9commands and push; a <code>git</code> key can only move git data, which is what 25commands and push; a <code>git</code> key can only move git data, which is what