tenseleyflow/shithub / ec560c5

Browse files

Add organizations settings page

Authored by espadonne
SHA
ec560c5a21b8d1764406191132a8e1f3b8abbdd8
Parents
2131691
Tree
3325a0d

7 changed files

StatusFile+-
M internal/web/handlers/auth/auth.go 1 0
M internal/web/handlers/auth/auth_test.go 2 0
A internal/web/handlers/auth/organizations.go 63 0
A internal/web/handlers/auth/organizations_test.go 65 0
M internal/web/static/css/shithub.css 122 0
M internal/web/templates/_settings_nav.html 3 0
A internal/web/templates/settings/organizations.html 57 0
internal/web/handlers/auth/auth.gomodified
@@ -146,6 +146,7 @@ func (h *Handlers) Mount(r chi.Router) {
146146
 			r.Post("/settings/password", h.settingsPasswordSubmit)
147147
 			r.Get("/settings/appearance", h.settingsAppearanceForm)
148148
 			r.Post("/settings/appearance", h.settingsAppearanceSubmit)
149
+			r.Get("/settings/organizations", h.settingsOrganizations)
149150
 			r.Get("/settings/emails", h.settingsEmailsList)
150151
 			r.Post("/settings/emails", h.settingsEmailsAdd)
151152
 			r.Post("/settings/emails/{id}/resend", h.settingsEmailsResend)
internal/web/handlers/auth/auth_test.gomodified
@@ -199,6 +199,7 @@ func authTemplatesFS() fs.FS {
199199
 	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 }}`
200200
 	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 }}`
201201
 	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 }}`
202
+	organizationsTpl := `{{ define "page" }}<h1>Organizations</h1>USER={{.Username}};ORGS={{ range .Organizations }}{{.Slug}}:{{.RoleLabel}}:manage={{.CanManage}};{{ end }}{{ end }}`
202203
 	sessTpl := `{{ define "page" }}<h1>Sessions</h1>{{ with .Success }}<p class=notice>{{.}}</p>{{ end }}<form action="/settings/sessions/logout-everywhere" method=POST><input name=csrf_token value="{{.CSRFToken}}">UA={{.UserAgent}};</form>{{ end }}`
203204
 	dangerTpl := `{{ define "page" }}<h1>Delete</h1>{{ with .Error }}<p class=error>{{.}}</p>{{ end }}<form action="/settings/danger" method=POST><input name=csrf_token value="{{.CSRFToken}}">USER={{.Username}};GRACE={{.GraceWindowDays}};</form>{{ end }}`
204205
 	errorPage := `{{ define "page" }}<h1>{{.Status}} {{.StatusText}}</h1><p>{{.Message}}</p>{{ end }}`
@@ -222,6 +223,7 @@ func authTemplatesFS() fs.FS {
222223
 		"settings/appearance.html":    {Data: []byte(apprTpl)},
223224
 		"settings/emails.html":        {Data: []byte(emailsTpl)},
224225
 		"settings/notifications.html": {Data: []byte(notifTpl)},
226
+		"settings/organizations.html": {Data: []byte(organizationsTpl)},
225227
 		"settings/sessions.html":      {Data: []byte(sessTpl)},
226228
 		"settings/danger.html":        {Data: []byte(dangerTpl)},
227229
 		"errors/404.html":             {Data: []byte(errorPage)},
internal/web/handlers/auth/organizations.goadded
@@ -0,0 +1,63 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package auth
4
+
5
+import (
6
+	"net/http"
7
+	"net/url"
8
+
9
+	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
10
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
11
+)
12
+
13
+type settingsOrganization struct {
14
+	Slug        string
15
+	DisplayName string
16
+	RoleLabel   string
17
+	AvatarURL   string
18
+	CanManage   bool
19
+}
20
+
21
+// settingsOrganizations renders GET /settings/organizations.
22
+func (h *Handlers) settingsOrganizations(w http.ResponseWriter, r *http.Request) {
23
+	user := middleware.CurrentUserFromContext(r.Context())
24
+	rows, err := orgsdb.New().ListOrgsForUser(r.Context(), h.d.Pool, user.ID)
25
+	if err != nil {
26
+		h.d.Logger.ErrorContext(r.Context(), "settings/organizations: list", "error", err)
27
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
28
+		return
29
+	}
30
+
31
+	organizations := make([]settingsOrganization, 0, len(rows))
32
+	for _, row := range rows {
33
+		displayName := row.DisplayName
34
+		if displayName == "" {
35
+			displayName = row.Slug
36
+		}
37
+		organizations = append(organizations, settingsOrganization{
38
+			Slug:        row.Slug,
39
+			DisplayName: displayName,
40
+			RoleLabel:   settingsOrgRoleLabel(row.Role),
41
+			AvatarURL:   "/avatars/" + url.PathEscape(row.Slug),
42
+			CanManage:   row.Role == orgsdb.OrgRoleOwner,
43
+		})
44
+	}
45
+
46
+	h.renderPage(w, r, "settings/organizations", map[string]any{
47
+		"Title":          "Organizations",
48
+		"SettingsActive": "organizations",
49
+		"Username":       user.Username,
50
+		"Organizations":  organizations,
51
+	})
52
+}
53
+
54
+func settingsOrgRoleLabel(role orgsdb.OrgRole) string {
55
+	switch role {
56
+	case orgsdb.OrgRoleOwner:
57
+		return "Owner"
58
+	case orgsdb.OrgRoleMember:
59
+		return "Member"
60
+	default:
61
+		return string(role)
62
+	}
63
+}
internal/web/handlers/auth/organizations_test.goadded
@@ -0,0 +1,65 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package auth_test
4
+
5
+import (
6
+	"context"
7
+	"io"
8
+	"net/http"
9
+	"net/url"
10
+	"strings"
11
+	"testing"
12
+
13
+	"github.com/tenseleyFlow/shithub/internal/orgs"
14
+)
15
+
16
+func TestSettingsOrganizationsListsMemberships(t *testing.T) {
17
+	t.Parallel()
18
+	httpsrv, pool, captor := newTestServerWithPool(t, false)
19
+	cli := newClient(t, httpsrv)
20
+
21
+	mustSignup(t, cli, "aliceorg", "aliceorg@example.com", "correct horse battery staple")
22
+	tok := extractTokenFromMessage(t, captor.all()[0], "/verify-email")
23
+	_ = cli.get(t, "/verify-email/"+tok).Body.Close()
24
+
25
+	csrf := cli.extractCSRF(t, "/login")
26
+	resp := cli.post(t, "/login", url.Values{
27
+		"csrf_token": {csrf},
28
+		"username":   {"aliceorg"},
29
+		"password":   {"correct horse battery staple"},
30
+	})
31
+	if resp.StatusCode != http.StatusSeeOther {
32
+		t.Fatalf("login: %d", resp.StatusCode)
33
+	}
34
+	_ = resp.Body.Close()
35
+
36
+	var userID int64
37
+	if err := pool.QueryRow(context.Background(), "SELECT id FROM users WHERE username = $1", "aliceorg").Scan(&userID); err != nil {
38
+		t.Fatalf("lookup user id: %v", err)
39
+	}
40
+	if _, err := orgs.Create(context.Background(), orgs.Deps{Pool: pool}, orgs.CreateParams{
41
+		Slug:            "alice-org",
42
+		DisplayName:     "Alice Org",
43
+		BillingEmail:    "aliceorg@example.com",
44
+		CreatedByUserID: userID,
45
+	}); err != nil {
46
+		t.Fatalf("create org: %v", err)
47
+	}
48
+
49
+	resp = cli.get(t, "/settings/organizations")
50
+	if resp.StatusCode != http.StatusOK {
51
+		t.Fatalf("get organizations: %d", resp.StatusCode)
52
+	}
53
+	body, _ := io.ReadAll(resp.Body)
54
+	_ = resp.Body.Close()
55
+
56
+	for _, want := range []string{
57
+		"Organizations",
58
+		"USER=aliceorg",
59
+		"alice-org:Owner:manage=true",
60
+	} {
61
+		if !strings.Contains(string(body), want) {
62
+			t.Errorf("missing %q in body: %s", want, body)
63
+		}
64
+	}
65
+}
internal/web/static/css/shithub.cssmodified
@@ -709,6 +709,120 @@ code {
709709
   font-size: 0.8rem;
710710
 }
711711
 
712
+.shithub-settings-orgs-page {
713
+  max-width: 78rem;
714
+  grid-template-columns: 220px minmax(0, 720px);
715
+}
716
+.shithub-settings-account-header {
717
+  display: grid;
718
+  grid-template-columns: 48px minmax(0, 1fr) auto;
719
+  gap: 0.75rem;
720
+  align-items: center;
721
+  margin-bottom: 1.5rem;
722
+}
723
+.shithub-settings-account-avatar {
724
+  width: 48px;
725
+  height: 48px;
726
+  border-radius: 50%;
727
+  background: var(--canvas-subtle);
728
+}
729
+.shithub-settings-account-header h1 {
730
+  margin: 0;
731
+  font-size: 1.25rem;
732
+  line-height: 1.25;
733
+}
734
+.shithub-settings-account-header p {
735
+  margin: 0.15rem 0 0;
736
+  color: var(--fg-muted);
737
+  font-size: 0.875rem;
738
+}
739
+.shithub-settings-account-actions {
740
+  display: flex;
741
+  gap: 0.5rem;
742
+  flex-wrap: wrap;
743
+  justify-content: flex-end;
744
+}
745
+.shithub-settings-orgs-head {
746
+  display: flex;
747
+  gap: 1rem;
748
+  align-items: center;
749
+  justify-content: space-between;
750
+  margin-bottom: 0.75rem;
751
+}
752
+.shithub-settings-orgs-head h2,
753
+.shithub-settings-orgs-move h2 {
754
+  margin: 0;
755
+  font-size: 1rem;
756
+  font-weight: 500;
757
+}
758
+.shithub-settings-org-list {
759
+  list-style: none;
760
+  margin: 0;
761
+  padding: 0;
762
+  border: 1px solid var(--border-default);
763
+  border-radius: 6px;
764
+  overflow: hidden;
765
+}
766
+.shithub-settings-org-row {
767
+  display: grid;
768
+  grid-template-columns: minmax(0, 1fr) auto auto;
769
+  gap: 0.75rem;
770
+  align-items: center;
771
+  min-height: 54px;
772
+  padding: 0.75rem 1rem;
773
+  border-top: 1px solid var(--border-default);
774
+}
775
+.shithub-settings-org-row:first-child { border-top: 0; }
776
+.shithub-settings-org-identity {
777
+  display: inline-flex;
778
+  gap: 0.5rem;
779
+  align-items: center;
780
+  min-width: 0;
781
+  font-weight: 600;
782
+}
783
+.shithub-settings-org-identity img {
784
+  width: 24px;
785
+  height: 24px;
786
+  border-radius: 4px;
787
+  background: var(--canvas-subtle);
788
+}
789
+.shithub-settings-org-identity span {
790
+  overflow: hidden;
791
+  text-overflow: ellipsis;
792
+  white-space: nowrap;
793
+}
794
+.shithub-settings-org-role {
795
+  color: var(--fg-muted);
796
+  border: 1px solid var(--border-default);
797
+  border-radius: 999px;
798
+  padding: 0.1rem 0.45rem;
799
+  font-size: 0.75rem;
800
+  font-weight: 500;
801
+}
802
+.shithub-settings-org-actions {
803
+  display: flex;
804
+  gap: 0.35rem;
805
+  justify-content: flex-end;
806
+  flex-wrap: wrap;
807
+}
808
+.shithub-settings-org-empty {
809
+  border: 1px solid var(--border-default);
810
+  border-radius: 6px;
811
+  padding: 1rem;
812
+  color: var(--fg-muted);
813
+}
814
+.shithub-settings-org-empty p { margin: 0 0 0.75rem; }
815
+.shithub-settings-orgs-move {
816
+  margin-top: 2rem;
817
+  padding-top: 1.5rem;
818
+  border-top: 1px solid var(--border-default);
819
+}
820
+.shithub-settings-orgs-move p {
821
+  max-width: 44rem;
822
+  color: var(--fg-muted);
823
+  font-size: 0.875rem;
824
+}
825
+
712826
 .shithub-profile-edit {
713827
   display: grid;
714828
   grid-template-columns: minmax(0, 1fr) 220px;
@@ -749,6 +863,14 @@ code {
749863
   .shithub-settings-page {
750864
     grid-template-columns: 1fr;
751865
   }
866
+  .shithub-settings-account-header,
867
+  .shithub-settings-org-row {
868
+    grid-template-columns: 1fr;
869
+  }
870
+  .shithub-settings-account-actions,
871
+  .shithub-settings-org-actions {
872
+    justify-content: flex-start;
873
+  }
752874
   .shithub-profile-edit {
753875
     grid-template-columns: 1fr;
754876
   }
internal/web/templates/_settings_nav.htmlmodified
@@ -30,6 +30,9 @@
3030
       <li{{ if eq .SettingsActive "keys" }} class="active"{{ end }}>
3131
         <a href="/settings/keys">SSH keys</a>
3232
       </li>
33
+      <li{{ if eq .SettingsActive "organizations" }} class="active"{{ end }}>
34
+        <a href="/settings/organizations">Organizations</a>
35
+      </li>
3336
       <li{{ if eq .SettingsActive "tokens" }} class="active"{{ end }}>
3437
         <a href="/settings/tokens">Personal access tokens</a>
3538
       </li>
internal/web/templates/settings/organizations.htmladded
@@ -0,0 +1,57 @@
1
+{{ define "page" -}}
2
+<div class="shithub-settings-page shithub-settings-orgs-page">
3
+  {{ template "settings-nav" . }}
4
+  <div class="shithub-settings-content shithub-settings-orgs-content">
5
+    <header class="shithub-settings-account-header">
6
+      <img src="/avatars/{{ .Username }}" alt="" class="shithub-settings-account-avatar" width="48" height="48">
7
+      <div>
8
+        <h1>{{ .Username }}</h1>
9
+        <p>Your personal account</p>
10
+      </div>
11
+      <div class="shithub-settings-account-actions">
12
+        <a href="/settings/profile" class="shithub-button shithub-button-ghost">{{ octicon "gear" }} Go to settings page</a>
13
+        <a href="/{{ .Username }}" class="shithub-button shithub-button-ghost">{{ octicon "person" }} Go to your personal profile</a>
14
+      </div>
15
+    </header>
16
+
17
+    <section class="shithub-settings-orgs-section" aria-labelledby="settings-organizations-heading">
18
+      <div class="shithub-settings-orgs-head">
19
+        <h2 id="settings-organizations-heading">Organizations</h2>
20
+        <a href="/organizations/new" class="shithub-button shithub-button-primary">New organization</a>
21
+      </div>
22
+
23
+      {{ if .Organizations }}
24
+      <ul class="shithub-settings-org-list">
25
+      {{ range .Organizations }}
26
+        <li class="shithub-settings-org-row">
27
+          <a href="/{{ .Slug }}" class="shithub-settings-org-identity">
28
+            <img src="{{ .AvatarURL }}" alt="" width="24" height="24">
29
+            <span>{{ .Slug }}</span>
30
+          </a>
31
+          <span class="shithub-settings-org-role">{{ .RoleLabel }}</span>
32
+          <div class="shithub-settings-org-actions">
33
+            {{ if .CanManage }}
34
+            <button type="button" class="shithub-button shithub-button-small" disabled>Compare plans</button>
35
+            <a href="/{{ .Slug }}/people" class="shithub-button shithub-button-small">Settings</a>
36
+            {{ end }}
37
+            <button type="button" class="shithub-button shithub-button-small shithub-button-danger" disabled>Leave</button>
38
+          </div>
39
+        </li>
40
+      {{ end }}
41
+      </ul>
42
+      {{ else }}
43
+      <div class="shithub-settings-org-empty">
44
+        <p>You are not a member of any organizations yet.</p>
45
+        <a href="/organizations/new" class="shithub-button shithub-button-primary">Create organization</a>
46
+      </div>
47
+      {{ end }}
48
+    </section>
49
+
50
+    <section class="shithub-settings-orgs-move" aria-labelledby="settings-move-org-heading">
51
+      <h2 id="settings-move-org-heading">Move to an organization</h2>
52
+      <p>Your personal account cannot be converted to an organization. You must create a new organization and transfer your repositories and projects to it instead. You can then rename your personal account and the organization if you want your organization to have the same name that you are currently using for your personal account.</p>
53
+      <a href="/organizations/new" class="shithub-button shithub-button-ghost">Move work to an organization</a>
54
+    </section>
55
+  </div>
56
+</div>
57
+{{- end }}