tenseleyflow/shithub / fa5aa61

Browse files

S10: Appearance theme picker — writes users.theme + theme cookie

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
fa5aa6160bb2d7a9fb5302940fdb59fe101cfadc
Parents
82087a7
Tree
7127fe4

6 changed files

StatusFile+-
A internal/web/handlers/auth/appearance.go 97 0
A internal/web/handlers/auth/appearance_test.go 102 0
M internal/web/handlers/auth/auth.go 2 0
M internal/web/handlers/auth/auth_test.go 2 0
M internal/web/static/css/shithub.css 45 0
A internal/web/templates/settings/appearance.html 56 0
internal/web/handlers/auth/appearance.goadded
@@ -0,0 +1,97 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package auth
4
+
5
+import (
6
+	"net/http"
7
+
8
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
9
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
10
+)
11
+
12
+// allowedThemes mirrors the CHECK constraint on users.theme. Plus the
13
+// empty string, which we treat the same as "auto" but persist as "" in
14
+// the DB so the cookie wins on devices where the user hasn't set a
15
+// preference.
16
+var allowedThemes = map[string]struct{}{
17
+	"":              {},
18
+	"light":         {},
19
+	"dark":          {},
20
+	"auto":          {},
21
+	"high_contrast": {},
22
+}
23
+
24
+// themeCookieName is read by the inline script in _layout.html before any
25
+// CSS computes, avoiding a flash on first paint.
26
+const themeCookieName = "theme"
27
+
28
+// settingsAppearanceForm renders GET /settings/appearance.
29
+func (h *Handlers) settingsAppearanceForm(w http.ResponseWriter, r *http.Request) {
30
+	h.renderAppearanceForm(w, r, "", "")
31
+}
32
+
33
+// settingsAppearanceSubmit handles POST /settings/appearance.
34
+//
35
+// Two writes:
36
+//  1. users.theme — source of truth across devices.
37
+//  2. theme cookie — so the next request paints the right theme without
38
+//     waiting on the inline JS to fall back to system preference.
39
+func (h *Handlers) settingsAppearanceSubmit(w http.ResponseWriter, r *http.Request) {
40
+	if err := r.ParseForm(); err != nil {
41
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
42
+		return
43
+	}
44
+	user := middleware.CurrentUserFromContext(r.Context())
45
+	choice := r.PostFormValue("theme")
46
+	if _, ok := allowedThemes[choice]; !ok {
47
+		h.renderAppearanceForm(w, r, "Unknown theme.", "")
48
+		return
49
+	}
50
+
51
+	if err := h.q.UpdateUserTheme(r.Context(), h.d.Pool, usersdb.UpdateUserThemeParams{
52
+		ID:    user.ID,
53
+		Theme: choice,
54
+	}); err != nil {
55
+		h.d.Logger.ErrorContext(r.Context(), "appearance: update", "error", err)
56
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
57
+		return
58
+	}
59
+
60
+	//nolint:gosec // G124 false positive: HttpOnly intentionally false because the
61
+	// inline script in _layout.html reads this cookie before any CSS computes,
62
+	// avoiding a flash of unthemed content. The cookie carries no auth value.
63
+	cookie := &http.Cookie{
64
+		Name:     themeCookieName,
65
+		Value:    choice,
66
+		Path:     "/",
67
+		MaxAge:   60 * 60 * 24 * 365, // 1 year
68
+		HttpOnly: false,
69
+		Secure:   r.TLS != nil,
70
+		SameSite: http.SameSiteLaxMode,
71
+	}
72
+	if choice == "" {
73
+		// Empty value clears the cookie so the inline script falls back
74
+		// to system preference on the next paint.
75
+		cookie.Value = ""
76
+		cookie.MaxAge = -1
77
+	}
78
+	http.SetCookie(w, cookie)
79
+
80
+	h.renderAppearanceForm(w, r, "", "Appearance updated.")
81
+}
82
+
83
+func (h *Handlers) renderAppearanceForm(w http.ResponseWriter, r *http.Request, errMsg, successMsg string) {
84
+	user := middleware.CurrentUserFromContext(r.Context())
85
+	current := ""
86
+	if row, err := h.q.GetUserByID(r.Context(), h.d.Pool, user.ID); err == nil {
87
+		current = row.Theme
88
+	}
89
+	h.renderPage(w, r, "settings/appearance", map[string]any{
90
+		"Title":          "Appearance",
91
+		"CSRFToken":      middleware.CSRFTokenForRequest(r),
92
+		"SettingsActive": "appearance",
93
+		"CurrentTheme":   current,
94
+		"Error":          errMsg,
95
+		"Success":        successMsg,
96
+	})
97
+}
internal/web/handlers/auth/appearance_test.goadded
@@ -0,0 +1,102 @@
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 loginAppearanceUser(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 TestAppearance_PersistsTheme(t *testing.T) {
35
+	t.Parallel()
36
+	cli := loginAppearanceUser(t, "appra")
37
+	csrf := cli.extractCSRF(t, "/settings/appearance")
38
+	resp := cli.post(t, "/settings/appearance", url.Values{
39
+		"csrf_token": {csrf},
40
+		"theme":      {"dark"},
41
+	})
42
+	defer func() { _ = resp.Body.Close() }()
43
+	body, _ := io.ReadAll(resp.Body)
44
+	if !strings.Contains(string(body), "Appearance updated") {
45
+		t.Fatalf("expected success, got: %s", body)
46
+	}
47
+	if !strings.Contains(string(body), "THEME=dark") {
48
+		t.Fatalf("expected THEME=dark, got: %s", body)
49
+	}
50
+
51
+	// Cookie should also be set.
52
+	var found bool
53
+	for _, c := range resp.Cookies() {
54
+		if c.Name == "theme" && c.Value == "dark" {
55
+			found = true
56
+			break
57
+		}
58
+	}
59
+	if !found {
60
+		t.Fatalf("expected theme=dark cookie in response; got %+v", resp.Cookies())
61
+	}
62
+}
63
+
64
+func TestAppearance_RejectsUnknownTheme(t *testing.T) {
65
+	t.Parallel()
66
+	cli := loginAppearanceUser(t, "apprb")
67
+	csrf := cli.extractCSRF(t, "/settings/appearance")
68
+	resp := cli.post(t, "/settings/appearance", url.Values{
69
+		"csrf_token": {csrf},
70
+		"theme":      {"neon"},
71
+	})
72
+	defer func() { _ = resp.Body.Close() }()
73
+	body, _ := io.ReadAll(resp.Body)
74
+	if !strings.Contains(string(body), "Unknown theme") {
75
+		t.Fatalf("expected unknown-theme error, got: %s", body)
76
+	}
77
+}
78
+
79
+func TestAppearance_EmptyClearsCookie(t *testing.T) {
80
+	t.Parallel()
81
+	cli := loginAppearanceUser(t, "apprc")
82
+
83
+	// Set a theme first.
84
+	csrf := cli.extractCSRF(t, "/settings/appearance")
85
+	_ = cli.post(t, "/settings/appearance", url.Values{
86
+		"csrf_token": {csrf},
87
+		"theme":      {"dark"},
88
+	}).Body.Close()
89
+
90
+	// Now clear it.
91
+	csrf = cli.extractCSRF(t, "/settings/appearance")
92
+	resp := cli.post(t, "/settings/appearance", url.Values{
93
+		"csrf_token": {csrf},
94
+		"theme":      {""},
95
+	})
96
+	defer func() { _ = resp.Body.Close() }()
97
+	for _, c := range resp.Cookies() {
98
+		if c.Name == "theme" && c.MaxAge >= 0 {
99
+			t.Fatalf("expected theme cookie cleared (MaxAge<0); got %+v", c)
100
+		}
101
+	}
102
+}
internal/web/handlers/auth/auth.gomodified
@@ -138,6 +138,8 @@ func (h *Handlers) Mount(r chi.Router) {
138138
 			r.Post("/settings/account/username", h.settingsAccountUsername)
139139
 			r.Get("/settings/password", h.settingsPasswordForm)
140140
 			r.Post("/settings/password", h.settingsPasswordSubmit)
141
+			r.Get("/settings/appearance", h.settingsAppearanceForm)
142
+			r.Post("/settings/appearance", h.settingsAppearanceSubmit)
141143
 			r.Get("/settings/keys", h.sshKeysList)
142144
 			r.Post("/settings/keys", h.sshKeysAdd)
143145
 			r.Post("/settings/keys/{id}/delete", h.sshKeysDelete)
internal/web/handlers/auth/auth_test.gomodified
@@ -166,6 +166,7 @@ func authTemplatesFS() fs.FS {
166166
 	accountTpl := `{{ define "page" }}<h1>Account</h1>{{ with .Error }}<p class=error>{{.}}</p>{{ end }}{{ with .Success }}<p class=notice>{{.}}</p>{{ end }}<form action="/settings/account/username" method=POST><input name=csrf_token value="{{.CSRFToken}}">USERNAME={{.CurrentUsername}};USED={{.RecentRenames}}/{{.MaxRenames}};</form>{{ end }}`
167167
 	//nolint:gosec // G101 false positive: HTML fixture, not a credential.
168168
 	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 }}`
169
+	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 }}`
169170
 	errorPage := `{{ define "page" }}<h1>{{.Status}} {{.StatusText}}</h1><p>{{.Message}}</p>{{ end }}`
170171
 	return fstest.MapFS{
171172
 		"_layout.html":               {Data: []byte(layout)},
@@ -184,6 +185,7 @@ func authTemplatesFS() fs.FS {
184185
 		"settings/profile.html":      {Data: []byte(profileTpl)},
185186
 		"settings/account.html":      {Data: []byte(accountTpl)},
186187
 		"settings/password.html":     {Data: []byte(pwTpl)},
188
+		"settings/appearance.html":   {Data: []byte(apprTpl)},
187189
 		"errors/404.html":            {Data: []byte(errorPage)},
188190
 		"errors/403.html":            {Data: []byte(errorPage)},
189191
 		"errors/429.html":            {Data: []byte(errorPage)},
internal/web/static/css/shithub.cssmodified
@@ -535,3 +535,48 @@ code {
535535
     grid-template-columns: 1fr;
536536
   }
537537
 }
538
+
539
+/* ----- theme picker (S10) ----- */
540
+.shithub-theme-grid {
541
+  border: none;
542
+  padding: 0;
543
+  margin: 0 0 1rem;
544
+  display: grid;
545
+  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
546
+  gap: 0.75rem;
547
+}
548
+.shithub-theme-grid legend {
549
+  padding: 0;
550
+  margin: 0 0 0.5rem;
551
+  font-size: 0.85rem;
552
+  font-weight: 500;
553
+}
554
+.shithub-theme-card {
555
+  display: grid;
556
+  gap: 0.25rem;
557
+  padding: 0.85rem 1rem;
558
+  border: 1px solid var(--border-default);
559
+  border-radius: 8px;
560
+  background: var(--canvas-subtle);
561
+  cursor: pointer;
562
+  position: relative;
563
+  transition: border-color 120ms;
564
+}
565
+.shithub-theme-card:hover { border-color: var(--accent-emphasis); }
566
+.shithub-theme-card.active {
567
+  border-color: var(--accent-emphasis);
568
+  box-shadow: 0 0 0 1px var(--accent-emphasis) inset;
569
+}
570
+.shithub-theme-card input[type=radio] {
571
+  position: absolute;
572
+  top: 0.6rem;
573
+  right: 0.6rem;
574
+}
575
+.shithub-theme-card-title {
576
+  font-weight: 600;
577
+  font-size: 0.95rem;
578
+}
579
+.shithub-theme-card-desc {
580
+  font-size: 0.8rem;
581
+  color: var(--fg-muted);
582
+}
internal/web/templates/settings/appearance.htmladded
@@ -0,0 +1,56 @@
1
+{{ define "page" -}}
2
+<div class="shithub-settings-page">
3
+  {{ template "settings-nav" . }}
4
+  <div class="shithub-settings-content">
5
+    <h1>Appearance</h1>
6
+
7
+    {{ with .Error }}<p class="shithub-flash shithub-flash-error" role="alert">{{ . }}</p>{{ end }}
8
+    {{ with .Success }}<p class="shithub-flash shithub-flash-notice" role="status">{{ . }}</p>{{ end }}
9
+
10
+    <section class="shithub-settings-section">
11
+      <h2>Theme preferences</h2>
12
+      <p>Choose how shithub looks. <em>Sync with system</em> matches your OS setting and switches automatically when it does.</p>
13
+
14
+      <form method="POST" action="/settings/appearance" novalidate class="shithub-theme-form">
15
+        <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
16
+
17
+        <fieldset class="shithub-theme-grid">
18
+          <legend>Theme mode</legend>
19
+
20
+          <label class="shithub-theme-card{{ if eq .CurrentTheme "" }} active{{ end }}">
21
+            <input type="radio" name="theme" value=""{{ if eq .CurrentTheme "" }} checked{{ end }}>
22
+            <span class="shithub-theme-card-title">Sync with system</span>
23
+            <span class="shithub-theme-card-desc">Follow your OS light/dark setting.</span>
24
+          </label>
25
+
26
+          <label class="shithub-theme-card{{ if eq .CurrentTheme "auto" }} active{{ end }}">
27
+            <input type="radio" name="theme" value="auto"{{ if eq .CurrentTheme "auto" }} checked{{ end }}>
28
+            <span class="shithub-theme-card-title">Auto (time-of-day)</span>
29
+            <span class="shithub-theme-card-desc">Light by day, dark by night — based on your OS preference.</span>
30
+          </label>
31
+
32
+          <label class="shithub-theme-card{{ if eq .CurrentTheme "light" }} active{{ end }}">
33
+            <input type="radio" name="theme" value="light"{{ if eq .CurrentTheme "light" }} checked{{ end }}>
34
+            <span class="shithub-theme-card-title">Light</span>
35
+            <span class="shithub-theme-card-desc">Always use the light theme.</span>
36
+          </label>
37
+
38
+          <label class="shithub-theme-card{{ if eq .CurrentTheme "dark" }} active{{ end }}">
39
+            <input type="radio" name="theme" value="dark"{{ if eq .CurrentTheme "dark" }} checked{{ end }}>
40
+            <span class="shithub-theme-card-title">Dark</span>
41
+            <span class="shithub-theme-card-desc">Always use the dark theme.</span>
42
+          </label>
43
+
44
+          <label class="shithub-theme-card{{ if eq .CurrentTheme "high_contrast" }} active{{ end }}">
45
+            <input type="radio" name="theme" value="high_contrast"{{ if eq .CurrentTheme "high_contrast" }} checked{{ end }}>
46
+            <span class="shithub-theme-card-title">High contrast</span>
47
+            <span class="shithub-theme-card-desc">Increased contrast for accessibility.</span>
48
+          </label>
49
+        </fieldset>
50
+
51
+        <button type="submit" class="shithub-button shithub-button-primary">Update theme</button>
52
+      </form>
53
+    </section>
54
+  </div>
55
+</div>
56
+{{- end }}