web: account settings for keys and addresses !78

merged merged by cmc on 2026-08-26 05:00 UTC · krz/gitbay:account-settings into main

8 files changed, +426 −0

Layout: unified · split

e2e/accountweb_test.go added +116
@@ -0,0 +1,116 @@
1package e2e
2
3import (
4 "encoding/json"
5 "net/url"
6 "os"
7 "strings"
8 "testing"
9)
10
11// TestAccountSettingsWeb covers managing your own keys and addresses from a
12// browser session. Public keys are the only credential-shaped input the web
13// accepts; secrets and token minting stay on SSH.
14func TestAccountSettingsWeb(t *testing.T) {
15 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
16 aliceKey := inst.newKey(t, "alice")
17 inst.admin(t, "admin", "user", "create", "alice",
18 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
19
20 out, _, code := inst.ssh(t, aliceKey, "", "web", "login", "--json")
21 if code != 0 {
22 t.Fatal("web login failed")
23 }
24 var env struct {
25 Data struct {
26 URL string `json:"url"`
27 } `json:"data"`
28 }
29 json.Unmarshal([]byte(out), &env)
30 browser := newBrowser(t)
31 browserGet(t, browser, inst.base()+env.Data.URL[strings.Index(env.Data.URL, "/login"):])
32
33 status, body := browserGet(t, browser, inst.base()+"/settings")
34 if status != 200 {
35 t.Fatalf("account settings: %d", status)
36 }
37 // The key that signed us in is listed, and its address shows verified.
38 if !strings.Contains(body, "SHA256:") {
39 t.Error("no SSH key fingerprint listed")
40 }
41 if !strings.Contains(body, "alice@example.test") || !strings.Contains(body, "verified") {
42 t.Error("verified address not shown")
43 }
44
45 // Add a second key through the form, then confirm it over SSH — the
46 // web write must land in the same place the CLI reads.
47 second := inst.newKey(t, "alice2")
48 raw, err := os.ReadFile(second + ".pub")
49 if err != nil {
50 t.Fatal(err)
51 }
52 pub := string(raw)
53 if status, _ := browserPost(t, browser, inst.base()+"/settings", url.Values{
54 "field": {"key-add"}, "key": {pub}, "scope": {"git"},
55 }); status != 303 && status != 200 {
56 t.Fatalf("key add: %d", status)
57 }
58 out, _, _ = inst.ssh(t, aliceKey, "", "keys", "list", "--json")
59 if strings.Count(out, "SHA256:") != 2 || !strings.Contains(out, `"scope":"git"`) {
60 t.Fatalf("key not registered with its scope: %s", out)
61 }
62
63 // A git-scoped key can move git data but cannot run commands, so the
64 // scope the form set is really enforced.
65 if _, _, code := inst.ssh(t, second, "", "whoami"); code == 0 {
66 t.Error("git-scoped key ran a control command")
67 }
68
69 // Removing it through the form removes it for SSH too.
70 fp := gitScopedFingerprint(t, out)
71 if status, _ := browserPost(t, browser, inst.base()+"/settings", url.Values{
72 "field": {"key-remove"}, "fingerprint": {fp},
73 }); status != 303 && status != 200 {
74 t.Fatalf("key remove: %d", status)
75 }
76 out, _, _ = inst.ssh(t, aliceKey, "", "keys", "list", "--json")
77 if strings.Count(out, "SHA256:") != 1 {
78 t.Fatalf("key not removed: %s", out)
79 }
80
81 // Garbage is refused by the same validation the CLI uses, and says so.
82 // The redirect carries the message, so the followed page shows it.
83 _, body = browserPost(t, browser, inst.base()+"/settings", url.Values{
84 "field": {"key-add"}, "key": {"not a key"},
85 })
86 if !strings.Contains(body, `class="error"`) {
87 t.Error("invalid key accepted without an error")
88 }
89
90 // Token minting is SSHOnly and has no web form to reach it.
91 if strings.Contains(body, `value="token-mint"`) {
92 t.Error("token minting exposed on the web")
93 }
94}
95
96// gitScopedFingerprint pulls the fingerprint of the git-scoped key out of
97// "auth keys list --json".
98func gitScopedFingerprint(t *testing.T, blob string) string {
99 t.Helper()
100 var env struct {
101 Data []struct {
102 Fingerprint string `json:"fingerprint"`
103 Scope string `json:"scope"`
104 } `json:"data"`
105 }
106 if err := json.Unmarshal([]byte(blob), &env); err != nil {
107 t.Fatalf("keys list JSON: %v\n%s", err, blob)
108 }
109 for _, k := range env.Data {
110 if k.Scope == "git" {
111 return k.Fingerprint
112 }
113 }
114 t.Fatalf("no git-scoped key in %s", blob)
115 return ""
116}
internal/httpd/account.go added +143
@@ -0,0 +1,143 @@
1package httpd
2
3import (
4 "encoding/json"
5 "net/http"
6 "net/url"
7 "strings"
8
9 "gitbay.org/gitbay/internal/store"
10)
11
12// accountKey is one SSH key as the settings page shows it: enough to
13// recognise which key this is without printing the whole blob.
14type accountKey struct {
15 Fingerprint string
16 Algo string
17 Scope string
18 Comment string
19}
20
21type accountPGP struct {
22 Fingerprint string
23 UIDs []string
24 Expired bool
25 Revoked bool
26}
27
28// accountForm renders the account's own settings: keys, addresses, and the
29// commands for everything that stays on SSH.
30func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.User) {
31 var keys []accountKey
32 if list, err := s.st.ListSSHKeys(u.ID); err == nil {
33 for _, k := range list {
34 keys = append(keys, accountKey{
35 Fingerprint: k.Fingerprint, Algo: k.Algo, Scope: k.Scope,
36 Comment: keyComment(k.Blob),
37 })
38 }
39 }
40 var pgp []accountPGP
41 if list, err := s.st.ListPGPKeys(u.ID); err == nil {
42 for _, k := range list {
43 var uids []string
44 json.Unmarshal([]byte(k.UIDsJSON), &uids)
45 pgp = append(pgp, accountPGP{
46 Fingerprint: k.Fingerprint, UIDs: uids,
47 Expired: k.ExpiresAt != nil, Revoked: k.RevokedAt != nil,
48 })
49 }
50 }
51 emails, _ := s.st.ListEmails(u.ID)
52
53 s.render(w, "account.html", struct {
54 basePage
55 Keys []accountKey
56 PGP []accountPGP
57 Emails []store.Email
58 Host string
59 Notice string
60 Message string
61 }{s.baseFor(u), keys, pgp, emails, s.cfg.SiteHost(),
62 r.URL.Query().Get("e"), r.URL.Query().Get("m")})
63}
64
65// keyComment pulls the trailing comment off an authorized_keys blob, which
66// is how people tell their own keys apart.
67func keyComment(blob []byte) string {
68 f := strings.Fields(string(blob))
69 if len(f) < 3 {
70 return ""
71 }
72 return strings.Join(f[2:], " ")
73}
74
75// accountSubmit routes the account forms to their commands. Everything
76// here is a public key or an address — no secret is accepted over the web.
77func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
78 back := func(msg, note string) {
79 q := ""
80 switch {
81 case msg != "":
82 q = "?e=" + url.QueryEscape(msg)
83 case note != "":
84 q = "?m=" + url.QueryEscape(note)
85 }
86 http.Redirect(w, r, "/settings"+q, http.StatusSeeOther)
87 }
88
89 switch r.FormValue("field") {
90 case "key-add":
91 body := strings.TrimSpace(r.FormValue("key"))
92 if body == "" {
93 back("paste a public key in authorized_keys format", "")
94 return
95 }
96 argv := []string{"keys", "add"}
97 if scope := r.FormValue("scope"); scope == "git" {
98 argv = append(argv, "--scope", "git")
99 }
100 if msg, ok := s.runControlStdin(u, argv, body+"\n"); !ok {
101 back(msg, "")
102 return
103 }
104 back("", "key registered")
105 case "key-remove":
106 if _, msg, ok := s.runControl(u, []string{"keys", "remove", r.FormValue("fingerprint")}); !ok {
107 back(msg, "")
108 return
109 }
110 back("", "key removed")
111 case "pgp-add":
112 body := strings.TrimSpace(r.FormValue("key"))
113 if body == "" {
114 back("paste an armored OpenPGP public key", "")
115 return
116 }
117 if msg, ok := s.runControlStdin(u, []string{"pgp", "add"}, body+"\n"); !ok {
118 back(msg, "")
119 return
120 }
121 back("", "PGP key registered")
122 case "pgp-remove":
123 if _, msg, ok := s.runControl(u, []string{"pgp", "remove", r.FormValue("fingerprint")}); !ok {
124 back(msg, "")
125 return
126 }
127 back("", "PGP key removed")
128 case "email-add":
129 if _, msg, ok := s.runControl(u, []string{"email", "add", strings.TrimSpace(r.FormValue("address"))}); !ok {
130 back(msg, "")
131 return
132 }
133 back("", "check that inbox for a verification code")
134 case "email-verify":
135 if _, msg, ok := s.runControl(u, []string{"email", "verify", strings.TrimSpace(r.FormValue("code"))}); !ok {
136 back(msg, "")
137 return
138 }
139 back("", "address verified")
140 default:
141 back("unknown form", "")
142 }
143}
internal/httpd/control.go +26
@@ -39,6 +39,32 @@ func (s *Server) runControl(u store.User, argv []string) (out string, msg string
3939 return stdout.String(), m, code == protocol.ExitOK
4040}
4141
42// runControlStdin is runControl for the handful of commands whose input
43// arrives on stdin. Public keys are the only such input the web accepts:
44// they are not secret, and pasting one into a browser is how people who
45// have not set up the CLI get their first key registered. Secrets, tokens
46// and mirror credentials remain SSHOnly and are refused by the dispatcher.
47func (s *Server) runControlStdin(u store.User, argv []string, stdin string) (msg string, ok bool) {
48 var stdout, stderr bytes.Buffer
49 ctx := &control.Ctx{
50 User: u,
51 Source: "web",
52 Scope: "full",
53 Store: s.st,
54 Cfg: s.cfg,
55 Stdin: strings.NewReader(stdin),
56 Stdout: &stdout,
57 Stderr: &stderr,
58 ViaAPI: true,
59 }
60 code := control.Dispatch(ctx, argv)
61 m := strings.TrimSpace(stderr.String())
62 if m == "" {
63 m = strings.TrimSpace(stdout.String())
64 }
65 return m, code == protocol.ExitOK
66}
67
4268// runControlJSON runs a command in JSON mode and returns its data object.
4369// In JSON mode a failure is an envelope carrying the message rather than
4470// stderr text, so both paths are read from the same envelope.
internal/httpd/routes.go +3
@@ -88,6 +88,9 @@ func (s *Server) Routes() []Route {
8888 Route{Method: "POST", Pattern: "/logout", Mutating: true,
8989 Handler: s.checkOrigin(s.logout)},
9090 Route{Method: "GET", Pattern: "/new", Handler: s.requireUser(s.newRepoForm)},
91 Route{Method: "GET", Pattern: "/settings", Handler: s.requireUser(s.accountForm)},
92 Route{Method: "POST", Pattern: "/settings", Mutating: true,
93 Handler: s.checkOrigin(s.requireUser(s.accountSubmit))},
9194 )
9295 // Web signup fronts the same registration path as SSH register,
9396 // so it exists only when registration is open or invite.
internal/store/users.go +29
@@ -136,6 +136,35 @@ func (s *Store) UserEmailAddresses(userID int64) ([]string, error) {
136136 return out, rows.Err()
137137}
138138
139// Email is one address on an account, with the state the signature rules
140// and notification routing depend on.
141type Email struct {
142 Address string
143 Verified bool
144 VerifiedBy string // smtp | admin, empty when unverified
145 Primary bool
146}
147
148// ListEmails returns every address on the account with its state.
149func (s *Store) ListEmails(userID int64) ([]Email, error) {
150 rows, err := s.DB.Query(`SELECT address, verified_at IS NOT NULL,
151 COALESCE(verified_by, ''), is_primary
152 FROM emails WHERE user_id = ? ORDER BY is_primary DESC, address`, userID)
153 if err != nil {
154 return nil, err
155 }
156 defer rows.Close()
157 var out []Email
158 for rows.Next() {
159 var e Email
160 if err := rows.Scan(&e.Address, &e.Verified, &e.VerifiedBy, &e.Primary); err != nil {
161 return nil, err
162 }
163 out = append(out, e)
164 }
165 return out, rows.Err()
166}
167
139168// SetUserDisabled suspends or restores an account. Disabling also drops
140169// the user's web sessions; their keys and tokens stay registered but are
141170// refused at every entry point until re-enabled.
internal/web/static/style.css +17
@@ -269,6 +269,8 @@ ul.raillist.wide .owner { font-size: var(--fs-0); }
269269}
270270.railfoot a { color: var(--shell-fg); }
271271a.railuser { display: inline-flex; align-items: center; gap: var(--sp-2); }
272.railfoot .railsettings { color: var(--shell-muted); display: inline-flex; }
273.railfoot .railsettings:hover { color: var(--shell-fg); }
272274.railfoot .linklike {
273275 background: none; border: 0; padding: 0; cursor: pointer;
274276 color: var(--shell-muted); font: inherit; text-decoration: underline;
@@ -795,6 +797,21 @@ pre.message {
795797}
796798
797799/* repo facts: the counts a visitor sizes a project up with */
800/* account settings */
801table.keys td { padding: var(--sp-2) var(--sp-4) var(--sp-2) 0; }
802table.keys td.mono, .mono { font-family: var(--mono); font-size: var(--fs-1); overflow-wrap: anywhere; }
803table.keys td.act { text-align: right; }
804ul.plain { list-style: none; margin: 0 0 var(--sp-3); padding: 0; }
805ul.plain li { padding: var(--sp-1) 0; display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
806form.setform.stack { display: block; }
807form.setform.stack textarea, form.setform.stack select { width: 100%; max-width: 48rem; display: block; margin-bottom: var(--sp-2); }
808p.notice {
809 border-left: 3px solid var(--ok);
810 background: var(--hover);
811 padding: var(--sp-2) var(--sp-3);
812 margin: 0 0 var(--sp-4);
813}
814
798815.facts { margin: 0 0 var(--sp-4); }
799816.facts .counts {
800817 display: flex; flex-wrap: wrap; gap: var(--sp-1) var(--sp-4);
internal/web/templates/account.html added +91
@@ -0,0 +1,91 @@
1{{define "title"}}account settings{{end}}
2{{define "content"}}
3<h1>Account settings</h1>
4{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
5{{if .Message}}<p class="notice" role="status">{{.Message}}</p>{{end}}
6
7<h2>SSH keys</h2>
8<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
10a CI checkout wants.</p>
11{{if .Keys}}<div class="tablewrap"><table class="keys">
12<tr class="cols"><th scope="col">fingerprint</th><th scope="col">type</th><th scope="col">scope</th><th scope="col"></th></tr>
13{{range .Keys}}<tr>
14 <td class="mono">{{.Fingerprint}}{{with .Comment}}<br><span class="muted">{{.}}</span>{{end}}</td>
15 <td>{{.Algo}}</td>
16 <td>{{.Scope}}</td>
17 <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>
18</tr>
19{{end}}</table></div>
20{{else}}<p class="none">No SSH keys — which cannot be right, since you signed in.</p>{{end}}
21<details class="editbox">
22 <summary>Add an SSH key</summary>
23 <form method="post" action="/settings" class="setform stack">
24 <input type="hidden" name="field" value="key-add">
25 <label for="key">Public key</label>
26 <textarea id="key" name="key" rows="3" required placeholder="ssh-ed25519 AAAA... you@machine"></textarea>
27 <label for="scope">Scope</label>
28 <select id="scope" name="scope">
29 <option value="full">full — commands and git</option>
30 <option value="git">git — git transport only</option>
31 </select>
32 <button type="submit" class="primary">Add key</button>
33 </form>
34</details>
35
36<h2>Email addresses</h2>
37<p class="meta">A verified address is what ties your signed commits to this
38account, and where notifications go.</p>
39{{if .Emails}}<ul class="plain">
40{{range .Emails}}<li>{{.Address}}
41 {{if .Primary}}<span class="chip">primary</span>{{end}}
42 {{if .Verified}}<span class="badge badge-verified">verified{{with .VerifiedBy}} · {{.}}{{end}}</span>
43 {{else}}<span class="badge badge-unsigned">unverified</span>{{end}}</li>
44{{end}}</ul>{{end}}
45<details class="editbox">
46 <summary>Add an address</summary>
47 <form method="post" action="/settings" class="setform">
48 <input type="hidden" name="field" value="email-add">
49 <label for="address">Address</label>
50 <input type="email" id="address" name="address" required placeholder="you@example.org">
51 <button type="submit">Send code</button>
52 </form>
53 <form method="post" action="/settings" class="setform">
54 <input type="hidden" name="field" value="email-verify">
55 <label for="code">Verification code</label>
56 <input type="text" id="code" name="code" required placeholder="from the mail">
57 <button type="submit">Verify</button>
58 </form>
59</details>
60
61<h2>OpenPGP keys</h2>
62<p class="meta">Only needed if you sign commits with GPG. SSH signing
63(<code>gpg.format = ssh</code>) uses the keys above.</p>
64{{if .PGP}}<div class="tablewrap"><table class="keys">
65<tr class="cols"><th scope="col">fingerprint</th><th scope="col">identities</th><th scope="col"></th></tr>
66{{range .PGP}}<tr>
67 <td class="mono">{{.Fingerprint}}</td>
68 <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>
69 <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>
70</tr>
71{{end}}</table></div>
72{{else}}<p class="none">No OpenPGP keys.</p>{{end}}
73<details class="editbox">
74 <summary>Add a PGP key</summary>
75 <form method="post" action="/settings" class="setform stack">
76 <input type="hidden" name="field" value="pgp-add">
77 <label for="pgpkey">Armored public key</label>
78 <textarea id="pgpkey" name="key" rows="6" required placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----"></textarea>
79 <button type="submit" class="primary">Add key</button>
80 </form>
81</details>
82
83<h2>On SSH only</h2>
84<p class="meta">Anything whose input is a credential stays on the command line,
85where it can be piped instead of pasted:</p>
86<pre class="message">gitbay auth token mint --name laptop # API tokens
87gitbay auth export &gt; account.bundle # your account, portable
88gitbay admin ... # instance administration</pre>
89<p class="meta">All of the above works from stock OpenSSH too:
90<code>ssh git@{{.Host}} auth whoami</code>.</p>
91{{end}}
internal/web/templates/layout.html +1
@@ -41,6 +41,7 @@
4141 </div>
4242 <div class="railfoot">
4343 {{if .Viewer}}<a class="railuser" href="/{{.Viewer}}"><span class="avatar">{{initial .Viewer}}</span>{{.Viewer}}</a>
44 <a class="railsettings" href="/settings" title="Account settings" aria-label="Account settings"><svg class="icon" width="14" height="14" viewBox="0 0 16 16" aria-hidden="true" fill="currentColor"><path d="M8 4.754a3.246 3.246 0 1 0 0 6.492 3.246 3.246 0 0 0 0-6.492ZM6.254 8a1.746 1.746 0 1 1 3.492 0 1.746 1.746 0 0 1-3.492 0Z"/><path d="M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 0 1-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 0 1-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 0 1 .52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 0 1 1.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 0 1 1.255-.52l.292.16c1.64.893 3.434-.902 2.54-2.541l-.159-.292a.873.873 0 0 1 .52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 0 1-.52-1.255l.16-.292c.893-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 0 1-1.255-.52l-.094-.319Zm-2.153.425c.18-.613 1.048-.613 1.229 0l.093.319a2.373 2.373 0 0 0 3.416 1.416l.292-.16c.561-.306 1.175.308.87.87l-.16.292a2.373 2.373 0 0 0 1.415 3.416l.319.093c.613.18.613 1.048 0 1.229l-.319.093a2.373 2.373 0 0 0-1.416 3.416l.16.292c.305.561-.309 1.175-.87.87l-.292-.16a2.373 2.373 0 0 0-3.416 1.415l-.093.319c-.181.613-1.049.613-1.229 0l-.093-.319a2.373 2.373 0 0 0-3.416-1.416l-.292.16c-.561.305-1.175-.309-.87-.87l.16-.292a2.373 2.373 0 0 0-1.415-3.416l-.32-.093c-.612-.181-.612-1.049 0-1.229l.32-.093a2.373 2.373 0 0 0 1.416-3.416l-.16-.292c-.306-.561.308-1.175.87-.87l.292.16a2.373 2.373 0 0 0 3.416-1.416l.093-.318Z"/></svg></a>
4445 <form method="post" action="/logout"><button type="submit" class="linklike">Log out</button></form>
4546 {{else}}<a href="/login">Sign in</a>{{end}}
4647 </div>