Go · 15162 bytes Raw Blame History
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 "net/url"
26 "strconv"
27 "strings"
28
29 "github.com/go-chi/chi/v5"
30 "github.com/jackc/pgx/v5/pgxpool"
31
32 authemail "github.com/tenseleyFlow/shithub/internal/auth/email"
33 "github.com/tenseleyFlow/shithub/internal/infra/storage"
34 "github.com/tenseleyFlow/shithub/internal/orgs"
35 orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
36 "github.com/tenseleyFlow/shithub/internal/web/middleware"
37 "github.com/tenseleyFlow/shithub/internal/web/render"
38 )
39
40 // Deps wires the handler set.
41 type Deps struct {
42 Logger *slog.Logger
43 Render *render.Renderer
44 Pool *pgxpool.Pool
45 EmailSender authemail.Sender
46 EmailFrom string
47 SiteName string
48 BaseURL string
49 ObjectStore storage.ObjectStore
50 }
51
52 // Handlers groups the org surface handlers.
53 type Handlers struct {
54 d Deps
55 }
56
57 // New constructs the handler set, validating Deps.
58 func New(d Deps) (*Handlers, error) {
59 if d.Render == nil {
60 return nil, errors.New("orgs handlers: nil Render")
61 }
62 if d.Pool == nil {
63 return nil, errors.New("orgs handlers: nil Pool")
64 }
65 return &Handlers{d: d}, nil
66 }
67
68 // MountCreate registers /organizations/new + POST /organizations.
69 // Caller wraps these in RequireUser since both require a logged-in
70 // creator. The /organizations prefix is on the auth-reserved list so
71 // it never shadows a user/org slug.
72 func (h *Handlers) MountCreate(r chi.Router) {
73 r.Get("/organizations/new", h.newForm)
74 r.Post("/organizations", h.createSubmit)
75 r.Get("/organizations/{org}/settings/profile", h.settingsProfile)
76 r.Post("/organizations/{org}/settings/profile", h.settingsProfileSubmit)
77 r.Post("/organizations/{org}/settings/profile/avatar", h.settingsAvatarUpload)
78 r.Post("/organizations/{org}/settings/profile/avatar/remove", h.settingsAvatarRemove)
79 r.Post("/organizations/{org}/settings/delete", h.settingsDelete)
80 }
81
82 // MountOrgRoutes registers the per-org surface under /{org}/people
83 // and /{org}/settings. Caller MUST register this before the
84 // /{username} catch-all so the `people` segment matches.
85 //
86 // Member-management routes live behind RequireUser at the wiring
87 // layer (server.go); profile-style reads stay public.
88 func (h *Handlers) MountOrgRoutes(r chi.Router) {
89 r.Get("/{org}/people", h.peoplePage)
90 r.Post("/{org}/people/invite", h.invite)
91 r.Post("/{org}/people/{userID}/role", h.changeRole)
92 r.Post("/{org}/people/{userID}/remove", h.removeMember)
93 h.MountTeams(r)
94 }
95
96 // MountInvitations registers /invitations/{token}* — accept/decline.
97 // Authed-only; the page also shows a hint when the viewer's logged-in
98 // user doesn't match the invite's target email.
99 func (h *Handlers) MountInvitations(r chi.Router) {
100 r.Get("/invitations/{token}", h.invitationView)
101 r.Post("/invitations/{token}/accept", h.invitationAccept)
102 r.Post("/invitations/{token}/decline", h.invitationDecline)
103 }
104
105 // ─── helpers ───────────────────────────────────────────────────────
106
107 func (h *Handlers) deps() orgs.Deps {
108 return orgs.Deps{
109 Pool: h.d.Pool,
110 Logger: h.d.Logger,
111 EmailSender: h.d.EmailSender,
112 EmailFrom: h.d.EmailFrom,
113 SiteName: h.d.SiteName,
114 BaseURL: h.d.BaseURL,
115 }
116 }
117
118 // orgFromSlug resolves the org from a {org} URL param, with an
119 // existence-leak-safe 404 path.
120 func (h *Handlers) orgFromSlug(w http.ResponseWriter, r *http.Request) (orgsdb.Org, bool) {
121 slug := chi.URLParam(r, "org")
122 row, err := orgsdb.New().GetOrgBySlug(r.Context(), h.d.Pool, slug)
123 if err != nil {
124 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
125 return orgsdb.Org{}, false
126 }
127 return row, true
128 }
129
130 func parseUserIDParam(s string) (int64, error) {
131 return strconv.ParseInt(s, 10, 64)
132 }
133
134 // ─── create ────────────────────────────────────────────────────────
135
136 func (h *Handlers) newForm(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 h.renderNewForm(w, r, "", "")
143 }
144
145 func (h *Handlers) createSubmit(w http.ResponseWriter, r *http.Request) {
146 viewer := middleware.CurrentUserFromContext(r.Context())
147 if viewer.IsAnonymous() {
148 http.Redirect(w, r, "/login?next=/organizations/new", http.StatusSeeOther)
149 return
150 }
151 if err := r.ParseForm(); err != nil {
152 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
153 return
154 }
155 slug := strings.TrimSpace(r.PostFormValue("slug"))
156 displayName := strings.TrimSpace(r.PostFormValue("display_name"))
157 billingEmail := strings.TrimSpace(r.PostFormValue("billing_email"))
158
159 row, err := orgs.Create(r.Context(), h.deps(), orgs.CreateParams{
160 Slug: slug,
161 DisplayName: displayName,
162 BillingEmail: billingEmail,
163 CreatedByUserID: viewer.ID,
164 })
165 if err != nil {
166 h.renderNewForm(w, r, slug, friendlyOrgErr(err))
167 return
168 }
169 http.Redirect(w, r, "/"+row.Slug, http.StatusSeeOther)
170 }
171
172 func (h *Handlers) renderNewForm(w http.ResponseWriter, r *http.Request, slug, errMsg string) {
173 if err := h.d.Render.RenderPage(w, r, "orgs/new", map[string]any{
174 "Title": "New organization",
175 "CSRFToken": middleware.CSRFTokenForRequest(r),
176 "Slug": slug,
177 "Error": errMsg,
178 }); err != nil {
179 h.d.Logger.ErrorContext(r.Context(), "orgs: render", "tpl", "orgs/new", "error", err)
180 }
181 }
182
183 // ─── people ────────────────────────────────────────────────────────
184
185 func (h *Handlers) peoplePage(w http.ResponseWriter, r *http.Request) {
186 org, ok := h.orgFromSlug(w, r)
187 if !ok {
188 return
189 }
190 viewer := middleware.CurrentUserFromContext(r.Context())
191 q := orgsdb.New()
192 members, err := q.ListOrgMembers(r.Context(), h.d.Pool, org.ID)
193 if err != nil {
194 h.d.Logger.ErrorContext(r.Context(), "orgs: list members", "error", err)
195 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
196 return
197 }
198 query := strings.TrimSpace(r.URL.Query().Get("query"))
199 filteredMembers := filterOrgMembers(members, query)
200 var pending []orgsdb.ListPendingInvitationsForOrgRow
201 isOwner := false
202 if !viewer.IsAnonymous() {
203 isOwner, _ = orgs.IsOwner(r.Context(), h.deps(), org.ID, viewer.ID)
204 if isOwner {
205 pending, _ = q.ListPendingInvitationsForOrg(r.Context(), h.d.Pool, org.ID)
206 }
207 }
208 navCounts := h.orgNavCounts(r.Context(), org.ID, -1)
209 if err := h.d.Render.RenderPage(w, r, "orgs/people", map[string]any{
210 "Title": org.Slug + " · people",
211 "CSRFToken": middleware.CSRFTokenForRequest(r),
212 "Org": org,
213 "AvatarURL": "/avatars/" + url.PathEscape(org.Slug),
214 "ActiveOrgNav": "people",
215 "RepoCount": navCounts.RepoCount,
216 "Members": filteredMembers,
217 "MemberCount": navCounts.MemberCount,
218 "TeamCount": navCounts.TeamCount,
219 "Pending": pending,
220 "PendingCount": len(pending),
221 "Query": query,
222 "HasQuery": query != "",
223 "IsOwner": isOwner,
224 "CanManagePeople": isOwner,
225 }); err != nil {
226 h.d.Logger.ErrorContext(r.Context(), "orgs: render", "tpl", "orgs/people", "error", err)
227 }
228 }
229
230 func filterOrgMembers(members []orgsdb.ListOrgMembersRow, query string) []orgsdb.ListOrgMembersRow {
231 query = strings.ToLower(strings.TrimSpace(query))
232 if query == "" {
233 return members
234 }
235 out := make([]orgsdb.ListOrgMembersRow, 0, len(members))
236 for _, member := range members {
237 if strings.Contains(strings.ToLower(member.Username), query) ||
238 strings.Contains(strings.ToLower(member.DisplayName), query) ||
239 strings.Contains(strings.ToLower(string(member.Role)), query) {
240 out = append(out, member)
241 }
242 }
243 return out
244 }
245
246 func (h *Handlers) invite(w http.ResponseWriter, r *http.Request) {
247 org, ok := h.orgFromSlug(w, r)
248 if !ok {
249 return
250 }
251 viewer := middleware.CurrentUserFromContext(r.Context())
252 if viewer.IsAnonymous() {
253 h.d.Render.HTTPError(w, r, http.StatusUnauthorized, "")
254 return
255 }
256 // Suspended owners are denied with the same 403 as non-owners
257 // (SR2 C4). Org/team mutations don't currently route through
258 // policy.Can; this short-circuit mirrors the suspended-actor
259 // gate every other write surface enforces.
260 if viewer.IsSuspended {
261 h.d.Render.HTTPError(w, r, http.StatusForbidden, "")
262 return
263 }
264 owner, err := orgs.IsOwner(r.Context(), h.deps(), org.ID, viewer.ID)
265 if err != nil || !owner {
266 h.d.Render.HTTPError(w, r, http.StatusForbidden, "")
267 return
268 }
269 if err := r.ParseForm(); err != nil {
270 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
271 return
272 }
273 target := strings.TrimSpace(r.PostFormValue("target"))
274 role := r.PostFormValue("role")
275 if role == "" {
276 role = "member"
277 }
278 p := orgs.InviteParams{
279 OrgID: org.ID,
280 InvitedByUserID: viewer.ID,
281 Role: role,
282 }
283 if strings.Contains(target, "@") {
284 p.TargetEmail = target
285 } else {
286 p.TargetUsername = target
287 }
288 if _, err := orgs.Invite(r.Context(), h.deps(), p); err != nil {
289 h.d.Logger.WarnContext(r.Context(), "orgs: invite failed",
290 "org", org.Slug, "target", target, "error", err)
291 }
292 http.Redirect(w, r, "/"+org.Slug+"/people", http.StatusSeeOther)
293 }
294
295 func (h *Handlers) changeRole(w http.ResponseWriter, r *http.Request) {
296 h.memberMutate(w, r, func(orgID, userID int64) error {
297 role := r.PostFormValue("role")
298 return orgs.ChangeRole(r.Context(), h.deps(), orgID, userID, role)
299 })
300 }
301
302 func (h *Handlers) removeMember(w http.ResponseWriter, r *http.Request) {
303 h.memberMutate(w, r, func(orgID, userID int64) error {
304 return orgs.RemoveMember(r.Context(), h.deps(), orgID, userID)
305 })
306 }
307
308 // memberMutate is the shared owner-check + redirect wrapper for the
309 // member-management POSTs. Centralizes the policy gate so each route
310 // is one line.
311 func (h *Handlers) memberMutate(w http.ResponseWriter, r *http.Request, action func(orgID, userID int64) error) {
312 org, ok := h.orgFromSlug(w, r)
313 if !ok {
314 return
315 }
316 viewer := middleware.CurrentUserFromContext(r.Context())
317 if viewer.IsAnonymous() {
318 h.d.Render.HTTPError(w, r, http.StatusUnauthorized, "")
319 return
320 }
321 // Suspended owners denied like non-owners (SR2 C4).
322 if viewer.IsSuspended {
323 h.d.Render.HTTPError(w, r, http.StatusForbidden, "")
324 return
325 }
326 owner, _ := orgs.IsOwner(r.Context(), h.deps(), org.ID, viewer.ID)
327 if !owner {
328 h.d.Render.HTTPError(w, r, http.StatusForbidden, "")
329 return
330 }
331 if err := r.ParseForm(); err != nil {
332 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
333 return
334 }
335 uid, err := parseUserIDParam(chi.URLParam(r, "userID"))
336 if err != nil {
337 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
338 return
339 }
340 if err := action(org.ID, uid); err != nil {
341 h.d.Logger.WarnContext(r.Context(), "orgs: member mutation",
342 "org", org.Slug, "user_id", uid, "error", err)
343 }
344 http.Redirect(w, r, "/"+org.Slug+"/people", http.StatusSeeOther)
345 }
346
347 // ─── invitations ───────────────────────────────────────────────────
348
349 func (h *Handlers) invitationView(w http.ResponseWriter, r *http.Request) {
350 tok := chi.URLParam(r, "token")
351 inv, err := orgs.LookupInvitationByToken(r.Context(), h.deps(), tok)
352 if err != nil {
353 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
354 return
355 }
356 org, err := orgsdb.New().GetOrgByID(r.Context(), h.d.Pool, inv.OrgID)
357 if err != nil {
358 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
359 return
360 }
361 if err := h.d.Render.RenderPage(w, r, "orgs/invitation", map[string]any{
362 "Title": "Organization invitation",
363 "CSRFToken": middleware.CSRFTokenForRequest(r),
364 "Org": org,
365 "Invitation": inv,
366 "Token": tok,
367 }); err != nil {
368 h.d.Logger.ErrorContext(r.Context(), "orgs: render", "tpl", "orgs/invitation", "error", err)
369 }
370 }
371
372 func (h *Handlers) invitationAccept(w http.ResponseWriter, r *http.Request) {
373 h.invitationAction(w, r, true)
374 }
375
376 func (h *Handlers) invitationDecline(w http.ResponseWriter, r *http.Request) {
377 h.invitationAction(w, r, false)
378 }
379
380 func (h *Handlers) invitationAction(w http.ResponseWriter, r *http.Request, accept bool) {
381 viewer := middleware.CurrentUserFromContext(r.Context())
382 if viewer.IsAnonymous() {
383 http.Redirect(w, r, "/login?next="+r.URL.Path, http.StatusSeeOther)
384 return
385 }
386 // Suspended users can't act on invitations either way (SR2 C4).
387 // Joining an org while suspended would let them participate in
388 // org-scoped actions; declining is harmless but the consistent
389 // gate makes the surface uniform.
390 if viewer.IsSuspended {
391 h.d.Render.HTTPError(w, r, http.StatusForbidden, "")
392 return
393 }
394 tok := chi.URLParam(r, "token")
395 inv, err := orgs.LookupInvitationByToken(r.Context(), h.deps(), tok)
396 if err != nil {
397 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
398 return
399 }
400 if accept {
401 if err := orgs.AcceptInvitation(r.Context(), h.deps(), inv, viewer.ID); err != nil {
402 h.d.Logger.WarnContext(r.Context(), "orgs: accept invitation",
403 "id", inv.ID, "error", err)
404 h.d.Render.HTTPError(w, r, http.StatusForbidden, "")
405 return
406 }
407 } else {
408 if err := orgs.DeclineInvitation(r.Context(), h.deps(), inv, viewer.ID); err != nil {
409 h.d.Logger.WarnContext(r.Context(), "orgs: decline invitation",
410 "id", inv.ID, "error", err)
411 }
412 }
413 org, _ := orgsdb.New().GetOrgByID(r.Context(), h.d.Pool, inv.OrgID)
414 http.Redirect(w, r, "/"+org.Slug, http.StatusSeeOther)
415 }
416
417 // friendlyOrgErr maps orchestrator errors to user-facing strings.
418 // Unknown errors collapse to a generic message — the underlying err
419 // is logged at the call site.
420 func friendlyOrgErr(err error) string {
421 switch {
422 case errors.Is(err, orgs.ErrEmptySlug):
423 return "Slug is required."
424 case errors.Is(err, orgs.ErrSlugTooLong):
425 return "Slug too long (max 39 characters)."
426 case errors.Is(err, orgs.ErrSlugInvalid):
427 return "Slug must be lowercase letters, digits, or hyphens; cannot start or end with a hyphen."
428 case errors.Is(err, orgs.ErrSlugReserved):
429 return "That slug is reserved. Try another."
430 case errors.Is(err, orgs.ErrSlugTaken):
431 return "That slug is already in use. Try another."
432 }
433 return "Something went wrong creating the organization."
434 }
435