tenseleyflow/shithub / 75b2ec9

Browse files

S30: org web surface — create, profile dispatch, people, invitations

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
75b2ec969aa3484c52b2c8858263f52a8896a4bf
Parents
d8fc620
Tree
7cb89c9

10 changed files

StatusFile+-
M internal/web/handlers/handlers.go 23 0
A internal/web/handlers/orgs/orgs.go 370 0
M internal/web/handlers/profile/profile.go 96 0
A internal/web/orgs_wiring.go 69 0
M internal/web/server.go 27 0
M internal/web/templates/_nav.html 1 0
A internal/web/templates/orgs/invitation.html 14 0
A internal/web/templates/orgs/new.html 28 0
A internal/web/templates/orgs/people.html 74 0
A internal/web/templates/orgs/profile.html 32 0
internal/web/handlers/handlers.gomodified
@@ -100,6 +100,18 @@ type Deps struct {
100100
 	// needed, so the route lives in the public group alongside
101101
 	// /healthz / /static.
102102
 	NotifPublicMounter func(chi.Router)
103
+	// OrgCreateMounter registers /organizations/new + POST
104
+	// /organizations (S30). Wrapped in RequireUser at the wiring
105
+	// layer.
106
+	OrgCreateMounter func(chi.Router)
107
+	// OrgRoutesMounter registers /{org}/people + invite + member
108
+	// management. Reads (people page) are public; mutations are
109
+	// owner-gated inside the handler. Must register BEFORE the
110
+	// /{username} catch-all so the `people` segment matches.
111
+	OrgRoutesMounter func(chi.Router)
112
+	// OrgInvitationsMounter registers /invitations/{token} +
113
+	// accept/decline. RequireUser at the wiring layer.
114
+	OrgInvitationsMounter func(chi.Router)
103115
 	// GitHTTPMounter, when non-nil, registers the smart-HTTP git routes
104116
 	// (`*.git/info/refs`, `git-upload-pack`, `git-receive-pack`). MUST
105117
 	// land in a route group that bypasses CSRF, response compression,
@@ -242,6 +254,17 @@ func RegisterChi(r *chi.Mux, deps Deps) (*chi.Mux, middleware.PanicHandler, http
242254
 		if deps.NotifInboxMounter != nil {
243255
 			deps.NotifInboxMounter(r)
244256
 		}
257
+		if deps.OrgCreateMounter != nil {
258
+			deps.OrgCreateMounter(r)
259
+		}
260
+		if deps.OrgInvitationsMounter != nil {
261
+			deps.OrgInvitationsMounter(r)
262
+		}
263
+		// /{org}/people MUST register before /{username} catch-all
264
+		// so the explicit `people` segment matches first.
265
+		if deps.OrgRoutesMounter != nil {
266
+			deps.OrgRoutesMounter(r)
267
+		}
245268
 		if deps.RepoHomeMounter != nil {
246269
 			deps.RepoHomeMounter(r)
247270
 		}
internal/web/handlers/orgs/orgs.goadded
@@ -0,0 +1,370 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+// Package orgs wires the S30 organization web surface:
4
+//
5
+//   GET  /organizations/new            create form
6
+//   POST /organizations                create submit
7
+//   GET  /{org}/people                 members + pending invites + invite form
8
+//   POST /{org}/people/invite          invite by username or email
9
+//   POST /{org}/people/{user}/role     change role
10
+//   POST /{org}/people/{user}/remove   remove member
11
+//   GET  /invitations/{token}          accept/decline view
12
+//   POST /invitations/{token}/accept   accept
13
+//   POST /invitations/{token}/decline  decline
14
+//
15
+// Profile rendering for /{org} is dispatched from the existing
16
+// /{username} catch-all in internal/web/handlers/profile via the
17
+// principals.Resolve lookup; this handler set only owns the org-
18
+// specific surfaces.
19
+package orgs
20
+
21
+import (
22
+	"errors"
23
+	"log/slog"
24
+	"net/http"
25
+	"strconv"
26
+	"strings"
27
+
28
+	"github.com/go-chi/chi/v5"
29
+	"github.com/jackc/pgx/v5/pgxpool"
30
+
31
+	authemail "github.com/tenseleyFlow/shithub/internal/auth/email"
32
+	"github.com/tenseleyFlow/shithub/internal/orgs"
33
+	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
34
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
35
+	"github.com/tenseleyFlow/shithub/internal/web/render"
36
+)
37
+
38
+// Deps wires the handler set.
39
+type Deps struct {
40
+	Logger      *slog.Logger
41
+	Render      *render.Renderer
42
+	Pool        *pgxpool.Pool
43
+	EmailSender authemail.Sender
44
+	EmailFrom   string
45
+	SiteName    string
46
+	BaseURL     string
47
+}
48
+
49
+// Handlers groups the org surface handlers.
50
+type Handlers struct {
51
+	d Deps
52
+}
53
+
54
+// New constructs the handler set, validating Deps.
55
+func New(d Deps) (*Handlers, error) {
56
+	if d.Render == nil {
57
+		return nil, errors.New("orgs handlers: nil Render")
58
+	}
59
+	if d.Pool == nil {
60
+		return nil, errors.New("orgs handlers: nil Pool")
61
+	}
62
+	return &Handlers{d: d}, nil
63
+}
64
+
65
+// MountCreate registers /organizations/new + POST /organizations.
66
+// Caller wraps these in RequireUser since both require a logged-in
67
+// creator. The /organizations prefix is on the auth-reserved list so
68
+// it never shadows a user/org slug.
69
+func (h *Handlers) MountCreate(r chi.Router) {
70
+	r.Get("/organizations/new", h.newForm)
71
+	r.Post("/organizations", h.createSubmit)
72
+}
73
+
74
+// MountOrgRoutes registers the per-org surface under /{org}/people
75
+// and /{org}/settings. Caller MUST register this before the
76
+// /{username} catch-all so the `people` segment matches.
77
+//
78
+// Member-management routes live behind RequireUser at the wiring
79
+// layer (server.go); profile-style reads stay public.
80
+func (h *Handlers) MountOrgRoutes(r chi.Router) {
81
+	r.Get("/{org}/people", h.peoplePage)
82
+	r.Post("/{org}/people/invite", h.invite)
83
+	r.Post("/{org}/people/{userID}/role", h.changeRole)
84
+	r.Post("/{org}/people/{userID}/remove", h.removeMember)
85
+}
86
+
87
+// MountInvitations registers /invitations/{token}* — accept/decline.
88
+// Authed-only; the page also shows a hint when the viewer's logged-in
89
+// user doesn't match the invite's target email.
90
+func (h *Handlers) MountInvitations(r chi.Router) {
91
+	r.Get("/invitations/{token}", h.invitationView)
92
+	r.Post("/invitations/{token}/accept", h.invitationAccept)
93
+	r.Post("/invitations/{token}/decline", h.invitationDecline)
94
+}
95
+
96
+// ─── helpers ───────────────────────────────────────────────────────
97
+
98
+func (h *Handlers) deps() orgs.Deps {
99
+	return orgs.Deps{
100
+		Pool:        h.d.Pool,
101
+		Logger:      h.d.Logger,
102
+		EmailSender: h.d.EmailSender,
103
+		EmailFrom:   h.d.EmailFrom,
104
+		SiteName:    h.d.SiteName,
105
+		BaseURL:     h.d.BaseURL,
106
+	}
107
+}
108
+
109
+// orgFromSlug resolves the org from a {org} URL param, with an
110
+// existence-leak-safe 404 path.
111
+func (h *Handlers) orgFromSlug(w http.ResponseWriter, r *http.Request) (orgsdb.Org, bool) {
112
+	slug := chi.URLParam(r, "org")
113
+	row, err := orgsdb.New().GetOrgBySlug(r.Context(), h.d.Pool, slug)
114
+	if err != nil {
115
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
116
+		return orgsdb.Org{}, false
117
+	}
118
+	return row, true
119
+}
120
+
121
+func parseUserIDParam(s string) (int64, error) {
122
+	return strconv.ParseInt(s, 10, 64)
123
+}
124
+
125
+// ─── create ────────────────────────────────────────────────────────
126
+
127
+func (h *Handlers) newForm(w http.ResponseWriter, r *http.Request) {
128
+	viewer := middleware.CurrentUserFromContext(r.Context())
129
+	if viewer.IsAnonymous() {
130
+		http.Redirect(w, r, "/login?next=/organizations/new", http.StatusSeeOther)
131
+		return
132
+	}
133
+	h.renderNewForm(w, r, "", "")
134
+}
135
+
136
+func (h *Handlers) createSubmit(w http.ResponseWriter, r *http.Request) {
137
+	viewer := middleware.CurrentUserFromContext(r.Context())
138
+	if viewer.IsAnonymous() {
139
+		http.Redirect(w, r, "/login?next=/organizations/new", http.StatusSeeOther)
140
+		return
141
+	}
142
+	if err := r.ParseForm(); err != nil {
143
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
144
+		return
145
+	}
146
+	slug := strings.TrimSpace(r.PostFormValue("slug"))
147
+	displayName := strings.TrimSpace(r.PostFormValue("display_name"))
148
+	billingEmail := strings.TrimSpace(r.PostFormValue("billing_email"))
149
+
150
+	row, err := orgs.Create(r.Context(), h.deps(), orgs.CreateParams{
151
+		Slug:            slug,
152
+		DisplayName:     displayName,
153
+		BillingEmail:    billingEmail,
154
+		CreatedByUserID: viewer.ID,
155
+	})
156
+	if err != nil {
157
+		h.renderNewForm(w, r, slug, friendlyOrgErr(err))
158
+		return
159
+	}
160
+	http.Redirect(w, r, "/"+row.Slug, http.StatusSeeOther)
161
+}
162
+
163
+func (h *Handlers) renderNewForm(w http.ResponseWriter, r *http.Request, slug, errMsg string) {
164
+	_ = h.d.Render.RenderPage(w, r, "orgs/new", map[string]any{
165
+		"Title":     "New organization",
166
+		"CSRFToken": middleware.CSRFTokenForRequest(r),
167
+		"Slug":      slug,
168
+		"Error":     errMsg,
169
+	})
170
+}
171
+
172
+// ─── people ────────────────────────────────────────────────────────
173
+
174
+func (h *Handlers) peoplePage(w http.ResponseWriter, r *http.Request) {
175
+	org, ok := h.orgFromSlug(w, r)
176
+	if !ok {
177
+		return
178
+	}
179
+	viewer := middleware.CurrentUserFromContext(r.Context())
180
+	q := orgsdb.New()
181
+	members, err := q.ListOrgMembers(r.Context(), h.d.Pool, org.ID)
182
+	if err != nil {
183
+		h.d.Logger.ErrorContext(r.Context(), "orgs: list members", "error", err)
184
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
185
+		return
186
+	}
187
+	var pending []orgsdb.ListPendingInvitationsForOrgRow
188
+	isOwner := false
189
+	if !viewer.IsAnonymous() {
190
+		isOwner, _ = orgs.IsOwner(r.Context(), h.deps(), org.ID, viewer.ID)
191
+		if isOwner {
192
+			pending, _ = q.ListPendingInvitationsForOrg(r.Context(), h.d.Pool, org.ID)
193
+		}
194
+	}
195
+	_ = h.d.Render.RenderPage(w, r, "orgs/people", map[string]any{
196
+		"Title":     org.Slug + " · people",
197
+		"CSRFToken": middleware.CSRFTokenForRequest(r),
198
+		"Org":       org,
199
+		"Members":   members,
200
+		"Pending":   pending,
201
+		"IsOwner":   isOwner,
202
+	})
203
+}
204
+
205
+func (h *Handlers) invite(w http.ResponseWriter, r *http.Request) {
206
+	org, ok := h.orgFromSlug(w, r)
207
+	if !ok {
208
+		return
209
+	}
210
+	viewer := middleware.CurrentUserFromContext(r.Context())
211
+	if viewer.IsAnonymous() {
212
+		h.d.Render.HTTPError(w, r, http.StatusUnauthorized, "")
213
+		return
214
+	}
215
+	owner, err := orgs.IsOwner(r.Context(), h.deps(), org.ID, viewer.ID)
216
+	if err != nil || !owner {
217
+		h.d.Render.HTTPError(w, r, http.StatusForbidden, "")
218
+		return
219
+	}
220
+	if err := r.ParseForm(); err != nil {
221
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
222
+		return
223
+	}
224
+	target := strings.TrimSpace(r.PostFormValue("target"))
225
+	role := r.PostFormValue("role")
226
+	if role == "" {
227
+		role = "member"
228
+	}
229
+	p := orgs.InviteParams{
230
+		OrgID:           org.ID,
231
+		InvitedByUserID: viewer.ID,
232
+		Role:            role,
233
+	}
234
+	if strings.Contains(target, "@") {
235
+		p.TargetEmail = target
236
+	} else {
237
+		p.TargetUsername = target
238
+	}
239
+	if _, err := orgs.Invite(r.Context(), h.deps(), p); err != nil {
240
+		h.d.Logger.WarnContext(r.Context(), "orgs: invite failed",
241
+			"org", org.Slug, "target", target, "error", err)
242
+	}
243
+	http.Redirect(w, r, "/"+org.Slug+"/people", http.StatusSeeOther)
244
+}
245
+
246
+func (h *Handlers) changeRole(w http.ResponseWriter, r *http.Request) {
247
+	h.memberMutate(w, r, func(orgID, userID int64) error {
248
+		role := r.PostFormValue("role")
249
+		return orgs.ChangeRole(r.Context(), h.deps(), orgID, userID, role)
250
+	})
251
+}
252
+
253
+func (h *Handlers) removeMember(w http.ResponseWriter, r *http.Request) {
254
+	h.memberMutate(w, r, func(orgID, userID int64) error {
255
+		return orgs.RemoveMember(r.Context(), h.deps(), orgID, userID)
256
+	})
257
+}
258
+
259
+// memberMutate is the shared owner-check + redirect wrapper for the
260
+// member-management POSTs. Centralizes the policy gate so each route
261
+// is one line.
262
+func (h *Handlers) memberMutate(w http.ResponseWriter, r *http.Request, action func(orgID, userID int64) error) {
263
+	org, ok := h.orgFromSlug(w, r)
264
+	if !ok {
265
+		return
266
+	}
267
+	viewer := middleware.CurrentUserFromContext(r.Context())
268
+	if viewer.IsAnonymous() {
269
+		h.d.Render.HTTPError(w, r, http.StatusUnauthorized, "")
270
+		return
271
+	}
272
+	owner, _ := orgs.IsOwner(r.Context(), h.deps(), org.ID, viewer.ID)
273
+	if !owner {
274
+		h.d.Render.HTTPError(w, r, http.StatusForbidden, "")
275
+		return
276
+	}
277
+	if err := r.ParseForm(); err != nil {
278
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
279
+		return
280
+	}
281
+	uid, err := parseUserIDParam(chi.URLParam(r, "userID"))
282
+	if err != nil {
283
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
284
+		return
285
+	}
286
+	if err := action(org.ID, uid); err != nil {
287
+		h.d.Logger.WarnContext(r.Context(), "orgs: member mutation",
288
+			"org", org.Slug, "user_id", uid, "error", err)
289
+	}
290
+	http.Redirect(w, r, "/"+org.Slug+"/people", http.StatusSeeOther)
291
+}
292
+
293
+// ─── invitations ───────────────────────────────────────────────────
294
+
295
+func (h *Handlers) invitationView(w http.ResponseWriter, r *http.Request) {
296
+	tok := chi.URLParam(r, "token")
297
+	inv, err := orgs.LookupInvitationByToken(r.Context(), h.deps(), tok)
298
+	if err != nil {
299
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
300
+		return
301
+	}
302
+	org, err := orgsdb.New().GetOrgByID(r.Context(), h.d.Pool, inv.OrgID)
303
+	if err != nil {
304
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
305
+		return
306
+	}
307
+	_ = h.d.Render.RenderPage(w, r, "orgs/invitation", map[string]any{
308
+		"Title":      "Organization invitation",
309
+		"CSRFToken":  middleware.CSRFTokenForRequest(r),
310
+		"Org":        org,
311
+		"Invitation": inv,
312
+		"Token":      tok,
313
+	})
314
+}
315
+
316
+func (h *Handlers) invitationAccept(w http.ResponseWriter, r *http.Request) {
317
+	h.invitationAction(w, r, true)
318
+}
319
+
320
+func (h *Handlers) invitationDecline(w http.ResponseWriter, r *http.Request) {
321
+	h.invitationAction(w, r, false)
322
+}
323
+
324
+func (h *Handlers) invitationAction(w http.ResponseWriter, r *http.Request, accept bool) {
325
+	viewer := middleware.CurrentUserFromContext(r.Context())
326
+	if viewer.IsAnonymous() {
327
+		http.Redirect(w, r, "/login?next="+r.URL.Path, http.StatusSeeOther)
328
+		return
329
+	}
330
+	tok := chi.URLParam(r, "token")
331
+	inv, err := orgs.LookupInvitationByToken(r.Context(), h.deps(), tok)
332
+	if err != nil {
333
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
334
+		return
335
+	}
336
+	if accept {
337
+		if err := orgs.AcceptInvitation(r.Context(), h.deps(), inv, viewer.ID); err != nil {
338
+			h.d.Logger.WarnContext(r.Context(), "orgs: accept invitation",
339
+				"id", inv.ID, "error", err)
340
+			h.d.Render.HTTPError(w, r, http.StatusForbidden, "")
341
+			return
342
+		}
343
+	} else {
344
+		if err := orgs.DeclineInvitation(r.Context(), h.deps(), inv, viewer.ID); err != nil {
345
+			h.d.Logger.WarnContext(r.Context(), "orgs: decline invitation",
346
+				"id", inv.ID, "error", err)
347
+		}
348
+	}
349
+	org, _ := orgsdb.New().GetOrgByID(r.Context(), h.d.Pool, inv.OrgID)
350
+	http.Redirect(w, r, "/"+org.Slug, http.StatusSeeOther)
351
+}
352
+
353
+// friendlyOrgErr maps orchestrator errors to user-facing strings.
354
+// Unknown errors collapse to a generic message — the underlying err
355
+// is logged at the call site.
356
+func friendlyOrgErr(err error) string {
357
+	switch {
358
+	case errors.Is(err, orgs.ErrEmptySlug):
359
+		return "Slug is required."
360
+	case errors.Is(err, orgs.ErrSlugTooLong):
361
+		return "Slug too long (max 39 characters)."
362
+	case errors.Is(err, orgs.ErrSlugInvalid):
363
+		return "Slug must be lowercase letters, digits, or hyphens; cannot start or end with a hyphen."
364
+	case errors.Is(err, orgs.ErrSlugReserved):
365
+		return "That slug is reserved. Try another."
366
+	case errors.Is(err, orgs.ErrSlugTaken):
367
+		return "That slug is already in use. Try another."
368
+	}
369
+	return "Something went wrong creating the organization."
370
+}
internal/web/handlers/profile/profile.gomodified
@@ -28,6 +28,9 @@ import (
2828
 	authpkg "github.com/tenseleyFlow/shithub/internal/auth"
2929
 	"github.com/tenseleyFlow/shithub/internal/avatars"
3030
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
31
+	"github.com/tenseleyFlow/shithub/internal/orgs"
32
+	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
33
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
3134
 	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
3235
 	"github.com/tenseleyFlow/shithub/internal/web/middleware"
3336
 	"github.com/tenseleyFlow/shithub/internal/web/render"
@@ -87,6 +90,21 @@ func (h *Handlers) serveProfile(w http.ResponseWriter, r *http.Request) {
8790
 		return
8891
 	}
8992
 
93
+	// S30: principals first. The single-row lookup decides whether
94
+	// /{slug} dispatches to the user-profile renderer (existing) or
95
+	// the org-profile renderer (this sprint). On miss, fall through
96
+	// to username_redirects so renamed users keep redirecting.
97
+	if p, err := orgs.Resolve(r.Context(), h.d.Pool, lower); err == nil {
98
+		switch p.Kind {
99
+		case orgs.PrincipalOrg:
100
+			h.serveOrgProfile(w, r, p.ID)
101
+			return
102
+		case orgs.PrincipalUser:
103
+			// Fall through to the user lookup below — keeps the
104
+			// existing canonical-case redirect + suspension paths.
105
+		}
106
+	}
107
+
90108
 	// Try direct lookup. citext makes the comparison case-insensitive.
91109
 	user, err := h.q.GetUserByUsername(r.Context(), h.d.Pool, rawName)
92110
 	if err != nil {
@@ -265,3 +283,81 @@ func safeWebsite(s string) template.URL {
265283
 // ensure context import is used by static analysis even if a future
266284
 // refactor removes its only inline use.
267285
 var _ = context.Background
286
+
287
+// serveOrgProfile renders /{org}. Pulls the org row + a small set of
288
+// the org's visible repos. Visibility scoping defers to the caller's
289
+// authentication state — a viewer that isn't a member sees only
290
+// public repos.
291
+func (h *Handlers) serveOrgProfile(w http.ResponseWriter, r *http.Request, orgID int64) {
292
+	ctx := r.Context()
293
+	org, err := orgsdb.New().GetOrgByID(ctx, h.d.Pool, orgID)
294
+	if err != nil {
295
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, r.URL.Path)
296
+		return
297
+	}
298
+	if org.DeletedAt.Valid {
299
+		// Soft-deleted orgs render the same "unavailable" shell as
300
+		// suspended/deleted users so the existence-leak posture is
301
+		// uniform.
302
+		h.renderUnavailable(w, r, string(org.Slug))
303
+		return
304
+	}
305
+	viewer := middleware.CurrentUserFromContext(r.Context())
306
+	isOwner := false
307
+	isMember := false
308
+	if !viewer.IsAnonymous() {
309
+		isOwner, _ = orgs.IsOwner(ctx, orgs.Deps{Pool: h.d.Pool, Logger: h.d.Logger}, org.ID, viewer.ID)
310
+		isMember, _ = orgs.IsMember(ctx, orgs.Deps{Pool: h.d.Pool, Logger: h.d.Logger}, org.ID, viewer.ID)
311
+	}
312
+
313
+	// Org repo listing — small inline query to avoid widening sqlc
314
+	// for one read. Members see private + public; non-members see
315
+	// public only. Soft-deleted repos are excluded uniformly.
316
+	visClause := "AND visibility = 'public'"
317
+	args := []any{org.ID}
318
+	if isMember {
319
+		visClause = ""
320
+	}
321
+	rows, err := h.d.Pool.Query(ctx,
322
+		`SELECT id, name, description, visibility::text
323
+		   FROM repos
324
+		  WHERE owner_org_id = $1 AND deleted_at IS NULL `+visClause+`
325
+		  ORDER BY name ASC LIMIT 50`,
326
+		args...)
327
+	if err != nil {
328
+		h.d.Logger.ErrorContext(ctx, "orgs profile: list repos", "error", err)
329
+	}
330
+	type repoRow struct {
331
+		Name, Description, Visibility string
332
+	}
333
+	var repos []repoRow
334
+	if rows != nil {
335
+		defer rows.Close()
336
+		for rows.Next() {
337
+			var id int64
338
+			var rr repoRow
339
+			if err := rows.Scan(&id, &rr.Name, &rr.Description, &rr.Visibility); err == nil {
340
+				repos = append(repos, rr)
341
+			}
342
+		}
343
+	}
344
+	memberCount := 0
345
+	{
346
+		var n int64
347
+		_ = h.d.Pool.QueryRow(ctx, `SELECT count(*) FROM org_members WHERE org_id = $1`, org.ID).Scan(&n)
348
+		memberCount = int(n)
349
+	}
350
+
351
+	_ = h.d.Render.RenderPage(w, r, "orgs/profile", map[string]any{
352
+		"Title":       org.DisplayName,
353
+		"Org":         org,
354
+		"Repos":       repos,
355
+		"MemberCount": memberCount,
356
+		"IsOwner":     isOwner,
357
+		"IsMember":    isMember,
358
+	})
359
+}
360
+
361
+// avoid the unused-import lint when reposdb is only referenced in
362
+// the inline raw query above.
363
+var _ = reposdb.New
internal/web/orgs_wiring.goadded
@@ -0,0 +1,69 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package web
4
+
5
+import (
6
+	"errors"
7
+	"io/fs"
8
+	"log/slog"
9
+	"net/http"
10
+	"os"
11
+	"time"
12
+
13
+	"github.com/jackc/pgx/v5/pgxpool"
14
+
15
+	"github.com/tenseleyFlow/shithub/internal/auth/email"
16
+	"github.com/tenseleyFlow/shithub/internal/infra/config"
17
+	orgshandlers "github.com/tenseleyFlow/shithub/internal/web/handlers/orgs"
18
+	"github.com/tenseleyFlow/shithub/internal/web/render"
19
+)
20
+
21
+// buildOrgHandlers wires the S30 organization handler set. Owns its
22
+// own renderer (same pattern as the search/notif builders).
23
+func buildOrgHandlers(
24
+	cfg config.Config,
25
+	pool *pgxpool.Pool,
26
+	tmplFS fs.FS,
27
+	logger *slog.Logger,
28
+) (*orgshandlers.Handlers, error) {
29
+	rr, err := render.New(tmplFS, render.Options{Octicons: render.BuiltinOcticons()})
30
+	if err != nil {
31
+		return nil, err
32
+	}
33
+	sender, _ := pickOrgsEmailSender(cfg)
34
+	return orgshandlers.New(orgshandlers.Deps{
35
+		Logger:      logger,
36
+		Render:      rr,
37
+		Pool:        pool,
38
+		EmailSender: sender,
39
+		EmailFrom:   cfg.Auth.EmailFrom,
40
+		SiteName:    cfg.Auth.SiteName,
41
+		BaseURL:     cfg.Auth.BaseURL,
42
+	})
43
+}
44
+
45
+// pickOrgsEmailSender mirrors pickEmailSender in auth_wiring.go.
46
+// Kept local so a missing/misconfigured email backend doesn't crash
47
+// the org surface — invitations still land in the DB; the email side
48
+// is best-effort.
49
+func pickOrgsEmailSender(cfg config.Config) (email.Sender, error) {
50
+	switch cfg.Auth.EmailBackend {
51
+	case "stdout":
52
+		return email.NewStdoutSender(os.Stdout), nil
53
+	case "smtp":
54
+		return &email.SMTPSender{
55
+			Addr:     cfg.Auth.SMTP.Addr,
56
+			From:     cfg.Auth.EmailFrom,
57
+			Username: cfg.Auth.SMTP.Username,
58
+			Password: cfg.Auth.SMTP.Password,
59
+		}, nil
60
+	case "postmark":
61
+		return &email.PostmarkSender{
62
+			ServerToken: cfg.Auth.Postmark.ServerToken,
63
+			From:        cfg.Auth.EmailFrom,
64
+			HTTP:        &http.Client{Timeout: 10 * time.Second},
65
+		}, nil
66
+	default:
67
+		return nil, errors.New("orgs: unknown email_backend")
68
+	}
69
+}
internal/web/server.gomodified
@@ -225,6 +225,33 @@ func Run(ctx context.Context, opts Options) error {
225225
 		}
226226
 		deps.NotifPublicMounter = notifH.MountPublic
227227
 
228
+		// S30 — orgs.
229
+		orgH, err := buildOrgHandlers(cfg, pool, deps.TemplatesFS, logger)
230
+		if err != nil {
231
+			return fmt.Errorf("org handlers: %w", err)
232
+		}
233
+		deps.OrgCreateMounter = func(r chi.Router) {
234
+			r.Group(func(r chi.Router) {
235
+				r.Use(middleware.RequireUser)
236
+				orgH.MountCreate(r)
237
+			})
238
+		}
239
+		// /{org}/people: GETs are public (org existence is non-secret;
240
+		// member lists for private orgs are deferred). Mutations are
241
+		// owner-checked inside the handler, but RequireUser wraps the
242
+		// POST routes so unauth submits redirect to /login.
243
+		deps.OrgRoutesMounter = func(r chi.Router) {
244
+			r.Group(func(r chi.Router) {
245
+				orgH.MountOrgRoutes(r)
246
+			})
247
+		}
248
+		deps.OrgInvitationsMounter = func(r chi.Router) {
249
+			r.Group(func(r chi.Router) {
250
+				r.Use(middleware.RequireUser)
251
+				orgH.MountInvitations(r)
252
+			})
253
+		}
254
+
228255
 		// Lifecycle danger-zone routes — also auth-required.
229256
 		deps.RepoLifecycleMounter = func(r chi.Router) {
230257
 			r.Group(func(r chi.Router) {
internal/web/templates/_nav.htmlmodified
@@ -27,6 +27,7 @@
2727
         </div>
2828
         <a role="menuitem" href="/{{ .Viewer.Username }}">Your profile</a>
2929
         <a role="menuitem" href="/{{ .Viewer.Username }}?tab=repositories">Your repositories</a>
30
+        <a role="menuitem" href="/organizations/new">New organization</a>
3031
         <a role="menuitem" href="/settings/profile">Settings</a>
3132
         <form method="POST" action="/logout" class="shithub-user-menu-signout">
3233
           <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
internal/web/templates/orgs/invitation.htmladded
@@ -0,0 +1,14 @@
1
+{{ define "page" -}}
2
+<section class="shithub-auth">
3
+  <h1>Organization invitation</h1>
4
+  <p>You've been invited to join <strong>{{ .Org.DisplayName }}</strong> (@{{ .Org.Slug }}) as a <strong>{{ .Invitation.Role }}</strong>.</p>
5
+  <form method="POST" action="/invitations/{{ .Token }}/accept" style="display:inline">
6
+    <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
7
+    <button type="submit" class="shithub-button shithub-button-primary">Accept</button>
8
+  </form>
9
+  <form method="POST" action="/invitations/{{ .Token }}/decline" style="display:inline">
10
+    <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
11
+    <button type="submit" class="shithub-button">Decline</button>
12
+  </form>
13
+</section>
14
+{{- end }}
internal/web/templates/orgs/new.htmladded
@@ -0,0 +1,28 @@
1
+{{ define "page" -}}
2
+<section class="shithub-auth">
3
+  <h1>Create an organization</h1>
4
+  {{ if .Error }}<p class="shithub-flash shithub-flash-error" role="alert">{{ .Error }}</p>{{ end }}
5
+  <form method="POST" action="/organizations" novalidate>
6
+    <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
7
+    <label>
8
+      <span>Slug</span>
9
+      <input type="text" name="slug" required
10
+             pattern="[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?"
11
+             title="lowercase letters, digits, and hyphens; 1–39 chars; cannot start or end with a hyphen"
12
+             value="{{ .Slug }}" autofocus>
13
+    </label>
14
+    <label>
15
+      <span>Display name</span>
16
+      <input type="text" name="display_name">
17
+    </label>
18
+    <label>
19
+      <span>Billing email (optional)</span>
20
+      <input type="email" name="billing_email" autocomplete="email">
21
+    </label>
22
+    <button type="submit" class="shithub-button shithub-button-primary">Create organization</button>
23
+  </form>
24
+  <p class="shithub-auth-aside">
25
+    The slug is your org's URL handle and must be unique across users + orgs. You can change the display name later.
26
+  </p>
27
+</section>
28
+{{- end }}
internal/web/templates/orgs/people.htmladded
@@ -0,0 +1,74 @@
1
+{{ define "page" -}}
2
+<section class="shithub-org-people">
3
+  <header class="shithub-org-profile-head">
4
+    <h1>{{ .Org.DisplayName }} · People</h1>
5
+    <p class="shithub-meta">@{{ .Org.Slug }}</p>
6
+  </header>
7
+
8
+  {{ if .IsOwner }}
9
+  <section class="shithub-org-invite">
10
+    <h2>Invite a new member</h2>
11
+    <form method="POST" action="/{{ .Org.Slug }}/people/invite">
12
+      <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
13
+      <label>
14
+        <span>Username or email</span>
15
+        <input type="text" name="target" required placeholder="@username or someone@example.com">
16
+      </label>
17
+      <label>
18
+        <span>Role</span>
19
+        <select name="role">
20
+          <option value="member" selected>Member</option>
21
+          <option value="owner">Owner</option>
22
+        </select>
23
+      </label>
24
+      <button type="submit" class="shithub-button shithub-button-primary">Invite</button>
25
+    </form>
26
+  </section>
27
+  {{ end }}
28
+
29
+  <section class="shithub-org-members">
30
+    <h2>Members ({{ len .Members }})</h2>
31
+    <table class="shithub-table">
32
+      <thead><tr><th>User</th><th>Role</th><th>Joined</th>{{ if .IsOwner }}<th></th>{{ end }}</tr></thead>
33
+      <tbody>
34
+        {{ range .Members }}
35
+        <tr>
36
+          <td><a href="/{{ .Username }}">@{{ .Username }}</a> {{ if .DisplayName }}<span class="shithub-meta">— {{ .DisplayName }}</span>{{ end }}</td>
37
+          <td>{{ .Role }}</td>
38
+          <td>{{ relativeTime .JoinedAt.Time }}</td>
39
+          {{ if $.IsOwner }}
40
+          <td>
41
+            <form method="POST" action="/{{ $.Org.Slug }}/people/{{ .UserID }}/role" style="display:inline">
42
+              <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
43
+              <select name="role" onchange="this.form.submit()" aria-label="Change role">
44
+                <option value="member" {{ if eq (printf "%s" .Role) "member" }}selected{{ end }}>Member</option>
45
+                <option value="owner" {{ if eq (printf "%s" .Role) "owner" }}selected{{ end }}>Owner</option>
46
+              </select>
47
+            </form>
48
+            <form method="POST" action="/{{ $.Org.Slug }}/people/{{ .UserID }}/remove" style="display:inline">
49
+              <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
50
+              <button type="submit" class="shithub-button">Remove</button>
51
+            </form>
52
+          </td>
53
+          {{ end }}
54
+        </tr>
55
+        {{ end }}
56
+      </tbody>
57
+    </table>
58
+  </section>
59
+
60
+  {{ if and .IsOwner .Pending }}
61
+  <section class="shithub-org-pending">
62
+    <h2>Pending invitations</h2>
63
+    <ul>
64
+    {{ range .Pending }}
65
+      <li>
66
+        {{ if .TargetUsername.Valid }}@{{ .TargetUsername.String }}{{ else }}{{ .TargetEmail.String }}{{ end }}
67
+        — {{ .Role }} — invited {{ relativeTime .CreatedAt.Time }}
68
+      </li>
69
+    {{ end }}
70
+    </ul>
71
+  </section>
72
+  {{ end }}
73
+</section>
74
+{{- end }}
internal/web/templates/orgs/profile.htmladded
@@ -0,0 +1,32 @@
1
+{{ define "page" -}}
2
+<section class="shithub-org-profile">
3
+  <header class="shithub-org-profile-head">
4
+    <h1>{{ .Org.DisplayName }}</h1>
5
+    <p class="shithub-meta">@{{ .Org.Slug }}</p>
6
+    {{ if .Org.Description }}<p>{{ .Org.Description }}</p>{{ end }}
7
+    <nav class="shithub-org-tabs">
8
+      <a href="/{{ .Org.Slug }}/people">People ({{ .MemberCount }})</a>
9
+      {{ if .IsOwner }}<a href="/{{ .Org.Slug }}/settings/profile">Settings</a>{{ end }}
10
+    </nav>
11
+  </header>
12
+  {{ if .Org.SuspendedAt.Valid }}
13
+  <p class="shithub-flash shithub-flash-error">This organization is suspended. Pushes are blocked; reads continue.</p>
14
+  {{ end }}
15
+  <section class="shithub-org-repos">
16
+    <h2>Repositories</h2>
17
+    {{ if .Repos }}
18
+      <ul class="shithub-search-list">
19
+      {{ range .Repos }}
20
+        <li>
21
+          <a href="/{{ $.Org.Slug }}/{{ .Name }}"><strong>{{ $.Org.Slug }}/{{ .Name }}</strong></a>
22
+          {{ if eq (printf "%s" .Visibility) "private" }}<span class="shithub-pill shithub-pill-private">private</span>{{ end }}
23
+          {{ if .Description }}<p class="shithub-meta">{{ .Description }}</p>{{ end }}
24
+        </li>
25
+      {{ end }}
26
+      </ul>
27
+    {{ else }}
28
+      <p class="shithub-empty">No repositories yet.</p>
29
+    {{ end }}
30
+  </section>
31
+</section>
32
+{{- end }}