tenseleyflow/shithub / 6baf2b6

Browse files

S10: profile editor at /settings/profile (display name, bio, location, website, company, pronouns)

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
6baf2b6a62f4f522a09fd34f9e5471460e751beb
Parents
134847d
Tree
a731f90

6 changed files

StatusFile+-
M internal/web/handlers/auth/auth.go 2 0
M internal/web/handlers/auth/auth_test.go 2 0
A internal/web/handlers/auth/profile.go 147 0
A internal/web/handlers/auth/profile_test.go 113 0
M internal/web/static/css/shithub.css 37 0
A internal/web/templates/settings/profile.html 53 0
internal/web/handlers/auth/auth.gomodified
@@ -123,6 +123,8 @@ func (h *Handlers) Mount(r chi.Router) {
123123
 		// Settings — require an authenticated user.
124124
 		r.Group(func(r chi.Router) {
125125
 			r.Use(middleware.RequireUser)
126
+			r.Get("/settings/profile", h.settingsProfileForm)
127
+			r.Post("/settings/profile", h.settingsProfileSubmit)
126128
 			r.Get("/settings/keys", h.sshKeysList)
127129
 			r.Post("/settings/keys", h.sshKeysAdd)
128130
 			r.Post("/settings/keys/{id}/delete", h.sshKeysDelete)
internal/web/handlers/auth/auth_test.gomodified
@@ -160,6 +160,7 @@ func authTemplatesFS() fs.FS {
160160
 	keysTpl := `{{ define "page" }}<form>{{ with .AddError }}<p class=error>{{.}}</p>{{ end }}<input name=csrf_token value="{{.CSRFToken}}">KEYS={{ range .Keys }}{{.ID}}:{{.FingerprintSha256}};{{ end }}</form>{{ end }}`
161161
 	//nolint:gosec // G101 false positive: test fixture, not a hardcoded credential.
162162
 	tokensTpl := `{{ define "page" }}<form>{{ with .CreateError }}<p class=error>{{.}}</p>{{ end }}<input name=csrf_token value="{{.CSRFToken}}">{{ if .JustCreatedRaw }}RAW={{.JustCreatedRaw}}{{ end }}TOKENS={{ range .Tokens }}{{.ID}}:{{.TokenPrefix}}{{ if .RevokedAt.Valid }}:revoked{{ end }};{{ end }}</form>{{ end }}`
163
+	profileTpl := `{{ define "page" }}<h1>Public profile</h1>{{ with .Error }}<p class=error>{{.}}</p>{{ end }}{{ with .Success }}<p class=notice>{{.}}</p>{{ end }}<form><input name=csrf_token value="{{.CSRFToken}}">DISPLAY={{.Form.DisplayName}};BIO={{.Form.Bio}};LOCATION={{.Form.Location}};WEBSITE={{.Form.Website}};COMPANY={{.Form.Company}};PRONOUNS={{.Form.Pronouns}};</form>{{ end }}`
163164
 	errorPage := `{{ define "page" }}<h1>{{.Status}} {{.StatusText}}</h1><p>{{.Message}}</p>{{ end }}`
164165
 	return fstest.MapFS{
165166
 		"_layout.html":               {Data: []byte(layout)},
@@ -175,6 +176,7 @@ func authTemplatesFS() fs.FS {
175176
 		"settings/2fa_recovery.html": {Data: []byte(tfaRecovery)},
176177
 		"settings/keys.html":         {Data: []byte(keysTpl)},
177178
 		"settings/tokens.html":       {Data: []byte(tokensTpl)},
179
+		"settings/profile.html":      {Data: []byte(profileTpl)},
178180
 		"errors/404.html":            {Data: []byte(errorPage)},
179181
 		"errors/403.html":            {Data: []byte(errorPage)},
180182
 		"errors/429.html":            {Data: []byte(errorPage)},
internal/web/handlers/auth/profile.goadded
@@ -0,0 +1,147 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package auth
4
+
5
+import (
6
+	"net/http"
7
+	"net/url"
8
+	"strings"
9
+	"unicode/utf8"
10
+
11
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
12
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
13
+)
14
+
15
+// Field length caps. These mirror the CHECK constraints added in
16
+// migration 0014; the handler validates before the DB does so we surface
17
+// a friendly error instead of a 500 from a constraint violation.
18
+const (
19
+	maxDisplayName = 100
20
+	maxBio         = 500
21
+	maxLocation    = 80
22
+	maxWebsite     = 200
23
+	maxCompany     = 80
24
+	maxPronouns    = 40
25
+)
26
+
27
+// profileForm is the editor's form state. We round-trip on validation
28
+// errors so the user doesn't lose their input.
29
+type profileForm struct {
30
+	DisplayName string
31
+	Bio         string
32
+	Location    string
33
+	Website     string
34
+	Company     string
35
+	Pronouns    string
36
+}
37
+
38
+// settingsProfileForm renders GET /settings/profile prefilled from the DB.
39
+func (h *Handlers) settingsProfileForm(w http.ResponseWriter, r *http.Request) {
40
+	user := middleware.CurrentUserFromContext(r.Context())
41
+	row, err := h.q.GetUserByID(r.Context(), h.d.Pool, user.ID)
42
+	if err != nil {
43
+		h.d.Logger.ErrorContext(r.Context(), "settings/profile: load", "error", err)
44
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
45
+		return
46
+	}
47
+	h.renderProfileForm(w, r, profileForm{
48
+		DisplayName: row.DisplayName,
49
+		Bio:         row.Bio,
50
+		Location:    row.Location,
51
+		Website:     row.Website,
52
+		Company:     row.Company,
53
+		Pronouns:    row.Pronouns,
54
+	}, "", "")
55
+}
56
+
57
+// settingsProfileSubmit handles POST /settings/profile.
58
+func (h *Handlers) settingsProfileSubmit(w http.ResponseWriter, r *http.Request) {
59
+	if err := r.ParseForm(); err != nil {
60
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
61
+		return
62
+	}
63
+	user := middleware.CurrentUserFromContext(r.Context())
64
+	form := profileForm{
65
+		DisplayName: strings.TrimSpace(r.PostFormValue("display_name")),
66
+		Bio:         strings.TrimRight(r.PostFormValue("bio"), " \t\r\n"),
67
+		Location:    strings.TrimSpace(r.PostFormValue("location")),
68
+		Website:     strings.TrimSpace(r.PostFormValue("website")),
69
+		Company:     strings.TrimSpace(r.PostFormValue("company")),
70
+		Pronouns:    strings.TrimSpace(r.PostFormValue("pronouns")),
71
+	}
72
+	if msg := validateProfile(&form); msg != "" {
73
+		h.renderProfileForm(w, r, form, msg, "")
74
+		return
75
+	}
76
+
77
+	if err := h.q.UpdateUserProfile(r.Context(), h.d.Pool, usersdb.UpdateUserProfileParams{
78
+		ID:          user.ID,
79
+		DisplayName: form.DisplayName,
80
+		Bio:         form.Bio,
81
+		Location:    form.Location,
82
+		Website:     form.Website,
83
+		Company:     form.Company,
84
+		Pronouns:    form.Pronouns,
85
+	}); err != nil {
86
+		h.d.Logger.ErrorContext(r.Context(), "settings/profile: update", "error", err)
87
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
88
+		return
89
+	}
90
+
91
+	h.renderProfileForm(w, r, form, "", "Profile updated.")
92
+}
93
+
94
+// renderProfileForm is the shared render path. errMsg / successMsg are
95
+// mutually exclusive in practice but we let the template show whichever is set.
96
+func (h *Handlers) renderProfileForm(w http.ResponseWriter, r *http.Request, form profileForm, errMsg, successMsg string) {
97
+	user := middleware.CurrentUserFromContext(r.Context())
98
+	h.renderPage(w, r, "settings/profile", map[string]any{
99
+		"Title":          "Public profile",
100
+		"CSRFToken":      middleware.CSRFTokenForRequest(r),
101
+		"SettingsActive": "profile",
102
+		"Username":       user.Username,
103
+		"Form":           form,
104
+		"Error":          errMsg,
105
+		"Success":        successMsg,
106
+	})
107
+}
108
+
109
+// validateProfile enforces length caps and (if present) URL shape on
110
+// the website. It returns a friendly message or "" on success.
111
+func validateProfile(f *profileForm) string {
112
+	if utf8.RuneCountInString(f.DisplayName) > maxDisplayName {
113
+		return "Display name is too long."
114
+	}
115
+	if utf8.RuneCountInString(f.Bio) > maxBio {
116
+		return "Bio is too long (max 500 characters)."
117
+	}
118
+	if utf8.RuneCountInString(f.Location) > maxLocation {
119
+		return "Location is too long."
120
+	}
121
+	if utf8.RuneCountInString(f.Website) > maxWebsite {
122
+		return "Website URL is too long."
123
+	}
124
+	if utf8.RuneCountInString(f.Company) > maxCompany {
125
+		return "Company is too long."
126
+	}
127
+	if utf8.RuneCountInString(f.Pronouns) > maxPronouns {
128
+		return "Pronouns is too long."
129
+	}
130
+	if f.Website != "" {
131
+		// Auto-prefix bare hosts so users can paste "example.com".
132
+		if !strings.Contains(f.Website, "://") {
133
+			f.Website = "https://" + f.Website
134
+		}
135
+		u, err := url.Parse(f.Website)
136
+		if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
137
+			return "Website must be an http(s) URL."
138
+		}
139
+	}
140
+	if strings.ContainsAny(f.DisplayName, "\r\n") ||
141
+		strings.ContainsAny(f.Location, "\r\n") ||
142
+		strings.ContainsAny(f.Company, "\r\n") ||
143
+		strings.ContainsAny(f.Pronouns, "\r\n") {
144
+		return "Single-line fields cannot contain newlines."
145
+	}
146
+	return ""
147
+}
internal/web/handlers/auth/profile_test.goadded
@@ -0,0 +1,113 @@
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 loginProfileUser(t *testing.T) *client {
14
+	t.Helper()
15
+	httpsrv, captor := newTestServer(t, false)
16
+	cli := newClient(t, httpsrv)
17
+
18
+	mustSignup(t, cli, "alicep", "alicep@example.com", "correct horse battery staple")
19
+	tok := extractTokenFromMessage(t, captor.all()[0], "/verify-email")
20
+	_ = cli.get(t, "/verify-email/"+tok).Body.Close()
21
+
22
+	csrf := cli.extractCSRF(t, "/login")
23
+	resp := cli.post(t, "/login", url.Values{
24
+		"csrf_token": {csrf},
25
+		"username":   {"alicep"},
26
+		"password":   {"correct horse battery staple"},
27
+	})
28
+	if resp.StatusCode != http.StatusSeeOther {
29
+		t.Fatalf("login: %d", resp.StatusCode)
30
+	}
31
+	_ = resp.Body.Close()
32
+	return cli
33
+}
34
+
35
+func TestProfileEditor_Roundtrip(t *testing.T) {
36
+	t.Parallel()
37
+	cli := loginProfileUser(t)
38
+
39
+	// GET shows the form.
40
+	resp := cli.get(t, "/settings/profile")
41
+	if resp.StatusCode != 200 {
42
+		t.Fatalf("get: %d", resp.StatusCode)
43
+	}
44
+	body, _ := io.ReadAll(resp.Body)
45
+	_ = resp.Body.Close()
46
+	if !strings.Contains(string(body), "Public profile") {
47
+		t.Fatalf("missing heading: %s", body)
48
+	}
49
+
50
+	csrf := cli.extractCSRF(t, "/settings/profile")
51
+	resp = cli.post(t, "/settings/profile", url.Values{
52
+		"csrf_token":   {csrf},
53
+		"display_name": {"Alice P."},
54
+		"bio":          {"Building things."},
55
+		"location":     {"Berlin"},
56
+		"website":      {"example.com"}, // bare host -> auto https://
57
+		"company":      {"Acme"},
58
+		"pronouns":     {"she/her"},
59
+	})
60
+	if resp.StatusCode != 200 {
61
+		body, _ := io.ReadAll(resp.Body)
62
+		t.Fatalf("post: %d %s", resp.StatusCode, body)
63
+	}
64
+	body, _ = io.ReadAll(resp.Body)
65
+	_ = resp.Body.Close()
66
+	for _, want := range []string{"Profile updated.", "Alice P.", "Building things.", "Berlin", "https://example.com", "Acme", "she/her"} {
67
+		if !strings.Contains(string(body), want) {
68
+			t.Errorf("missing %q in body", want)
69
+		}
70
+	}
71
+}
72
+
73
+func TestProfileEditor_RejectsBadURL(t *testing.T) {
74
+	t.Parallel()
75
+	cli := loginProfileUser(t)
76
+	csrf := cli.extractCSRF(t, "/settings/profile")
77
+	resp := cli.post(t, "/settings/profile", url.Values{
78
+		"csrf_token": {csrf},
79
+		"website":    {"javascript:alert(1)"},
80
+	})
81
+	body, _ := io.ReadAll(resp.Body)
82
+	_ = resp.Body.Close()
83
+	if !strings.Contains(string(body), "http(s)") {
84
+		t.Fatalf("expected URL error, got: %s", body)
85
+	}
86
+}
87
+
88
+func TestProfileEditor_RequiresAuth(t *testing.T) {
89
+	t.Parallel()
90
+	httpsrv, _ := newTestServer(t, false)
91
+	cli := newClient(t, httpsrv)
92
+	resp := cli.get(t, "/settings/profile")
93
+	defer func() { _ = resp.Body.Close() }()
94
+	// RequireUser sends an unauthenticated visitor to /login (303 See Other).
95
+	if resp.StatusCode != http.StatusSeeOther && resp.StatusCode != http.StatusFound {
96
+		t.Fatalf("expected redirect, got %d", resp.StatusCode)
97
+	}
98
+}
99
+
100
+func TestProfileEditor_TooLongBio(t *testing.T) {
101
+	t.Parallel()
102
+	cli := loginProfileUser(t)
103
+	csrf := cli.extractCSRF(t, "/settings/profile")
104
+	resp := cli.post(t, "/settings/profile", url.Values{
105
+		"csrf_token": {csrf},
106
+		"bio":        {strings.Repeat("a", 501)},
107
+	})
108
+	body, _ := io.ReadAll(resp.Body)
109
+	_ = resp.Body.Close()
110
+	if !strings.Contains(string(body), "Bio is too long") {
111
+		t.Fatalf("expected bio length error, got: %s", body)
112
+	}
113
+}
internal/web/static/css/shithub.cssmodified
@@ -484,3 +484,40 @@ code {
484484
   background: rgba(207, 34, 46, 0.04);
485485
 }
486486
 .shithub-settings-danger-zone h2 { color: #cf222e; }
487
+
488
+.shithub-settings-section label small {
489
+  font-weight: 400;
490
+  color: var(--fg-muted);
491
+  font-size: 0.8rem;
492
+}
493
+
494
+.shithub-profile-edit {
495
+  display: grid;
496
+  grid-template-columns: minmax(0, 1fr) 220px;
497
+  gap: 2rem;
498
+  align-items: start;
499
+}
500
+.shithub-profile-edit-form { margin: 0; padding: 0; border: none; }
501
+.shithub-profile-edit-aside h2 { margin: 0 0 0.75rem; font-size: 0.9rem; font-weight: 600; }
502
+.shithub-profile-edit-avatar {
503
+  width: 200px;
504
+  height: 200px;
505
+  border-radius: 50%;
506
+  border: 1px solid var(--border-default);
507
+  background: var(--canvas-subtle);
508
+  display: block;
509
+}
510
+.shithub-empty-note {
511
+  margin: 0.5rem 0 0;
512
+  font-size: 0.8rem;
513
+  color: var(--fg-muted);
514
+}
515
+
516
+@media (max-width: 720px) {
517
+  .shithub-settings-page {
518
+    grid-template-columns: 1fr;
519
+  }
520
+  .shithub-profile-edit {
521
+    grid-template-columns: 1fr;
522
+  }
523
+}
internal/web/templates/settings/profile.htmladded
@@ -0,0 +1,53 @@
1
+{{ define "page" -}}
2
+<div class="shithub-settings-page">
3
+  {{ template "settings-nav" . }}
4
+  <div class="shithub-settings-content">
5
+    <h1>Public profile</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
+    <div class="shithub-profile-edit">
11
+      <section class="shithub-settings-section shithub-profile-edit-form">
12
+        <form method="POST" action="/settings/profile" novalidate>
13
+          <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
14
+          <label>
15
+            <span>Name</span>
16
+            <input type="text" name="display_name" maxlength="100" value="{{ .Form.DisplayName }}">
17
+            <small>Your name may appear around shithub where you contribute or are mentioned. You can remove it at any time.</small>
18
+          </label>
19
+          <label>
20
+            <span>Bio</span>
21
+            <textarea name="bio" rows="3" maxlength="500">{{ .Form.Bio }}</textarea>
22
+            <small>You can @mention other users and organizations to link to them.</small>
23
+          </label>
24
+          <label>
25
+            <span>Pronouns</span>
26
+            <input type="text" name="pronouns" maxlength="40" value="{{ .Form.Pronouns }}" placeholder="e.g. she/her, they/them">
27
+          </label>
28
+          <label>
29
+            <span>URL</span>
30
+            <input type="text" name="website" maxlength="200" value="{{ .Form.Website }}" placeholder="https://example.com">
31
+          </label>
32
+          <label>
33
+            <span>Company</span>
34
+            <input type="text" name="company" maxlength="80" value="{{ .Form.Company }}">
35
+            <small>You can @mention your company's shithub organization to link it.</small>
36
+          </label>
37
+          <label>
38
+            <span>Location</span>
39
+            <input type="text" name="location" maxlength="80" value="{{ .Form.Location }}">
40
+          </label>
41
+          <button type="submit" class="shithub-button shithub-button-primary">Update profile</button>
42
+        </form>
43
+      </section>
44
+
45
+      <aside class="shithub-profile-edit-aside">
46
+        <h2>Profile picture</h2>
47
+        <img src="/avatars/{{ .Username }}" alt="" class="shithub-profile-edit-avatar">
48
+        <p class="shithub-empty-note">Avatar upload coming soon.</p>
49
+      </aside>
50
+    </div>
51
+  </div>
52
+</div>
53
+{{- end }}