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