Go · 23568 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/plan plan selection
6 // GET /organizations/new create form
7 // POST /organizations create submit
8 // GET /orgs/{org}/repositories repository list
9 // GET /{org}/people members + pending invites + invite form
10 // POST /{org}/people/invite invite by username or email
11 // POST /{org}/people/{user}/role change role
12 // POST /{org}/people/{user}/remove remove member
13 // GET /organizations/{org}/settings/profile profile settings
14 // GET /organizations/{org}/settings/import GitHub org import
15 // POST /organizations/{org}/settings/import start GitHub org import
16 // GET /organizations/{org}/imports/{importID} GitHub org import progress
17 // GET /organizations/{org}/settings/{secrets,variables}/actions
18 // POST /organizations/{org}/settings/{secrets,variables}/actions
19 // GET /invitations/{token} accept/decline view
20 // POST /invitations/{token}/accept accept
21 // POST /invitations/{token}/decline decline
22 //
23 // Profile rendering for /{org} is dispatched from the existing
24 // /{username} catch-all in internal/web/handlers/profile via the
25 // principals.Resolve lookup; this handler set only owns the org-
26 // specific surfaces.
27 package orgs
28
29 import (
30 "errors"
31 "log/slog"
32 "net/http"
33 "net/url"
34 "strconv"
35 "strings"
36 "time"
37
38 "github.com/go-chi/chi/v5"
39 "github.com/jackc/pgx/v5/pgxpool"
40
41 "github.com/tenseleyFlow/shithub/internal/auth/audit"
42 authemail "github.com/tenseleyFlow/shithub/internal/auth/email"
43 "github.com/tenseleyFlow/shithub/internal/auth/secretbox"
44 "github.com/tenseleyFlow/shithub/internal/billing/stripebilling"
45 "github.com/tenseleyFlow/shithub/internal/entitlements"
46 "github.com/tenseleyFlow/shithub/internal/infra/storage"
47 "github.com/tenseleyFlow/shithub/internal/orgs"
48 orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
49 "github.com/tenseleyFlow/shithub/internal/web/middleware"
50 "github.com/tenseleyFlow/shithub/internal/web/render"
51 )
52
53 // Deps wires the handler set.
54 type Deps struct {
55 Logger *slog.Logger
56 Render *render.Renderer
57 Pool *pgxpool.Pool
58 EmailSender authemail.Sender
59 EmailFrom string
60 SiteName string
61 BaseURL string
62 ObjectStore storage.ObjectStore
63 SecretBox *secretbox.Box
64 Audit *audit.Recorder
65 BillingEnabled bool
66 BillingGracePeriod time.Duration
67 Stripe stripebilling.Remote
68 StripeSuccessURL string
69 StripeCancelURL string
70 StripePortalReturnURL string
71 }
72
73 // Handlers groups the org surface handlers.
74 type Handlers struct {
75 d Deps
76 }
77
78 // New constructs the handler set, validating Deps.
79 func New(d Deps) (*Handlers, error) {
80 if d.Render == nil {
81 return nil, errors.New("orgs handlers: nil Render")
82 }
83 if d.Pool == nil {
84 return nil, errors.New("orgs handlers: nil Pool")
85 }
86 if d.Audit == nil {
87 d.Audit = audit.NewRecorder()
88 }
89 return &Handlers{d: d}, nil
90 }
91
92 // MountCreate registers /organizations/plan, /organizations/new, POST /organizations, and
93 // organization settings routes under /organizations/{org}/settings/*.
94 // Caller wraps these in RequireUser since they require a logged-in
95 // actor. The /organizations prefix is on the auth-reserved list so it
96 // never shadows a user/org slug.
97 func (h *Handlers) MountCreate(r chi.Router) {
98 r.Get("/organizations/plan", h.planSelection)
99 r.Get("/organizations/new", h.newForm)
100 r.Post("/organizations", h.createSubmit)
101 r.Get("/organizations/{org}/settings/profile", h.settingsProfile)
102 r.Post("/organizations/{org}/settings/profile", h.settingsProfileSubmit)
103 r.Post("/organizations/{org}/settings/profile/avatar", h.settingsAvatarUpload)
104 r.Post("/organizations/{org}/settings/profile/avatar/remove", h.settingsAvatarRemove)
105 r.Post("/organizations/{org}/settings/delete", h.settingsDelete)
106 r.Get("/organizations/{org}/settings/import", h.settingsImport)
107 r.Post("/organizations/{org}/settings/import", h.settingsImportSubmit)
108 r.Get("/organizations/{org}/imports/{importID}", h.importProgress)
109 r.Get("/organizations/{org}/settings/secrets/actions", h.settingsActionsSecrets)
110 r.Post("/organizations/{org}/settings/secrets/actions", h.settingsActionsSecretSet)
111 r.Post("/organizations/{org}/settings/secrets/actions/{name}/delete", h.settingsActionsSecretDelete)
112 r.Get("/organizations/{org}/settings/variables/actions", h.settingsActionsVariables)
113 r.Post("/organizations/{org}/settings/variables/actions", h.settingsActionsVariableSet)
114 r.Post("/organizations/{org}/settings/variables/actions/{name}/delete", h.settingsActionsVariableDelete)
115 if h.billingConfigured() {
116 r.Get("/organizations/{org}/settings/billing", h.settingsBilling)
117 r.Post("/organizations/{org}/billing/checkout", h.billingCheckout)
118 r.Post("/organizations/{org}/billing/portal", h.billingPortal)
119 r.Get("/organizations/{org}/billing/success", h.billingSuccess)
120 r.Get("/organizations/{org}/billing/cancel", h.billingCancel)
121 }
122 }
123
124 // MountOrgRoutes registers the per-org surface under /{org}/people
125 // and /{org}/settings. Caller MUST register this before the
126 // /{username} catch-all so the `people` segment matches.
127 //
128 // Member-management routes live behind RequireUser at the wiring
129 // layer (server.go); profile-style reads stay public.
130 func (h *Handlers) MountOrgRoutes(r chi.Router) {
131 r.Get("/{org}/people", h.peoplePage)
132 r.Post("/{org}/people/invite", h.invite)
133 r.Post("/{org}/people/{userID}/role", h.changeRole)
134 r.Post("/{org}/people/{userID}/remove", h.removeMember)
135 h.MountTeams(r)
136 }
137
138 // MountInvitations registers /invitations/{token}* — accept/decline.
139 // Authed-only; the page also shows a hint when the viewer's logged-in
140 // user doesn't match the invite's target email.
141 func (h *Handlers) MountInvitations(r chi.Router) {
142 r.Get("/invitations/{token}", h.invitationView)
143 r.Post("/invitations/{token}/accept", h.invitationAccept)
144 r.Post("/invitations/{token}/decline", h.invitationDecline)
145 }
146
147 func (h *Handlers) MountBillingWebhook(r chi.Router) {
148 if !h.billingConfigured() {
149 return
150 }
151 r.Post("/stripe/webhook", h.billingWebhook)
152 }
153
154 // ─── helpers ───────────────────────────────────────────────────────
155
156 func (h *Handlers) deps() orgs.Deps {
157 return orgs.Deps{
158 Pool: h.d.Pool,
159 Logger: h.d.Logger,
160 EmailSender: h.d.EmailSender,
161 EmailFrom: h.d.EmailFrom,
162 SiteName: h.d.SiteName,
163 BaseURL: h.d.BaseURL,
164 }
165 }
166
167 func (h *Handlers) billingConfigured() bool {
168 return h.d.BillingEnabled && h.d.Stripe != nil
169 }
170
171 // orgFromSlug resolves the org from a {org} URL param, with an
172 // existence-leak-safe 404 path.
173 func (h *Handlers) orgFromSlug(w http.ResponseWriter, r *http.Request) (orgsdb.Org, bool) {
174 slug := chi.URLParam(r, "org")
175 row, err := orgsdb.New().GetOrgBySlug(r.Context(), h.d.Pool, slug)
176 if err != nil {
177 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
178 return orgsdb.Org{}, false
179 }
180 return row, true
181 }
182
183 func parseUserIDParam(s string) (int64, error) {
184 return strconv.ParseInt(s, 10, 64)
185 }
186
187 // ─── create ────────────────────────────────────────────────────────
188
189 func (h *Handlers) planSelection(w http.ResponseWriter, r *http.Request) {
190 viewer := middleware.CurrentUserFromContext(r.Context())
191 if viewer.IsAnonymous() {
192 http.Redirect(w, r, "/login?next=/organizations/plan", http.StatusSeeOther)
193 return
194 }
195 h.renderPlanSelection(w, r, "")
196 }
197
198 func (h *Handlers) newForm(w http.ResponseWriter, r *http.Request) {
199 viewer := middleware.CurrentUserFromContext(r.Context())
200 if viewer.IsAnonymous() {
201 http.Redirect(w, r, "/login?next=/organizations/new", http.StatusSeeOther)
202 return
203 }
204 requestedPlan := requestedOrgCreatePlan(r.URL.Query().Get("plan"))
205 if h.billingConfigured() && requestedPlan == "" {
206 h.renderPlanSelection(w, r, "")
207 return
208 }
209 plan := normalizeOrgCreatePlan(requestedPlan, h.billingConfigured())
210 if plan == orgCreatePlanEnterprise {
211 h.renderPlanSelection(w, r, "Enterprise organizations are contact-sales only today.")
212 return
213 }
214 h.renderNewForm(w, r, orgCreateForm{SelectedTier: plan}, "")
215 }
216
217 type orgCreateForm struct {
218 SelectedTier string
219 Slug string
220 DisplayName string
221 BillingEmail string
222 GitHubOrg string
223 GitHubToken string
224 AcceptTerms bool
225 }
226
227 func (h *Handlers) createSubmit(w http.ResponseWriter, r *http.Request) {
228 viewer := middleware.CurrentUserFromContext(r.Context())
229 if viewer.IsAnonymous() {
230 http.Redirect(w, r, "/login?next=/organizations/new", http.StatusSeeOther)
231 return
232 }
233 if err := r.ParseForm(); err != nil {
234 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
235 return
236 }
237 form := orgCreateForm{
238 SelectedTier: normalizeOrgCreatePlan(r.PostFormValue("plan"), h.billingConfigured()),
239 Slug: strings.TrimSpace(r.PostFormValue("slug")),
240 DisplayName: strings.TrimSpace(r.PostFormValue("display_name")),
241 BillingEmail: strings.TrimSpace(r.PostFormValue("billing_email")),
242 GitHubOrg: strings.TrimSpace(r.PostFormValue("github_org")),
243 GitHubToken: strings.TrimSpace(r.PostFormValue("github_token")),
244 AcceptTerms: r.PostFormValue("accept_terms") != "",
245 }
246 if form.SelectedTier == orgCreatePlanEnterprise {
247 h.renderPlanSelection(w, r, "Enterprise organizations are contact-sales only today.")
248 return
249 }
250 if !form.AcceptTerms {
251 h.renderNewForm(w, r, form.withoutToken(), "You must accept the terms to create an organization.")
252 return
253 }
254 if form.GitHubOrg != "" {
255 if _, err := orgs.NormalizeGitHubOrg(form.GitHubOrg); err != nil {
256 h.renderNewForm(w, r, form, "GitHub organization must be a valid organization name or github.com organization URL.")
257 return
258 }
259 if form.GitHubToken != "" && h.d.SecretBox == nil {
260 h.renderNewForm(w, r, form.withoutToken(), "GitHub token imports require the server secret key to be configured.")
261 return
262 }
263 }
264
265 row, err := orgs.Create(r.Context(), h.deps(), orgs.CreateParams{
266 Slug: form.Slug,
267 DisplayName: form.DisplayName,
268 BillingEmail: form.BillingEmail,
269 CreatedByUserID: viewer.ID,
270 })
271 if err != nil {
272 h.renderNewForm(w, r, form.withoutToken(), friendlyOrgErr(err))
273 return
274 }
275 if form.GitHubOrg != "" {
276 imp, err := orgs.StartGitHubImport(r.Context(), orgs.ImportDeps{
277 Pool: h.d.Pool, Box: h.d.SecretBox, Logger: h.d.Logger,
278 }, orgs.StartGitHubImportParams{
279 OrgID: row.ID, SourceOrg: form.GitHubOrg,
280 RequestedByUserID: viewer.ID, Token: form.GitHubToken,
281 })
282 if err != nil {
283 h.d.Logger.WarnContext(r.Context(), "orgs: start GitHub import after create", "error", err, "org_id", row.ID)
284 http.Redirect(w, r, "/organizations/"+row.Slug+"/settings/import?notice=start-failed", http.StatusSeeOther)
285 return
286 }
287 if form.SelectedTier == orgCreatePlanTeam && h.billingConfigured() {
288 h.redirectToTeamCheckout(w, r, row)
289 return
290 }
291 http.Redirect(w, r, "/organizations/"+row.Slug+"/imports/"+strconv.FormatInt(imp.ID, 10), http.StatusSeeOther)
292 return
293 }
294 if form.SelectedTier == orgCreatePlanTeam && h.billingConfigured() {
295 h.redirectToTeamCheckout(w, r, row)
296 return
297 }
298 http.Redirect(w, r, "/"+row.Slug, http.StatusSeeOther)
299 }
300
301 func (h *Handlers) redirectToTeamCheckout(w http.ResponseWriter, r *http.Request, org orgsdb.Org) {
302 sessionURL, err := h.startBillingCheckout(r, org)
303 if err != nil {
304 h.d.Logger.ErrorContext(r.Context(), "orgs: start team checkout after create", "org_id", org.ID, "error", err)
305 http.Redirect(w, r, orgBillingSettingsPath(org.Slug)+"?notice=team-checkout-failed", http.StatusSeeOther)
306 return
307 }
308 http.Redirect(w, r, sessionURL, http.StatusSeeOther)
309 }
310
311 func (f orgCreateForm) withoutToken() orgCreateForm {
312 f.GitHubToken = ""
313 return f
314 }
315
316 func (h *Handlers) renderNewForm(w http.ResponseWriter, r *http.Request, form orgCreateForm, errMsg string) {
317 if err := h.d.Render.RenderPage(w, r, "orgs/new", map[string]any{
318 "Title": orgCreateTitle(form.SelectedTier),
319 "CSRFToken": middleware.CSRFTokenForRequest(r),
320 "Slug": form.Slug,
321 "Form": form,
322 "Error": errMsg,
323 }); err != nil {
324 h.d.Logger.ErrorContext(r.Context(), "orgs: render", "tpl", "orgs/new", "error", err)
325 }
326 }
327
328 const (
329 orgCreatePlanFree = "free"
330 orgCreatePlanTeam = "team"
331 orgCreatePlanEnterprise = "enterprise"
332 )
333
334 func requestedOrgCreatePlan(raw string) string {
335 switch strings.ToLower(strings.TrimSpace(raw)) {
336 case orgCreatePlanFree, orgCreatePlanTeam, orgCreatePlanEnterprise:
337 return strings.ToLower(strings.TrimSpace(raw))
338 default:
339 return ""
340 }
341 }
342
343 func normalizeOrgCreatePlan(raw string, billingConfigured bool) string {
344 switch requestedOrgCreatePlan(raw) {
345 case orgCreatePlanTeam:
346 if billingConfigured {
347 return orgCreatePlanTeam
348 }
349 case orgCreatePlanEnterprise:
350 if billingConfigured {
351 return orgCreatePlanEnterprise
352 }
353 }
354 return orgCreatePlanFree
355 }
356
357 func orgCreateTitle(plan string) string {
358 if plan == orgCreatePlanTeam {
359 return "Set up your organization"
360 }
361 return "New organization"
362 }
363
364 func (h *Handlers) renderPlanSelection(w http.ResponseWriter, r *http.Request, errMsg string) {
365 if err := h.d.Render.RenderPage(w, r, "orgs/new_plan", map[string]any{
366 "Title": "Pick a plan for your organization",
367 "Error": errMsg,
368 "BillingConfigured": h.billingConfigured(),
369 }); err != nil {
370 h.d.Logger.ErrorContext(r.Context(), "orgs: render", "tpl", "orgs/new_plan", "error", err)
371 }
372 }
373
374 // ─── people ────────────────────────────────────────────────────────
375
376 func (h *Handlers) peoplePage(w http.ResponseWriter, r *http.Request) {
377 org, ok := h.orgFromSlug(w, r)
378 if !ok {
379 return
380 }
381 viewer := middleware.CurrentUserFromContext(r.Context())
382 q := orgsdb.New()
383 members, err := q.ListOrgMembers(r.Context(), h.d.Pool, org.ID)
384 if err != nil {
385 h.d.Logger.ErrorContext(r.Context(), "orgs: list members", "error", err)
386 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
387 return
388 }
389 query := strings.TrimSpace(r.URL.Query().Get("query"))
390 filteredMembers := filterOrgMembers(members, query)
391 var pending []orgsdb.ListPendingInvitationsForOrgRow
392 isOwner := false
393 if !viewer.IsAnonymous() {
394 isOwner, _ = orgs.IsOwner(r.Context(), h.deps(), org.ID, viewer.ID)
395 if isOwner {
396 pending, _ = q.ListPendingInvitationsForOrg(r.Context(), h.d.Pool, org.ID)
397 }
398 }
399 navCounts := h.orgNavCounts(r.Context(), org.ID, -1)
400 if err := h.d.Render.RenderPage(w, r, "orgs/people", map[string]any{
401 "Title": org.Slug + " · people",
402 "CSRFToken": middleware.CSRFTokenForRequest(r),
403 "Org": org,
404 "AvatarURL": "/avatars/" + url.PathEscape(org.Slug),
405 "ActiveOrgNav": "people",
406 "RepoCount": navCounts.RepoCount,
407 "Members": filteredMembers,
408 "MemberCount": navCounts.MemberCount,
409 "TeamCount": navCounts.TeamCount,
410 "Pending": pending,
411 "PendingCount": len(pending),
412 "Query": query,
413 "HasQuery": query != "",
414 "IsOwner": isOwner,
415 "CanManagePeople": isOwner,
416 "Notice": peopleNoticeMessage(r.URL.Query().Get("notice")),
417 }); err != nil {
418 h.d.Logger.ErrorContext(r.Context(), "orgs: render", "tpl", "orgs/people", "error", err)
419 }
420 }
421
422 func peopleNoticeMessage(code string) string {
423 switch code {
424 case "private-collab-upgrade":
425 return "Free organizations can have up to 3 private collaborators. Upgrade to Team to add more private collaborators."
426 default:
427 return ""
428 }
429 }
430
431 func filterOrgMembers(members []orgsdb.ListOrgMembersRow, query string) []orgsdb.ListOrgMembersRow {
432 query = strings.ToLower(strings.TrimSpace(query))
433 if query == "" {
434 return members
435 }
436 out := make([]orgsdb.ListOrgMembersRow, 0, len(members))
437 for _, member := range members {
438 if strings.Contains(strings.ToLower(member.Username), query) ||
439 strings.Contains(strings.ToLower(member.DisplayName), query) ||
440 strings.Contains(strings.ToLower(string(member.Role)), query) {
441 out = append(out, member)
442 }
443 }
444 return out
445 }
446
447 func (h *Handlers) invite(w http.ResponseWriter, r *http.Request) {
448 org, ok := h.orgFromSlug(w, r)
449 if !ok {
450 return
451 }
452 viewer := middleware.CurrentUserFromContext(r.Context())
453 if viewer.IsAnonymous() {
454 h.d.Render.HTTPError(w, r, http.StatusUnauthorized, "")
455 return
456 }
457 // Suspended owners are denied with the same 403 as non-owners
458 // (SR2 C4). Org/team mutations don't currently route through
459 // policy.Can; this short-circuit mirrors the suspended-actor
460 // gate every other write surface enforces.
461 if viewer.IsSuspended {
462 h.d.Render.HTTPError(w, r, http.StatusForbidden, "")
463 return
464 }
465 owner, err := orgs.IsOwner(r.Context(), h.deps(), org.ID, viewer.ID)
466 if err != nil || !owner {
467 h.d.Render.HTTPError(w, r, http.StatusForbidden, "")
468 return
469 }
470 if err := r.ParseForm(); err != nil {
471 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
472 return
473 }
474 target := strings.TrimSpace(r.PostFormValue("target"))
475 role := r.PostFormValue("role")
476 if role == "" {
477 role = "member"
478 }
479 p := orgs.InviteParams{
480 OrgID: org.ID,
481 InvitedByUserID: viewer.ID,
482 Role: role,
483 }
484 if strings.Contains(target, "@") {
485 p.TargetEmail = target
486 } else {
487 p.TargetUsername = target
488 }
489 if _, err := orgs.Invite(r.Context(), h.deps(), p); err != nil {
490 h.d.Logger.WarnContext(r.Context(), "orgs: invite failed",
491 "org", org.Slug, "target", target, "error", err)
492 if errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) {
493 http.Redirect(w, r, "/"+org.Slug+"/people?notice=private-collab-upgrade", http.StatusSeeOther)
494 return
495 }
496 }
497 http.Redirect(w, r, "/"+org.Slug+"/people", http.StatusSeeOther)
498 }
499
500 func (h *Handlers) changeRole(w http.ResponseWriter, r *http.Request) {
501 h.memberMutate(w, r, func(orgID, userID int64) error {
502 role := r.PostFormValue("role")
503 return orgs.ChangeRole(r.Context(), h.deps(), orgID, userID, role)
504 })
505 }
506
507 func (h *Handlers) removeMember(w http.ResponseWriter, r *http.Request) {
508 h.memberMutate(w, r, func(orgID, userID int64) error {
509 return orgs.RemoveMember(r.Context(), h.deps(), orgID, userID)
510 })
511 }
512
513 // memberMutate is the shared owner-check + redirect wrapper for the
514 // member-management POSTs. Centralizes the policy gate so each route
515 // is one line.
516 func (h *Handlers) memberMutate(w http.ResponseWriter, r *http.Request, action func(orgID, userID int64) error) {
517 org, ok := h.orgFromSlug(w, r)
518 if !ok {
519 return
520 }
521 viewer := middleware.CurrentUserFromContext(r.Context())
522 if viewer.IsAnonymous() {
523 h.d.Render.HTTPError(w, r, http.StatusUnauthorized, "")
524 return
525 }
526 // Suspended owners denied like non-owners (SR2 C4).
527 if viewer.IsSuspended {
528 h.d.Render.HTTPError(w, r, http.StatusForbidden, "")
529 return
530 }
531 owner, _ := orgs.IsOwner(r.Context(), h.deps(), org.ID, viewer.ID)
532 if !owner {
533 h.d.Render.HTTPError(w, r, http.StatusForbidden, "")
534 return
535 }
536 if err := r.ParseForm(); err != nil {
537 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
538 return
539 }
540 uid, err := parseUserIDParam(chi.URLParam(r, "userID"))
541 if err != nil {
542 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
543 return
544 }
545 if err := action(org.ID, uid); err != nil {
546 h.d.Logger.WarnContext(r.Context(), "orgs: member mutation",
547 "org", org.Slug, "user_id", uid, "error", err)
548 if errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) {
549 http.Redirect(w, r, "/"+org.Slug+"/people?notice=private-collab-upgrade", http.StatusSeeOther)
550 return
551 }
552 }
553 http.Redirect(w, r, "/"+org.Slug+"/people", http.StatusSeeOther)
554 }
555
556 // ─── invitations ───────────────────────────────────────────────────
557
558 func (h *Handlers) invitationView(w http.ResponseWriter, r *http.Request) {
559 tok := chi.URLParam(r, "token")
560 inv, err := orgs.LookupInvitationByToken(r.Context(), h.deps(), tok)
561 if err != nil {
562 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
563 return
564 }
565 org, err := orgsdb.New().GetOrgByID(r.Context(), h.d.Pool, inv.OrgID)
566 if err != nil {
567 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
568 return
569 }
570 if err := h.d.Render.RenderPage(w, r, "orgs/invitation", map[string]any{
571 "Title": "Organization invitation",
572 "CSRFToken": middleware.CSRFTokenForRequest(r),
573 "Org": org,
574 "Invitation": inv,
575 "Token": tok,
576 }); err != nil {
577 h.d.Logger.ErrorContext(r.Context(), "orgs: render", "tpl", "orgs/invitation", "error", err)
578 }
579 }
580
581 func (h *Handlers) invitationAccept(w http.ResponseWriter, r *http.Request) {
582 h.invitationAction(w, r, true)
583 }
584
585 func (h *Handlers) invitationDecline(w http.ResponseWriter, r *http.Request) {
586 h.invitationAction(w, r, false)
587 }
588
589 func (h *Handlers) invitationAction(w http.ResponseWriter, r *http.Request, accept bool) {
590 viewer := middleware.CurrentUserFromContext(r.Context())
591 if viewer.IsAnonymous() {
592 http.Redirect(w, r, "/login?next="+r.URL.Path, http.StatusSeeOther)
593 return
594 }
595 // Suspended users can't act on invitations either way (SR2 C4).
596 // Joining an org while suspended would let them participate in
597 // org-scoped actions; declining is harmless but the consistent
598 // gate makes the surface uniform.
599 if viewer.IsSuspended {
600 h.d.Render.HTTPError(w, r, http.StatusForbidden, "")
601 return
602 }
603 tok := chi.URLParam(r, "token")
604 inv, err := orgs.LookupInvitationByToken(r.Context(), h.deps(), tok)
605 if err != nil {
606 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
607 return
608 }
609 if accept {
610 if err := orgs.AcceptInvitation(r.Context(), h.deps(), inv, viewer.ID); err != nil {
611 h.d.Logger.WarnContext(r.Context(), "orgs: accept invitation",
612 "id", inv.ID, "error", err)
613 if errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) {
614 h.d.Render.HTTPError(w, r, http.StatusPaymentRequired, "")
615 return
616 }
617 h.d.Render.HTTPError(w, r, http.StatusForbidden, "")
618 return
619 }
620 } else {
621 if err := orgs.DeclineInvitation(r.Context(), h.deps(), inv, viewer.ID); err != nil {
622 h.d.Logger.WarnContext(r.Context(), "orgs: decline invitation",
623 "id", inv.ID, "error", err)
624 }
625 }
626 org, _ := orgsdb.New().GetOrgByID(r.Context(), h.d.Pool, inv.OrgID)
627 http.Redirect(w, r, "/"+org.Slug, http.StatusSeeOther)
628 }
629
630 // friendlyOrgErr maps orchestrator errors to user-facing strings.
631 // Unknown errors collapse to a generic message — the underlying err
632 // is logged at the call site.
633 func friendlyOrgErr(err error) string {
634 switch {
635 case errors.Is(err, orgs.ErrEmptySlug):
636 return "Slug is required."
637 case errors.Is(err, orgs.ErrSlugTooLong):
638 return "Slug too long (max 39 characters)."
639 case errors.Is(err, orgs.ErrSlugInvalid):
640 return "Slug must be lowercase letters, digits, or hyphens; cannot start or end with a hyphen."
641 case errors.Is(err, orgs.ErrSlugReserved):
642 return "That slug is reserved. Try another."
643 case errors.Is(err, orgs.ErrSlugTaken):
644 return "That slug is already in use. Try another."
645 }
646 return "Something went wrong creating the organization."
647 }
648