Commit 3a58c644d0

3a58c644d02215af4867c0ef759947e6891be76a

parent: 90af4498e8

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-20 10:15 UTC

web: push toggle and device list on notification settings

No add-a-device form: a browser cannot produce an APNs token.

Ref #89
internal/httpd/account.go +28 −2
@@ -31,6 +31,16 @@ type accountPGP struct {
3131 Confirm string // the fingerprint's first 8 characters
3232}
3333
34// accountDevice is one registered APNs device as the settings page shows
35// it. Token is truncated to its first 8 characters: the full token is
36// device-identifying and never reaches the page.
37type accountDevice struct {
38 ID int64
39 Label string
40 Token string
41 LastSeenAt string
42}
43
3444// accountForm renders the account's own settings: keys, addresses, and the
3545// commands for everything that stays on SSH.
3646func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.User) {
@@ -64,8 +74,16 @@ func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, u store.Use
6474 s.runControlInto(u, []string{"profile", "show"}, &profile)
6575 mailOn, _ := s.st.MailEnabled(u.ID)
6676 watchOn, _ := s.st.WatchEnabled(u.ID)
77 pushOn, _ := s.st.PushEnabled(u.ID)
6778 theme, _ := s.st.Theme(u.ID)
6879
80 var devices []accountDevice
81 if list, err := s.st.PushDevices(u.ID); err == nil {
82 for _, d := range list {
83 devices = append(devices, accountDevice{ID: d.ID, Label: d.Label, Token: prefix8(d.Token), LastSeenAt: d.LastSeenAt})
84 }
85 }
86
6987 // The about text is a file. The page points at it rather than editing
7088 // it: the repository's own editor already does that job.
7189 aboutRepo := u.Username + "/" + control.ProfileRepoName
@@ -89,10 +107,12 @@ func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, u store.Use
89107 Message string
90108 MailOn bool
91109 WatchOn bool
110 PushOn bool
111 Devices []accountDevice
92112 ThemeSetting string // system, light or dark: the form's selected option
93113 }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links),
94114 aboutRepo, aboutEdit, s.cfg.SiteHost(),
95 s.takeFlash(w, r), r.URL.Query().Get("m"), mailOn, watchOn, theme})
115 s.takeFlash(w, r), r.URL.Query().Get("m"), mailOn, watchOn, pushOn, devices, theme})
96116}
97117
98118// accountExport hands the browser the same bundle `account export`
@@ -243,7 +263,7 @@ func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.U
243263 return
244264 }
245265 back("", "colour scheme saved")
246 case "notify-mail", "notify-watch":
266 case "notify-mail", "notify-watch", "notify-push":
247267 pref := strings.TrimPrefix(r.FormValue("field"), "notify-")
248268 state := "off"
249269 if r.FormValue(pref) == "on" {
@@ -254,6 +274,12 @@ func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.U
254274 return
255275 }
256276 back("", "notification preferences saved")
277 case "device-remove":
278 if _, msg, ok := s.runControl(u, []string{"notifications", "device", "remove", r.FormValue("id")}); !ok {
279 back(msg, "")
280 return
281 }
282 back("", "device removed")
257283 case "profile":
258284 argv := []string{"profile", "set",
259285 "--description", r.FormValue("description"),
internal/httpd/account_test.go added +50
@@ -0,0 +1,50 @@
1package httpd
2
3import (
4 "net/http/httptest"
5 "strings"
6 "testing"
7
8 "gitbay.org/gitbay/internal/config"
9 "gitbay.org/gitbay/internal/store"
10)
11
12// The settings page carries a push toggle beside the mail and watch ones,
13// and lists registered devices by label and truncated token. The full
14// token is device-identifying and must never reach the page.
15func TestAccountPagePushToggleAndDevices(t *testing.T) {
16 st, err := store.Open(":memory:")
17 if err != nil {
18 t.Fatal(err)
19 }
20 defer st.Close()
21 if err := st.MigrateUp(); err != nil {
22 t.Fatal(err)
23 }
24
25 uid, err := st.CreateUser("alice", false)
26 if err != nil {
27 t.Fatal(err)
28 }
29 token := strings.Repeat("a", 64)
30 if _, err := st.AddPushDevice(uid, token, "iphone"); err != nil {
31 t.Fatal(err)
32 }
33
34 s := New(config.Default(), st)
35 rr := httptest.NewRecorder()
36 req := httptest.NewRequest("GET", "/settings", nil)
37 s.accountPage(rr, req, store.User{ID: uid, Username: "alice"})
38
39 body := rr.Body.String()
40 if !strings.Contains(body, `value="notify-push"`) {
41 t.Fatal("no push toggle")
42 }
43 if !strings.Contains(body, "iphone") {
44 t.Fatal("the device is not listed")
45 }
46 // A token is device-identifying and is never printed in full.
47 if strings.Contains(body, token) {
48 t.Fatal("the page printed a device token in full")
49 }
50}
internal/web/templates/account.html +20
@@ -144,6 +144,26 @@ account and where notifications go.</p>
144144 <button type="submit" class="btn">Save</button>
145145</form>
146146<p class="meta">Alerts for every issue and merge request on those repositories. A watch or mute on a repository has priority over this setting.</p>
147<form method="post" action="/settings" class="setform">
148 <input type="hidden" name="field" value="notify-push">
149 <label for="notify-push">Activity on your registered devices</label>
150 <input type="checkbox" id="notify-push" name="push" value="on"{{if .PushOn}} checked{{end}}>
151 <button type="submit" class="btn">Save</button>
152</form>
153<p class="meta">Notification text is sent in full, including for private repositories, so a repository name and item number reach Apple and appear on a lock screen.</p>
154<h3>Devices</h3>
155{{if .Devices}}<div class="tablewrap"><table class="keys nowrap">
156<tr class="cols"><th scope="col">label</th><th scope="col">token</th><th scope="col">last seen</th><th scope="col"><span class="vh">actions</span></th></tr>
157{{range .Devices}}<tr>
158 <td>{{.Label}}</td>
159 <td class="mono">{{.Token}}</td>
160 <td>{{if .LastSeenAt}}{{when .LastSeenAt}}{{else}}never{{end}}</td>
161 <td class="act"><form method="post" action="/settings"><input type="hidden" name="field" value="device-remove"><input type="hidden" name="id" value="{{.ID}}"><button type="submit" class="danger">Remove</button></form></td>
162</tr>
163{{end}}</table></div>
164{{else}}<p class="none">No registered devices.</p>{{end}}
165<p class="meta">Devices register themselves from the iOS app; there is no
166form here to add one, since a browser cannot produce an APNs token.</p>
147167</section>
148168
149169<section id="appearance"><h2>Appearance</h2>