tenseleyflow/shithub / 42e74ce

Browse files

S10: Notification prefs CRUD via generic k/v jsonb (security_alerts, account_changes, product_news)

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
42e74ce5773766ce58663107bf04adf5f3ffb8dd
Parents
134f212
Tree
9738f49

6 changed files

StatusFile+-
M internal/web/handlers/auth/auth.go 2 0
M internal/web/handlers/auth/auth_test.go 24 22
A internal/web/handlers/auth/notifications.go 158 0
A internal/web/handlers/auth/notifications_test.go 91 0
M internal/web/static/css/shithub.css 16 0
A internal/web/templates/settings/notifications.html 33 0
internal/web/handlers/auth/auth.gomodified
@@ -145,6 +145,8 @@ func (h *Handlers) Mount(r chi.Router) {
145145
 			r.Post("/settings/emails/{id}/resend", h.settingsEmailsResend)
146146
 			r.Post("/settings/emails/{id}/primary", h.settingsEmailsSetPrimary)
147147
 			r.Post("/settings/emails/{id}/remove", h.settingsEmailsRemove)
148
+			r.Get("/settings/notifications", h.settingsNotificationsForm)
149
+			r.Post("/settings/notifications", h.settingsNotificationsSubmit)
148150
 			r.Get("/settings/keys", h.sshKeysList)
149151
 			r.Post("/settings/keys", h.sshKeysAdd)
150152
 			r.Post("/settings/keys/{id}/delete", h.sshKeysDelete)
internal/web/handlers/auth/auth_test.gomodified
@@ -174,30 +174,32 @@ func authTemplatesFS() fs.FS {
174174
 	pwTpl := `{{ define "page" }}<h1>Password</h1>{{ with .Error }}<p class=error>{{.}}</p>{{ end }}{{ with .Success }}<p class=notice>{{.}}</p>{{ end }}<form action="/settings/password" method=POST><input name=csrf_token value="{{.CSRFToken}}">RECENT={{.RecentAuthOK}};</form>{{ end }}`
175175
 	apprTpl := `{{ define "page" }}<h1>Appearance</h1>{{ with .Error }}<p class=error>{{.}}</p>{{ end }}{{ with .Success }}<p class=notice>{{.}}</p>{{ end }}<form action="/settings/appearance" method=POST><input name=csrf_token value="{{.CSRFToken}}">THEME={{.CurrentTheme}};</form>{{ end }}`
176176
 	emailsTpl := `{{ define "page" }}<h1>Emails</h1>{{ with .Error }}<p class=error>{{.}}</p>{{ end }}{{ with .Success }}<p class=notice>{{.}}</p>{{ end }}<form action="/settings/emails" method=POST><input name=csrf_token value="{{.CSRFToken}}"></form>EMAILS={{ range .Emails }}{{.ID}}:{{.Email}}:p={{.IsPrimary}}:v={{.Verified}};{{ end }}{{ end }}`
177
+	notifTpl := `{{ define "page" }}<h1>Notifications</h1>{{ with .Success }}<p class=notice>{{.}}</p>{{ end }}<form action="/settings/notifications" method=POST><input name=csrf_token value="{{.CSRFToken}}">CHANNELS={{ range .Channels }}{{.Key}}:e={{.Enabled}}:r={{.Required}};{{ end }}</form>{{ end }}`
177178
 	errorPage := `{{ define "page" }}<h1>{{.Status}} {{.StatusText}}</h1><p>{{.Message}}</p>{{ end }}`
178179
 	return fstest.MapFS{
179
-		"_layout.html":               {Data: []byte(layout)},
180
-		"hello.html":                 {Data: []byte(`{{ define "page" }}home{{ end }}`)},
181
-		"auth/signup.html":           {Data: []byte(signup)},
182
-		"auth/login.html":            {Data: []byte(login)},
183
-		"auth/reset_request.html":    {Data: []byte(resetReq)},
184
-		"auth/reset_confirm.html":    {Data: []byte(resetConf)},
185
-		"auth/verify_resend.html":    {Data: []byte(verifyResend)},
186
-		"auth/2fa_challenge.html":    {Data: []byte(tfaChallenge)},
187
-		"settings/2fa_enable.html":   {Data: []byte(tfaEnable)},
188
-		"settings/2fa_disable.html":  {Data: []byte(tfaDisable)},
189
-		"settings/2fa_recovery.html": {Data: []byte(tfaRecovery)},
190
-		"settings/keys.html":         {Data: []byte(keysTpl)},
191
-		"settings/tokens.html":       {Data: []byte(tokensTpl)},
192
-		"settings/profile.html":      {Data: []byte(profileTpl)},
193
-		"settings/account.html":      {Data: []byte(accountTpl)},
194
-		"settings/password.html":     {Data: []byte(pwTpl)},
195
-		"settings/appearance.html":   {Data: []byte(apprTpl)},
196
-		"settings/emails.html":       {Data: []byte(emailsTpl)},
197
-		"errors/404.html":            {Data: []byte(errorPage)},
198
-		"errors/403.html":            {Data: []byte(errorPage)},
199
-		"errors/429.html":            {Data: []byte(errorPage)},
200
-		"errors/500.html":            {Data: []byte(errorPage)},
180
+		"_layout.html":                {Data: []byte(layout)},
181
+		"hello.html":                  {Data: []byte(`{{ define "page" }}home{{ end }}`)},
182
+		"auth/signup.html":            {Data: []byte(signup)},
183
+		"auth/login.html":             {Data: []byte(login)},
184
+		"auth/reset_request.html":     {Data: []byte(resetReq)},
185
+		"auth/reset_confirm.html":     {Data: []byte(resetConf)},
186
+		"auth/verify_resend.html":     {Data: []byte(verifyResend)},
187
+		"auth/2fa_challenge.html":     {Data: []byte(tfaChallenge)},
188
+		"settings/2fa_enable.html":    {Data: []byte(tfaEnable)},
189
+		"settings/2fa_disable.html":   {Data: []byte(tfaDisable)},
190
+		"settings/2fa_recovery.html":  {Data: []byte(tfaRecovery)},
191
+		"settings/keys.html":          {Data: []byte(keysTpl)},
192
+		"settings/tokens.html":        {Data: []byte(tokensTpl)},
193
+		"settings/profile.html":       {Data: []byte(profileTpl)},
194
+		"settings/account.html":       {Data: []byte(accountTpl)},
195
+		"settings/password.html":      {Data: []byte(pwTpl)},
196
+		"settings/appearance.html":    {Data: []byte(apprTpl)},
197
+		"settings/emails.html":        {Data: []byte(emailsTpl)},
198
+		"settings/notifications.html": {Data: []byte(notifTpl)},
199
+		"errors/404.html":             {Data: []byte(errorPage)},
200
+		"errors/403.html":             {Data: []byte(errorPage)},
201
+		"errors/429.html":             {Data: []byte(errorPage)},
202
+		"errors/500.html":             {Data: []byte(errorPage)},
201203
 	}
202204
 }
203205
 
internal/web/handlers/auth/notifications.goadded
@@ -0,0 +1,158 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package auth
4
+
5
+import (
6
+	"encoding/json"
7
+	"net/http"
8
+
9
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
10
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
11
+)
12
+
13
+// notifChannel is one toggleable preference. Adding a channel is a code
14
+// change (this list) plus a translation, never a DB migration; the
15
+// underlying user_notification_prefs table is generic key/value.
16
+type notifChannel struct {
17
+	Key         string
18
+	Title       string
19
+	Description string
20
+	Required    bool // when true, displayed but not toggleable
21
+}
22
+
23
+// notifChannels is the fixed set surfaced on /settings/notifications.
24
+// Order matters — it's the render order. Required channels (security
25
+// alerts) are listed first so they're prominent.
26
+var notifChannels = []notifChannel{
27
+	{
28
+		Key:         "security_alerts",
29
+		Title:       "Security alerts",
30
+		Description: "New sign-ins, password changes, 2FA enrollment, recovery codes used. These cannot be disabled.",
31
+		Required:    true,
32
+	},
33
+	{
34
+		Key:         "account_changes",
35
+		Title:       "Account changes",
36
+		Description: "Username changes, primary-email switches, account-deletion confirmations.",
37
+	},
38
+	{
39
+		Key:         "product_news",
40
+		Title:       "Product news",
41
+		Description: "Occasional updates about new shithub features. Off by default.",
42
+	},
43
+}
44
+
45
+// notifChannelDefault returns the default state for a channel that has
46
+// no DB row. Account-related channels are opt-out (default on); marketing
47
+// channels are opt-in (default off).
48
+func notifChannelDefault(key string) bool {
49
+	switch key {
50
+	case "security_alerts", "account_changes":
51
+		return true
52
+	default:
53
+		return false
54
+	}
55
+}
56
+
57
+// notifPrefValue is the JSON shape we persist in user_notification_prefs.value.
58
+type notifPrefValue struct {
59
+	Enabled bool `json:"enabled"`
60
+}
61
+
62
+// settingsNotificationsForm renders GET /settings/notifications.
63
+func (h *Handlers) settingsNotificationsForm(w http.ResponseWriter, r *http.Request) {
64
+	h.renderNotificationsForm(w, r, "")
65
+}
66
+
67
+// settingsNotificationsSubmit handles POST /settings/notifications.
68
+//
69
+// Diff strategy:
70
+//   - For each non-required channel, read the form checkbox.
71
+//   - If desired matches default → DeleteUserNotificationPref (so the
72
+//     row only exists when the user has actively diverged).
73
+//   - Otherwise → UpsertUserNotificationPref with {"enabled":<desired>}.
74
+//
75
+// This keeps the table small and makes "reset to defaults" a no-op
76
+// rather than a code change.
77
+func (h *Handlers) settingsNotificationsSubmit(w http.ResponseWriter, r *http.Request) {
78
+	if err := r.ParseForm(); err != nil {
79
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
80
+		return
81
+	}
82
+	user := middleware.CurrentUserFromContext(r.Context())
83
+
84
+	for _, ch := range notifChannels {
85
+		if ch.Required {
86
+			continue
87
+		}
88
+		desired := r.PostFormValue(ch.Key) == "on"
89
+		if desired == notifChannelDefault(ch.Key) {
90
+			if err := h.q.DeleteUserNotificationPref(r.Context(), h.d.Pool, usersdb.DeleteUserNotificationPrefParams{
91
+				UserID: user.ID, Key: ch.Key,
92
+			}); err != nil {
93
+				h.d.Logger.ErrorContext(r.Context(), "notif: delete", "error", err, "key", ch.Key)
94
+				h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
95
+				return
96
+			}
97
+			continue
98
+		}
99
+		val, err := json.Marshal(notifPrefValue{Enabled: desired})
100
+		if err != nil {
101
+			h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
102
+			return
103
+		}
104
+		if err := h.q.UpsertUserNotificationPref(r.Context(), h.d.Pool, usersdb.UpsertUserNotificationPrefParams{
105
+			UserID: user.ID, Key: ch.Key, Value: val,
106
+		}); err != nil {
107
+			h.d.Logger.ErrorContext(r.Context(), "notif: upsert", "error", err, "key", ch.Key)
108
+			h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
109
+			return
110
+		}
111
+	}
112
+
113
+	h.renderNotificationsForm(w, r, "Preferences saved.")
114
+}
115
+
116
+// renderNotificationsForm pulls the current persisted state, layers it
117
+// on top of defaults, and renders the toggles.
118
+func (h *Handlers) renderNotificationsForm(w http.ResponseWriter, r *http.Request, successMsg string) {
119
+	user := middleware.CurrentUserFromContext(r.Context())
120
+	rows, err := h.q.ListUserNotificationPrefs(r.Context(), h.d.Pool, user.ID)
121
+	if err != nil {
122
+		h.d.Logger.ErrorContext(r.Context(), "notif: list", "error", err)
123
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
124
+		return
125
+	}
126
+	persisted := make(map[string]bool, len(rows))
127
+	for _, p := range rows {
128
+		var v notifPrefValue
129
+		if err := json.Unmarshal(p.Value, &v); err != nil {
130
+			continue
131
+		}
132
+		persisted[p.Key] = v.Enabled
133
+	}
134
+
135
+	type viewChan struct {
136
+		notifChannel
137
+		Enabled bool
138
+	}
139
+	view := make([]viewChan, 0, len(notifChannels))
140
+	for _, ch := range notifChannels {
141
+		enabled := notifChannelDefault(ch.Key)
142
+		if v, ok := persisted[ch.Key]; ok {
143
+			enabled = v
144
+		}
145
+		if ch.Required {
146
+			enabled = true
147
+		}
148
+		view = append(view, viewChan{notifChannel: ch, Enabled: enabled})
149
+	}
150
+
151
+	h.renderPage(w, r, "settings/notifications", map[string]any{
152
+		"Title":          "Notifications",
153
+		"CSRFToken":      middleware.CSRFTokenForRequest(r),
154
+		"SettingsActive": "notifications",
155
+		"Channels":       view,
156
+		"Success":        successMsg,
157
+	})
158
+}
internal/web/handlers/auth/notifications_test.goadded
@@ -0,0 +1,91 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package auth_test
4
+
5
+import (
6
+	"io"
7
+	"net/http"
8
+	"net/url"
9
+	"strings"
10
+	"testing"
11
+)
12
+
13
+func loginNotifUser(t *testing.T, name string) *client {
14
+	t.Helper()
15
+	httpsrv, captor := newTestServer(t, false)
16
+	cli := newClient(t, httpsrv)
17
+	mustSignup(t, cli, name, name+"@example.com", "correct horse battery staple")
18
+	tok := extractTokenFromMessage(t, captor.all()[0], "/verify-email")
19
+	_ = cli.get(t, "/verify-email/"+tok).Body.Close()
20
+
21
+	csrf := cli.extractCSRF(t, "/login")
22
+	resp := cli.post(t, "/login", url.Values{
23
+		"csrf_token": {csrf},
24
+		"username":   {name},
25
+		"password":   {"correct horse battery staple"},
26
+	})
27
+	if resp.StatusCode != http.StatusSeeOther {
28
+		t.Fatalf("login: %d", resp.StatusCode)
29
+	}
30
+	_ = resp.Body.Close()
31
+	return cli
32
+}
33
+
34
+func TestNotifications_DefaultsRender(t *testing.T) {
35
+	t.Parallel()
36
+	cli := loginNotifUser(t, "nfa")
37
+	resp := cli.get(t, "/settings/notifications")
38
+	defer func() { _ = resp.Body.Close() }()
39
+	body, _ := io.ReadAll(resp.Body)
40
+	// Required is always on. account_changes default-on. product_news default-off.
41
+	if !strings.Contains(string(body), "security_alerts:e=true:r=true") {
42
+		t.Errorf("security_alerts row wrong: %s", body)
43
+	}
44
+	if !strings.Contains(string(body), "account_changes:e=true:r=false") {
45
+		t.Errorf("account_changes default mismatch: %s", body)
46
+	}
47
+	if !strings.Contains(string(body), "product_news:e=false:r=false") {
48
+		t.Errorf("product_news default mismatch: %s", body)
49
+	}
50
+}
51
+
52
+func TestNotifications_OptOutThenOptIn(t *testing.T) {
53
+	t.Parallel()
54
+	cli := loginNotifUser(t, "nfb")
55
+
56
+	// Opt out of account_changes (no checkbox sent for it).
57
+	csrf := cli.extractCSRF(t, "/settings/notifications")
58
+	resp := cli.post(t, "/settings/notifications", url.Values{
59
+		"csrf_token": {csrf},
60
+		// account_changes intentionally omitted
61
+		"product_news": {"on"},
62
+	})
63
+	body, _ := io.ReadAll(resp.Body)
64
+	_ = resp.Body.Close()
65
+	if !strings.Contains(string(body), "Preferences saved") {
66
+		t.Fatalf("expected save success, got: %s", body)
67
+	}
68
+	if !strings.Contains(string(body), "account_changes:e=false:r=false") {
69
+		t.Errorf("account_changes should be off after opt-out: %s", body)
70
+	}
71
+	if !strings.Contains(string(body), "product_news:e=true:r=false") {
72
+		t.Errorf("product_news should be on after opt-in: %s", body)
73
+	}
74
+
75
+	// Now flip back to defaults — DB rows for both should be deleted
76
+	// since the desired state matches the default again.
77
+	csrf = cli.extractCSRF(t, "/settings/notifications")
78
+	resp = cli.post(t, "/settings/notifications", url.Values{
79
+		"csrf_token":      {csrf},
80
+		"account_changes": {"on"}, // back to default-on
81
+		// product_news omitted → back to default-off
82
+	})
83
+	defer func() { _ = resp.Body.Close() }()
84
+	body, _ = io.ReadAll(resp.Body)
85
+	if !strings.Contains(string(body), "account_changes:e=true:r=false") {
86
+		t.Errorf("expected account_changes back on: %s", body)
87
+	}
88
+	if !strings.Contains(string(body), "product_news:e=false:r=false") {
89
+		t.Errorf("expected product_news back off: %s", body)
90
+	}
91
+}
internal/web/static/css/shithub.cssmodified
@@ -615,3 +615,19 @@ code {
615615
 .shithub-pill-primary { background: rgba(56, 139, 253, 0.15); border-color: rgba(56, 139, 253, 0.4); }
616616
 .shithub-pill-verified { background: rgba(63, 185, 80, 0.15); border-color: rgba(63, 185, 80, 0.4); }
617617
 .shithub-pill-unverified { background: rgba(187, 128, 9, 0.15); border-color: rgba(187, 128, 9, 0.4); }
618
+
619
+/* ----- notifications (S10) ----- */
620
+.shithub-notif-form { display: grid; gap: 0.75rem; max-width: 36rem; }
621
+.shithub-notif-row {
622
+  display: grid;
623
+  grid-template-columns: max-content 1fr;
624
+  gap: 0.75rem;
625
+  padding: 0.75rem 1rem;
626
+  border: 1px solid var(--border-default);
627
+  border-radius: 6px;
628
+  background: var(--canvas-subtle);
629
+  align-items: start;
630
+}
631
+.shithub-notif-row.required { background: rgba(56, 139, 253, 0.06); }
632
+.shithub-notif-row strong { display: block; font-size: 0.95rem; }
633
+.shithub-notif-row small { display: block; color: var(--fg-muted); font-size: 0.85rem; }
internal/web/templates/settings/notifications.htmladded
@@ -0,0 +1,33 @@
1
+{{ define "page" -}}
2
+<div class="shithub-settings-page">
3
+  {{ template "settings-nav" . }}
4
+  <div class="shithub-settings-content">
5
+    <h1>Notifications</h1>
6
+
7
+    {{ with .Success }}<p class="shithub-flash shithub-flash-notice" role="status">{{ . }}</p>{{ end }}
8
+
9
+    <section class="shithub-settings-section">
10
+      <h2>Email preferences</h2>
11
+      <p>Choose which kinds of email shithub may send to your primary address.</p>
12
+
13
+      <form method="POST" action="/settings/notifications" novalidate class="shithub-notif-form">
14
+        <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
15
+
16
+        {{ range .Channels }}
17
+        <label class="shithub-notif-row{{ if .Required }} required{{ end }}">
18
+          <input type="checkbox" name="{{ .Key }}" value="on"
19
+                 {{ if .Enabled }}checked{{ end }}
20
+                 {{ if .Required }}disabled{{ end }}>
21
+          <span>
22
+            <strong>{{ .Title }}</strong>
23
+            <small>{{ .Description }}</small>
24
+          </span>
25
+        </label>
26
+        {{ end }}
27
+
28
+        <button type="submit" class="shithub-button shithub-button-primary">Save preferences</button>
29
+      </form>
30
+    </section>
31
+  </div>
32
+</div>
33
+{{- end }}