Commit 3a58c644d0
Verified · cmc
internal/httpd/account.go +28 −2
| @@ -31,6 +31,16 @@ type accountPGP struct { | ||
| 31 | 31 | Confirm string // the fingerprint's first 8 characters |
| 32 | 32 | } |
| 33 | 33 | |
| 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. | |
| 37 | type accountDevice struct { | |
| 38 | ID int64 | |
| 39 | Label string | |
| 40 | Token string | |
| 41 | LastSeenAt string | |
| 42 | } | |
| 43 | ||
| 34 | 44 | // accountForm renders the account's own settings: keys, addresses, and the |
| 35 | 45 | // commands for everything that stays on SSH. |
| 36 | 46 | func (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 | ||
| 64 | 74 | s.runControlInto(u, []string{"profile", "show"}, &profile) |
| 65 | 75 | mailOn, _ := s.st.MailEnabled(u.ID) |
| 66 | 76 | watchOn, _ := s.st.WatchEnabled(u.ID) |
| 77 | pushOn, _ := s.st.PushEnabled(u.ID) | |
| 67 | 78 | theme, _ := s.st.Theme(u.ID) |
| 68 | 79 | |
| 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 | ||
| 69 | 87 | // The about text is a file. The page points at it rather than editing |
| 70 | 88 | // it: the repository's own editor already does that job. |
| 71 | 89 | aboutRepo := u.Username + "/" + control.ProfileRepoName |
| @@ -89,10 +107,12 @@ func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, u store.Use | ||
| 89 | 107 | Message string |
| 90 | 108 | MailOn bool |
| 91 | 109 | WatchOn bool |
| 110 | PushOn bool | |
| 111 | Devices []accountDevice | |
| 92 | 112 | ThemeSetting string // system, light or dark: the form's selected option |
| 93 | 113 | }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links), |
| 94 | 114 | 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}) | |
| 96 | 116 | } |
| 97 | 117 | |
| 98 | 118 | // 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 | ||
| 243 | 263 | return |
| 244 | 264 | } |
| 245 | 265 | back("", "colour scheme saved") |
| 246 | case "notify-mail", "notify-watch": | |
| 266 | case "notify-mail", "notify-watch", "notify-push": | |
| 247 | 267 | pref := strings.TrimPrefix(r.FormValue("field"), "notify-") |
| 248 | 268 | state := "off" |
| 249 | 269 | if r.FormValue(pref) == "on" { |
| @@ -254,6 +274,12 @@ func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.U | ||
| 254 | 274 | return |
| 255 | 275 | } |
| 256 | 276 | 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") | |
| 257 | 283 | case "profile": |
| 258 | 284 | argv := []string{"profile", "set", |
| 259 | 285 | "--description", r.FormValue("description"), |
internal/httpd/account_test.go added +50
| @@ -0,0 +1,50 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 15 | func 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> | ||
| 144 | 144 | <button type="submit" class="btn">Save</button> |
| 145 | 145 | </form> |
| 146 | 146 | <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 | |
| 166 | form here to add one, since a browser cannot produce an APNs token.</p> | |
| 147 | 167 | </section> |
| 148 | 168 | |
| 149 | 169 | <section id="appearance"><h2>Appearance</h2> |