Add organizations settings page
- SHA
ec560c5a21b8d1764406191132a8e1f3b8abbdd8- Parents
-
2131691 - Tree
3325a0d
ec560c5
ec560c5a21b8d1764406191132a8e1f3b8abbdd82131691
3325a0dinternal/web/handlers/auth/auth.gomodified@@ -146,6 +146,7 @@ func (h *Handlers) Mount(r chi.Router) { | ||
| 146 | 146 | r.Post("/settings/password", h.settingsPasswordSubmit) |
| 147 | 147 | r.Get("/settings/appearance", h.settingsAppearanceForm) |
| 148 | 148 | r.Post("/settings/appearance", h.settingsAppearanceSubmit) |
| 149 | + r.Get("/settings/organizations", h.settingsOrganizations) | |
| 149 | 150 | r.Get("/settings/emails", h.settingsEmailsList) |
| 150 | 151 | r.Post("/settings/emails", h.settingsEmailsAdd) |
| 151 | 152 | r.Post("/settings/emails/{id}/resend", h.settingsEmailsResend) |
internal/web/handlers/auth/auth_test.gomodified@@ -199,6 +199,7 @@ func authTemplatesFS() fs.FS { | ||
| 199 | 199 | 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 }}` |
| 200 | 200 | 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 }}` |
| 201 | 201 | 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 }}` | |
| 202 | 203 | 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 }}` |
| 203 | 204 | 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 }}` |
| 204 | 205 | errorPage := `{{ define "page" }}<h1>{{.Status}} {{.StatusText}}</h1><p>{{.Message}}</p>{{ end }}` |
@@ -222,6 +223,7 @@ func authTemplatesFS() fs.FS { | ||
| 222 | 223 | "settings/appearance.html": {Data: []byte(apprTpl)}, |
| 223 | 224 | "settings/emails.html": {Data: []byte(emailsTpl)}, |
| 224 | 225 | "settings/notifications.html": {Data: []byte(notifTpl)}, |
| 226 | + "settings/organizations.html": {Data: []byte(organizationsTpl)}, | |
| 225 | 227 | "settings/sessions.html": {Data: []byte(sessTpl)}, |
| 226 | 228 | "settings/danger.html": {Data: []byte(dangerTpl)}, |
| 227 | 229 | "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 { | ||
| 709 | 709 | font-size: 0.8rem; |
| 710 | 710 | } |
| 711 | 711 | |
| 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 | + | |
| 712 | 826 | .shithub-profile-edit { |
| 713 | 827 | display: grid; |
| 714 | 828 | grid-template-columns: minmax(0, 1fr) 220px; |
@@ -749,6 +863,14 @@ code { | ||
| 749 | 863 | .shithub-settings-page { |
| 750 | 864 | grid-template-columns: 1fr; |
| 751 | 865 | } |
| 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 | + } | |
| 752 | 874 | .shithub-profile-edit { |
| 753 | 875 | grid-template-columns: 1fr; |
| 754 | 876 | } |
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 }} | |