tenseleyflow/shithub / b507608

Browse files

Add org settings profile mutations

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
b507608db412b0c1cfc7bd53d394d0733a3bc26d
Parents
44daa8e
Tree
4c5abca

4 changed files

StatusFile+-
M internal/web/handlers/orgs/avatar.go 1 11
M internal/web/handlers/orgs/avatar_test.go 172 0
M internal/web/handlers/orgs/orgs.go 2 0
A internal/web/handlers/orgs/settings_profile.go 200 0
internal/web/handlers/orgs/avatar.gomodified
@@ -117,17 +117,7 @@ func (h *Handlers) renderSettingsProfile(
117117
 	errMsg string,
118118
 	success string,
119119
 ) {
120
-	_ = h.d.Render.RenderPage(w, r, "orgs/settings_profile", map[string]any{
121
-		"Title":               org.Slug + " · profile settings",
122
-		"CSRFToken":           middleware.CSRFTokenForRequest(r),
123
-		"Org":                 org,
124
-		"AvatarURL":           "/avatars/" + org.Slug,
125
-		"ActiveOrgNav":        "settings",
126
-		"AvatarUploadEnabled": h.d.ObjectStore != nil,
127
-		"HasAvatar":           org.AvatarObjectKey.Valid && org.AvatarObjectKey.String != "",
128
-		"Error":               errMsg,
129
-		"Success":             success,
130
-	})
120
+	h.renderSettingsProfileWithForm(w, r, org, settingsProfileFormFromOrg(org), errMsg, success)
131121
 }
132122
 
133123
 func orgSettingsProfilePath(org orgsdb.Org) string {
internal/web/handlers/orgs/avatar_test.gomodified
@@ -117,6 +117,178 @@ func TestOrgAvatarUploadRoundTrip(t *testing.T) {
117117
 	}
118118
 }
119119
 
120
+func TestOrgSettingsProfileUpdate(t *testing.T) {
121
+	t.Parallel()
122
+	ctx := context.Background()
123
+	pool := dbtest.NewTestDB(t)
124
+	q := orgsdb.New()
125
+	viewerID := insertOrgAvatarUser(t, pool, "mfwolffe")
126
+	orgID := insertOrgAvatarOrg(t, pool, viewerID, "tenseleyFlow")
127
+
128
+	tmplFS := fstest.MapFS{
129
+		"_layout.html":               {Data: []byte(`{{ define "layout" }}<html><body>{{ template "page" . }}</body></html>{{ end }}`)},
130
+		"orgs/settings_profile.html": {Data: []byte(`{{ define "page" }}{{ with .Error }}ERROR={{ . }}{{ end }}{{ with .Success }}SUCCESS={{ . }}{{ end }}DISPLAY={{ .Form.DisplayName }}{{ end }}`)},
131
+		"errors/403.html":            {Data: []byte(`{{ define "page" }}403{{ end }}`)},
132
+		"errors/404.html":            {Data: []byte(`{{ define "page" }}404{{ end }}`)},
133
+		"errors/500.html":            {Data: []byte(`{{ define "page" }}500{{ end }}`)},
134
+	}
135
+	rr, err := render.New(tmplFS, render.Options{})
136
+	if err != nil {
137
+		t.Fatalf("render.New: %v", err)
138
+	}
139
+	h, err := orgsh.New(orgsh.Deps{
140
+		Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
141
+		Render: rr,
142
+		Pool:   pool,
143
+	})
144
+	if err != nil {
145
+		t.Fatalf("orgsh.New: %v", err)
146
+	}
147
+	r := chi.NewRouter()
148
+	r.Use(func(next http.Handler) http.Handler {
149
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
150
+			viewer := middleware.CurrentUser{ID: viewerID, Username: "mfwolffe"}
151
+			next.ServeHTTP(w, r.WithContext(middleware.WithCurrentUserForTest(r.Context(), viewer)))
152
+		})
153
+	})
154
+	h.MountCreate(r)
155
+	srv := httptest.NewServer(r)
156
+	t.Cleanup(srv.Close)
157
+
158
+	resp, err := http.PostForm(srv.URL+"/organizations/tenseleyFlow/settings/profile", url.Values{
159
+		"display_name":             {"Tenseley Flow"},
160
+		"description":              {"Workflow repositories"},
161
+		"website":                  {"example.com"},
162
+		"location":                 {"United States of America"},
163
+		"billing_email":            {"billing@example.com"},
164
+		"allow_member_repo_create": {"on"},
165
+	})
166
+	if err != nil {
167
+		t.Fatalf("POST settings: %v", err)
168
+	}
169
+	body, _ := io.ReadAll(resp.Body)
170
+	_ = resp.Body.Close()
171
+	if resp.StatusCode != http.StatusOK {
172
+		t.Fatalf("POST status=%d body=%s", resp.StatusCode, body)
173
+	}
174
+	if !strings.Contains(string(body), "SUCCESS=Organization profile updated.") {
175
+		t.Fatalf("expected success render, got %s", body)
176
+	}
177
+	org, err := q.GetOrgByID(ctx, pool, orgID)
178
+	if err != nil {
179
+		t.Fatalf("GetOrgByID: %v", err)
180
+	}
181
+	if org.DisplayName != "Tenseley Flow" ||
182
+		org.Description != "Workflow repositories" ||
183
+		org.Website != "https://example.com" ||
184
+		org.Location != "United States of America" ||
185
+		org.BillingEmail != "billing@example.com" ||
186
+		!org.AllowMemberRepoCreate {
187
+		t.Fatalf("unexpected org after update: %#v", org)
188
+	}
189
+
190
+	resp, err = http.PostForm(srv.URL+"/organizations/tenseleyFlow/settings/profile", url.Values{
191
+		"display_name":  {"Tenseley Flow"},
192
+		"billing_email": {"billing@example.com"},
193
+	})
194
+	if err != nil {
195
+		t.Fatalf("POST settings clear checkbox: %v", err)
196
+	}
197
+	_ = resp.Body.Close()
198
+	org, err = q.GetOrgByID(ctx, pool, orgID)
199
+	if err != nil {
200
+		t.Fatalf("GetOrgByID after checkbox clear: %v", err)
201
+	}
202
+	if org.AllowMemberRepoCreate {
203
+		t.Fatalf("expected unchecked allow_member_repo_create to persist false")
204
+	}
205
+}
206
+
207
+func TestOrgSettingsDeleteRequiresSlugConfirmation(t *testing.T) {
208
+	t.Parallel()
209
+	ctx := context.Background()
210
+	pool := dbtest.NewTestDB(t)
211
+	q := orgsdb.New()
212
+	viewerID := insertOrgAvatarUser(t, pool, "mfwolffe")
213
+	insertOrgAvatarOrg(t, pool, viewerID, "tenseleyFlow")
214
+
215
+	tmplFS := fstest.MapFS{
216
+		"_layout.html":               {Data: []byte(`{{ define "layout" }}<html><body>{{ template "page" . }}</body></html>{{ end }}`)},
217
+		"orgs/settings_profile.html": {Data: []byte(`{{ define "page" }}{{ with .Error }}ERROR={{ . }}{{ end }}{{ end }}`)},
218
+		"errors/403.html":            {Data: []byte(`{{ define "page" }}403{{ end }}`)},
219
+		"errors/404.html":            {Data: []byte(`{{ define "page" }}404{{ end }}`)},
220
+		"errors/500.html":            {Data: []byte(`{{ define "page" }}500{{ end }}`)},
221
+	}
222
+	rr, err := render.New(tmplFS, render.Options{})
223
+	if err != nil {
224
+		t.Fatalf("render.New: %v", err)
225
+	}
226
+	h, err := orgsh.New(orgsh.Deps{
227
+		Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
228
+		Render: rr,
229
+		Pool:   pool,
230
+	})
231
+	if err != nil {
232
+		t.Fatalf("orgsh.New: %v", err)
233
+	}
234
+	r := chi.NewRouter()
235
+	r.Use(func(next http.Handler) http.Handler {
236
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
237
+			viewer := middleware.CurrentUser{ID: viewerID, Username: "mfwolffe"}
238
+			next.ServeHTTP(w, r.WithContext(middleware.WithCurrentUserForTest(r.Context(), viewer)))
239
+		})
240
+	})
241
+	h.MountCreate(r)
242
+	srv := httptest.NewServer(r)
243
+	t.Cleanup(srv.Close)
244
+	cli := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
245
+		return http.ErrUseLastResponse
246
+	}}
247
+
248
+	resp, err := cli.PostForm(srv.URL+"/organizations/tenseleyFlow/settings/delete", url.Values{
249
+		"confirm_slug": {"wrong"},
250
+	})
251
+	if err != nil {
252
+		t.Fatalf("POST delete wrong confirmation: %v", err)
253
+	}
254
+	body, _ := io.ReadAll(resp.Body)
255
+	_ = resp.Body.Close()
256
+	if resp.StatusCode != http.StatusOK {
257
+		t.Fatalf("wrong confirmation status=%d body=%s", resp.StatusCode, body)
258
+	}
259
+	if !strings.Contains(string(body), "ERROR=Enter this organization's name to confirm deletion.") {
260
+		t.Fatalf("expected confirmation error, got %s", body)
261
+	}
262
+	org, err := q.GetOrgBySlugIncludingDeleted(ctx, pool, "tenseleyFlow")
263
+	if err != nil {
264
+		t.Fatalf("GetOrgBySlugIncludingDeleted: %v", err)
265
+	}
266
+	if org.DeletedAt.Valid {
267
+		t.Fatalf("org should not be deleted after wrong confirmation")
268
+	}
269
+
270
+	resp, err = cli.PostForm(srv.URL+"/organizations/tenseleyFlow/settings/delete", url.Values{
271
+		"confirm_slug": {"TENSELEYFLOW"},
272
+	})
273
+	if err != nil {
274
+		t.Fatalf("POST delete: %v", err)
275
+	}
276
+	_ = resp.Body.Close()
277
+	if resp.StatusCode != http.StatusSeeOther {
278
+		t.Fatalf("delete status=%d", resp.StatusCode)
279
+	}
280
+	if got := resp.Header.Get("Location"); got != "/settings/organizations" {
281
+		t.Fatalf("delete Location=%q", got)
282
+	}
283
+	org, err = q.GetOrgBySlugIncludingDeleted(ctx, pool, "tenseleyFlow")
284
+	if err != nil {
285
+		t.Fatalf("GetOrgBySlugIncludingDeleted after delete: %v", err)
286
+	}
287
+	if !org.DeletedAt.Valid {
288
+		t.Fatalf("expected org to be soft-deleted")
289
+	}
290
+}
291
+
120292
 func postOrgAvatar(t *testing.T, cli *http.Client, endpoint string, png []byte) *http.Response {
121293
 	t.Helper()
122294
 	body := &bytes.Buffer{}
internal/web/handlers/orgs/orgs.gomodified
@@ -73,8 +73,10 @@ func (h *Handlers) MountCreate(r chi.Router) {
7373
 	r.Get("/organizations/new", h.newForm)
7474
 	r.Post("/organizations", h.createSubmit)
7575
 	r.Get("/organizations/{org}/settings/profile", h.settingsProfile)
76
+	r.Post("/organizations/{org}/settings/profile", h.settingsProfileSubmit)
7677
 	r.Post("/organizations/{org}/settings/profile/avatar", h.settingsAvatarUpload)
7778
 	r.Post("/organizations/{org}/settings/profile/avatar/remove", h.settingsAvatarRemove)
79
+	r.Post("/organizations/{org}/settings/delete", h.settingsDelete)
7880
 }
7981
 
8082
 // MountOrgRoutes registers the per-org surface under /{org}/people
internal/web/handlers/orgs/settings_profile.goadded
@@ -0,0 +1,200 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package orgs
4
+
5
+import (
6
+	"net/http"
7
+	"net/mail"
8
+	"net/url"
9
+	"strings"
10
+	"unicode/utf8"
11
+
12
+	orgdomain "github.com/tenseleyFlow/shithub/internal/orgs"
13
+	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
14
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
15
+)
16
+
17
+const (
18
+	orgProfileMaxDisplayName = 100
19
+	orgProfileMaxLocation    = 80
20
+	orgProfileMaxWebsite     = 200
21
+)
22
+
23
+type settingsProfileForm struct {
24
+	DisplayName           string
25
+	Description           string
26
+	Website               string
27
+	Location              string
28
+	BillingEmail          string
29
+	AllowMemberRepoCreate bool
30
+}
31
+
32
+func settingsProfileFormFromOrg(org orgsdb.Org) settingsProfileForm {
33
+	return settingsProfileForm{
34
+		DisplayName:           org.DisplayName,
35
+		Description:           org.Description,
36
+		Website:               org.Website,
37
+		Location:              org.Location,
38
+		BillingEmail:          org.BillingEmail,
39
+		AllowMemberRepoCreate: org.AllowMemberRepoCreate,
40
+	}
41
+}
42
+
43
+func (h *Handlers) settingsProfileSubmit(w http.ResponseWriter, r *http.Request) {
44
+	org, ok := h.orgFromSlug(w, r)
45
+	if !ok {
46
+		return
47
+	}
48
+	viewer := middleware.CurrentUserFromContext(r.Context())
49
+	if !h.requireOrgOwner(w, r, org.ID, viewer) {
50
+		return
51
+	}
52
+	if err := r.ParseForm(); err != nil {
53
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
54
+		return
55
+	}
56
+
57
+	form := settingsProfileForm{
58
+		DisplayName:           strings.TrimSpace(r.PostFormValue("display_name")),
59
+		Description:           strings.TrimRight(r.PostFormValue("description"), " \t\r\n"),
60
+		Website:               strings.TrimSpace(r.PostFormValue("website")),
61
+		Location:              strings.TrimSpace(r.PostFormValue("location")),
62
+		BillingEmail:          strings.TrimSpace(r.PostFormValue("billing_email")),
63
+		AllowMemberRepoCreate: r.PostFormValue("allow_member_repo_create") == "on",
64
+	}
65
+	if form.DisplayName == "" {
66
+		form.DisplayName = org.Slug
67
+	}
68
+	if msg := validateOrgProfile(&form); msg != "" {
69
+		h.renderSettingsProfileWithForm(w, r, org, form, msg, "")
70
+		return
71
+	}
72
+
73
+	q := orgsdb.New()
74
+	tx, err := h.d.Pool.Begin(r.Context())
75
+	if err != nil {
76
+		h.d.Logger.ErrorContext(r.Context(), "org settings: begin update", "error", err)
77
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
78
+		return
79
+	}
80
+	committed := false
81
+	defer func() {
82
+		if !committed {
83
+			_ = tx.Rollback(r.Context())
84
+		}
85
+	}()
86
+	if err := q.UpdateOrgProfile(r.Context(), tx, orgsdb.UpdateOrgProfileParams{
87
+		ID:           org.ID,
88
+		DisplayName:  form.DisplayName,
89
+		Description:  form.Description,
90
+		Location:     form.Location,
91
+		Website:      form.Website,
92
+		BillingEmail: form.BillingEmail,
93
+	}); err != nil {
94
+		h.d.Logger.ErrorContext(r.Context(), "org settings: update profile", "error", err)
95
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
96
+		return
97
+	}
98
+	if err := q.SetOrgAllowMemberRepoCreate(r.Context(), tx, orgsdb.SetOrgAllowMemberRepoCreateParams{
99
+		ID:                    org.ID,
100
+		AllowMemberRepoCreate: form.AllowMemberRepoCreate,
101
+	}); err != nil {
102
+		h.d.Logger.ErrorContext(r.Context(), "org settings: update member repo create", "error", err)
103
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
104
+		return
105
+	}
106
+	if err := tx.Commit(r.Context()); err != nil {
107
+		h.d.Logger.ErrorContext(r.Context(), "org settings: commit update", "error", err)
108
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
109
+		return
110
+	}
111
+	committed = true
112
+	updated, err := q.GetOrgByID(r.Context(), h.d.Pool, org.ID)
113
+	if err != nil {
114
+		h.d.Logger.ErrorContext(r.Context(), "org settings: reload profile", "error", err)
115
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
116
+		return
117
+	}
118
+	h.renderSettingsProfile(w, r, updated, "", "Organization profile updated.")
119
+}
120
+
121
+func (h *Handlers) settingsDelete(w http.ResponseWriter, r *http.Request) {
122
+	org, ok := h.orgFromSlug(w, r)
123
+	if !ok {
124
+		return
125
+	}
126
+	viewer := middleware.CurrentUserFromContext(r.Context())
127
+	if !h.requireOrgOwner(w, r, org.ID, viewer) {
128
+		return
129
+	}
130
+	if err := r.ParseForm(); err != nil {
131
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
132
+		return
133
+	}
134
+	if !strings.EqualFold(strings.TrimSpace(r.PostFormValue("confirm_slug")), org.Slug) {
135
+		h.renderSettingsProfile(w, r, org, "Enter this organization's name to confirm deletion.", "")
136
+		return
137
+	}
138
+	if err := orgdomain.SoftDelete(r.Context(), h.deps(), org.ID, viewer.ID); err != nil {
139
+		h.d.Logger.ErrorContext(r.Context(), "org settings: soft delete", "error", err)
140
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
141
+		return
142
+	}
143
+	http.Redirect(w, r, "/settings/organizations", http.StatusSeeOther)
144
+}
145
+
146
+func (h *Handlers) renderSettingsProfileWithForm(
147
+	w http.ResponseWriter,
148
+	r *http.Request,
149
+	org orgsdb.Org,
150
+	form settingsProfileForm,
151
+	errMsg string,
152
+	success string,
153
+) {
154
+	_ = h.d.Render.RenderPage(w, r, "orgs/settings_profile", map[string]any{
155
+		"Title":               org.Slug + " - profile settings",
156
+		"CSRFToken":           middleware.CSRFTokenForRequest(r),
157
+		"Org":                 org,
158
+		"Form":                form,
159
+		"AvatarURL":           "/avatars/" + url.PathEscape(org.Slug),
160
+		"ActiveOrgNav":        "settings",
161
+		"AvatarUploadEnabled": h.d.ObjectStore != nil,
162
+		"HasAvatar":           org.AvatarObjectKey.Valid && org.AvatarObjectKey.String != "",
163
+		"Error":               errMsg,
164
+		"Success":             success,
165
+	})
166
+}
167
+
168
+func validateOrgProfile(f *settingsProfileForm) string {
169
+	if utf8.RuneCountInString(f.DisplayName) > orgProfileMaxDisplayName {
170
+		return "Organization display name is too long."
171
+	}
172
+	if utf8.RuneCountInString(f.Description) > 350 {
173
+		return "Description is too long (max 350 characters)."
174
+	}
175
+	if utf8.RuneCountInString(f.Location) > orgProfileMaxLocation {
176
+		return "Location is too long."
177
+	}
178
+	if utf8.RuneCountInString(f.Website) > orgProfileMaxWebsite {
179
+		return "Website URL is too long."
180
+	}
181
+	if strings.ContainsAny(f.DisplayName, "\r\n") || strings.ContainsAny(f.Location, "\r\n") {
182
+		return "Single-line fields cannot contain newlines."
183
+	}
184
+	if f.Website != "" {
185
+		if !strings.Contains(f.Website, "://") {
186
+			f.Website = "https://" + f.Website
187
+		}
188
+		u, err := url.Parse(f.Website)
189
+		if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
190
+			return "Website must be an http(s) URL."
191
+		}
192
+	}
193
+	if f.BillingEmail != "" {
194
+		addr, err := mail.ParseAddress(f.BillingEmail)
195
+		if err != nil || addr.Address != f.BillingEmail {
196
+			return "Billing email must be a single valid email address."
197
+		}
198
+	}
199
+	return ""
200
+}