mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-12 06:35:39 +00:00
593 lines
19 KiB
Go
593 lines
19 KiB
Go
package core
|
||
|
||
import (
|
||
"errors"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"github.com/go-chi/chi/v5"
|
||
|
||
"github.com/jleight/meshtender/internal/store"
|
||
"github.com/jleight/meshtender/internal/web"
|
||
)
|
||
|
||
// orgID resolves the {id} URL param (a slug) to the internal int64 primary key.
|
||
func (s *Handlers) orgID(r *http.Request) (int64, bool) {
|
||
id, err := s.Store.OrgIDBySlug(r.Context(), chi.URLParam(r, "id"))
|
||
return id, err == nil
|
||
}
|
||
|
||
// orgParam returns the raw slug from the {id} URL param, for building redirects.
|
||
func orgParam(r *http.Request) string { return chi.URLParam(r, "id") }
|
||
|
||
func orgErr(w http.ResponseWriter, r *http.Request, msg string) {
|
||
web.RedirectErr(w, r, "/orgs/"+orgParam(r), msg)
|
||
}
|
||
|
||
// pageMyOrgs lists the organizations the signed-in user belongs to (the app
|
||
// host's /orgs). Public discovery lives on the root host; a "Discover" button
|
||
// links there.
|
||
func (s *Handlers) pageMyOrgs(w http.ResponseWriter, r *http.Request) {
|
||
uid := s.Auth.CurrentUserID(r.Context())
|
||
mine, err := s.Store.ListOrgsForUser(r.Context(), uid)
|
||
if err != nil {
|
||
s.ServerError(w, r, "could not load organizations", err)
|
||
return
|
||
}
|
||
s.Render(w, r, "my_orgs.html", map[string]any{
|
||
"Orgs": mine,
|
||
"Error": r.URL.Query().Get("error"),
|
||
})
|
||
}
|
||
|
||
// pageNewOrg renders the standalone "create an organization" form.
|
||
func (s *Handlers) pageNewOrg(w http.ResponseWriter, r *http.Request) {
|
||
s.Render(w, r, "new_org.html", map[string]any{
|
||
"Error": r.URL.Query().Get("error"),
|
||
})
|
||
}
|
||
|
||
// handleCreateOrg creates an org with the current user as its first admin.
|
||
func (s *Handlers) handleCreateOrg(w http.ResponseWriter, r *http.Request) {
|
||
uid := s.Auth.CurrentUserID(r.Context())
|
||
name := strings.TrimSpace(r.FormValue("name"))
|
||
if name == "" || len(name) > 80 {
|
||
web.RedirectErr(w, r, "/orgs/new", "Enter an organization name.")
|
||
return
|
||
}
|
||
org, err := s.Store.CreateOrg(r.Context(), name, uid)
|
||
if err != nil {
|
||
web.RedirectErr(w, r, "/orgs/new", "Could not create organization.")
|
||
return
|
||
}
|
||
http.Redirect(w, r, "/orgs/"+org.Slug, http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin
|
||
}
|
||
|
||
// pageOrg shows an org's home. Members get the full management view; a signed-in
|
||
// non-member gets the public view (so the Join button works on the app host).
|
||
// Members reach the public page via the "View public page" action, which links to
|
||
// the root (public) host.
|
||
func (s *Handlers) pageOrg(w http.ResponseWriter, r *http.Request) {
|
||
uid := s.Auth.CurrentUserID(r.Context())
|
||
id, ok := s.orgID(r)
|
||
if !ok {
|
||
s.NotFound(w, r)
|
||
return
|
||
}
|
||
org, err := s.Store.GetOrg(r.Context(), id)
|
||
if err != nil {
|
||
s.NotFound(w, r)
|
||
return
|
||
}
|
||
role, isMember, err := s.Store.OrgRole(r.Context(), id, uid)
|
||
if err != nil {
|
||
s.ServerError(w, r, "could not load organization", err)
|
||
return
|
||
}
|
||
if !isMember {
|
||
// Render the public org view here on the app host (not the root host), where
|
||
// the user's session lives, so a non-member's "Join" button POSTs to the app
|
||
// host. The root host serves the same page to anonymous/external visitors.
|
||
s.renderOrgPublic(w, r, org, isMember, role == "admin")
|
||
return
|
||
}
|
||
members, err := s.Store.ListOrgMembers(r.Context(), id)
|
||
if err != nil {
|
||
s.ServerError(w, r, "could not load members", err)
|
||
return
|
||
}
|
||
repeaters, err := s.Store.ListOrgRepeaters(r.Context(), id)
|
||
if err != nil {
|
||
s.ServerError(w, r, "could not load repeaters", err)
|
||
return
|
||
}
|
||
links, err := s.Store.ListOrgLinks(r.Context(), id)
|
||
if err != nil {
|
||
s.ServerError(w, r, "could not load links", err)
|
||
return
|
||
}
|
||
mapped := 0
|
||
for _, rp := range repeaters {
|
||
if rp.HasLocation {
|
||
mapped++
|
||
}
|
||
}
|
||
adminCount := 0
|
||
for _, m := range members {
|
||
if m.Role == "admin" {
|
||
adminCount++
|
||
}
|
||
}
|
||
isAdmin := role == "admin"
|
||
data := map[string]any{
|
||
"Org": org,
|
||
"Nav": s.OrgNavFor(r.Context(), web.OrgNavArgs{OrgID: org.ID, Name: org.Name, Slug: org.Slug, Active: "home", IsMember: true, IsAdmin: isAdmin, Manage: true}),
|
||
"Role": role,
|
||
"IsAdmin": isAdmin,
|
||
"Members": members,
|
||
"Repeaters": repeaters,
|
||
"Links": links,
|
||
"PlatformsJS": web.LinkPlatformsJS(store.LinkPlatforms()),
|
||
"HasMap": mapped > 0,
|
||
"MemberCount": len(members),
|
||
"RepeaterCount": len(repeaters),
|
||
"AdminCount": adminCount,
|
||
"Self": uid,
|
||
"Error": r.URL.Query().Get("error"),
|
||
}
|
||
// Custom domains are hidden for now (infrastructure not in place); the
|
||
// org.html card and the /domains routes are disabled, so don't load them.
|
||
s.Render(w, r, "org.html", data)
|
||
}
|
||
|
||
// renderOrgPublic renders the shared public org page on the app host for a
|
||
// signed-in non-member, so the Join button's POST hits the app host (where the
|
||
// session lives). The data shape matches the marketing surface's anonymous
|
||
// rendering of the same template.
|
||
func (s *Handlers) renderOrgPublic(w http.ResponseWriter, r *http.Request, org *store.Org, isMember, isAdmin bool) {
|
||
admins, err := s.Store.ListOrgAdmins(r.Context(), org.ID)
|
||
if err != nil {
|
||
s.ServerError(w, r, "could not load organization", err)
|
||
return
|
||
}
|
||
memberCount, repeaterCount, err := s.Store.OrgCounts(r.Context(), org.ID)
|
||
if err != nil {
|
||
s.ServerError(w, r, "could not load organization", err)
|
||
return
|
||
}
|
||
pubReps, err := s.Store.ListPublicRepeaters(r.Context(), org.ID)
|
||
if err != nil {
|
||
s.ServerError(w, r, "could not load organization", err)
|
||
return
|
||
}
|
||
links, err := s.Store.ListOrgLinks(r.Context(), org.ID)
|
||
if err != nil {
|
||
s.ServerError(w, r, "could not load organization", err)
|
||
return
|
||
}
|
||
uid := s.Auth.CurrentUserID(r.Context())
|
||
s.Render(w, r, "org_public.html", map[string]any{
|
||
"Org": org,
|
||
// The public view never exposes the Members tab — membership isn't public,
|
||
// only the admin list is — so build the nav as a non-member.
|
||
"Nav": s.OrgNavFor(r.Context(), web.OrgNavArgs{
|
||
OrgID: org.ID, Name: org.Name, Slug: org.Slug, Active: "home",
|
||
IsMember: false, IsAdmin: isAdmin, Manage: false,
|
||
CanGoToOrg: isMember, CanJoin: uid != 0 && !isMember,
|
||
}),
|
||
"Admins": admins,
|
||
"MemberCount": memberCount,
|
||
"RepeaterCount": repeaterCount,
|
||
"Repeaters": pubReps,
|
||
"Links": links,
|
||
"HasMap": len(pubReps) > 0,
|
||
"IsMember": isMember,
|
||
"LoggedIn": uid != 0,
|
||
"CanJoin": uid != 0 && !isMember,
|
||
})
|
||
}
|
||
|
||
// pageOrgMembers lists an org's members (with role management for admins). It's
|
||
// members-only: the list carries personal info (names/usernames), so non-members
|
||
// get a 404 — there is no public version of this page.
|
||
func (s *Handlers) pageOrgMembers(w http.ResponseWriter, r *http.Request) {
|
||
uid := s.Auth.CurrentUserID(r.Context())
|
||
id, ok := s.orgID(r)
|
||
if !ok {
|
||
s.NotFound(w, r)
|
||
return
|
||
}
|
||
org, err := s.Store.GetOrg(r.Context(), id)
|
||
if err != nil {
|
||
s.NotFound(w, r)
|
||
return
|
||
}
|
||
role, isMember, err := s.Store.OrgRole(r.Context(), id, uid)
|
||
if err != nil {
|
||
s.ServerError(w, r, "could not load organization", err)
|
||
return
|
||
}
|
||
if !isMember {
|
||
s.NotFound(w, r)
|
||
return
|
||
}
|
||
members, err := s.Store.ListOrgMembers(r.Context(), id)
|
||
if err != nil {
|
||
s.ServerError(w, r, "could not load members", err)
|
||
return
|
||
}
|
||
s.Render(w, r, "org_members.html", map[string]any{
|
||
"Org": org,
|
||
"Nav": s.OrgNavFor(r.Context(), web.OrgNavArgs{OrgID: org.ID, Name: org.Name, Slug: org.Slug, Active: "members", IsMember: true, IsAdmin: role == "admin", Manage: true}),
|
||
"IsAdmin": role == "admin",
|
||
"Members": members,
|
||
"Self": uid,
|
||
"Error": r.URL.Query().Get("error"),
|
||
})
|
||
}
|
||
|
||
// pageOrgRepeaters lists an org's repeaters with a map. Members see every
|
||
// contributed repeater (with links); any other viewer sees only those opted into
|
||
// the public map. The root host serves the same page anonymously.
|
||
func (s *Handlers) pageOrgRepeaters(w http.ResponseWriter, r *http.Request) {
|
||
uid := s.Auth.CurrentUserID(r.Context())
|
||
id, ok := s.orgID(r)
|
||
if !ok {
|
||
s.NotFound(w, r)
|
||
return
|
||
}
|
||
org, err := s.Store.GetOrg(r.Context(), id)
|
||
if err != nil {
|
||
s.NotFound(w, r)
|
||
return
|
||
}
|
||
role, isMember, err := s.Store.OrgRole(r.Context(), id, uid)
|
||
if err != nil {
|
||
s.ServerError(w, r, "could not load organization", err)
|
||
return
|
||
}
|
||
rv, err := web.BuildRepeatersView(r.Context(), s.Store, id, isMember)
|
||
if err != nil {
|
||
s.ServerError(w, r, "could not load repeaters", err)
|
||
return
|
||
}
|
||
s.Render(w, r, "org_repeaters.html", map[string]any{
|
||
"Org": org,
|
||
"Nav": s.OrgNavFor(r.Context(), web.OrgNavArgs{
|
||
OrgID: org.ID, Name: org.Name, Slug: org.Slug, Active: "repeaters",
|
||
IsMember: isMember, IsAdmin: role == "admin", Manage: isMember,
|
||
CanGoToOrg: isMember, CanJoin: uid != 0 && !isMember,
|
||
}),
|
||
"Reps": rv,
|
||
})
|
||
}
|
||
|
||
// handleEditOrg updates an org's slug, name, description, and region (admin only).
|
||
// pageEditOrg renders the org profile edit modal fragment (admin only), loaded via
|
||
// htmx into the org Home page.
|
||
func (s *Handlers) pageEditOrg(w http.ResponseWriter, r *http.Request) {
|
||
id, ok := s.requireOrgAdmin(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
org, err := s.Store.GetOrg(r.Context(), id)
|
||
if err != nil {
|
||
s.NotFound(w, r)
|
||
return
|
||
}
|
||
s.renderProfileModal(w, r, org.Slug, org.Name, org.Slug, org.Region, org.Description, "")
|
||
}
|
||
|
||
// pageEditLinks renders the org links edit modal fragment (admin only), loaded via
|
||
// htmx into the org Home page.
|
||
func (s *Handlers) pageEditLinks(w http.ResponseWriter, r *http.Request) {
|
||
id, ok := s.requireOrgAdmin(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
links, err := s.Store.ListOrgLinks(r.Context(), id)
|
||
if err != nil {
|
||
s.ServerError(w, r, "could not load links", err)
|
||
return
|
||
}
|
||
s.renderLinksModal(w, r, orgParam(r), links, "")
|
||
}
|
||
|
||
// renderProfileModal renders the profile edit modal fragment with the given field
|
||
// values and optional error — reused by the GET (prefilled from the org) and the
|
||
// POST error re-render (repopulated from the submission, so no work is lost).
|
||
func (s *Handlers) renderProfileModal(w http.ResponseWriter, r *http.Request, orgSlug, name, slug, region, desc, errMsg string) {
|
||
s.Render(w, r, "org_edit_profile.html", map[string]any{
|
||
"OrgSlug": orgSlug, "Name": name, "Slug": slug, "Region": region,
|
||
"Description": desc, "Error": errMsg, "Layout": "org-profile-modal",
|
||
})
|
||
}
|
||
|
||
// renderLinksModal renders the links edit modal fragment with the given rows and
|
||
// optional error — reused by the GET and the POST error re-render.
|
||
func (s *Handlers) renderLinksModal(w http.ResponseWriter, r *http.Request, orgSlug string, links []store.OrgLink, errMsg string) {
|
||
s.Render(w, r, "org_edit_links.html", map[string]any{
|
||
"OrgSlug": orgSlug, "Links": links, "Platforms": store.LinkPlatforms(),
|
||
"Error": errMsg, "Layout": "org-links-modal",
|
||
})
|
||
}
|
||
|
||
// hxRedirect navigates the browser to url: for an htmx request (a modal form) via
|
||
// the HX-Redirect header so it does a full navigation that closes the modal;
|
||
// otherwise a plain 303 (no-JS / non-htmx fallback).
|
||
func (s *Handlers) hxRedirect(w http.ResponseWriter, r *http.Request, url string) {
|
||
if r.Header.Get("HX-Request") != "" {
|
||
w.Header().Set("HX-Redirect", url)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
http.Redirect(w, r, url, http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin
|
||
}
|
||
|
||
func (s *Handlers) handleEditOrg(w http.ResponseWriter, r *http.Request) {
|
||
id, ok := s.requireOrgAdmin(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
name := strings.TrimSpace(r.FormValue("name"))
|
||
slug := strings.ToLower(strings.TrimSpace(r.FormValue("slug")))
|
||
desc := strings.TrimSpace(r.FormValue("description"))
|
||
region := strings.TrimSpace(r.FormValue("region"))
|
||
// fail keeps the modal open with the entered values on an htmx submit; a non-htmx
|
||
// (no-JS) submit falls back to the full-page error redirect.
|
||
fail := func(msg string) {
|
||
if r.Header.Get("HX-Request") != "" {
|
||
s.renderProfileModal(w, r, orgParam(r), name, slug, region, desc, msg)
|
||
return
|
||
}
|
||
orgErr(w, r, msg)
|
||
}
|
||
if name == "" || len(name) > 80 {
|
||
fail("Enter an organization name.")
|
||
return
|
||
}
|
||
if !store.ValidOrgSlug(slug) {
|
||
fail("Slug must be 3–40 lowercase letters, numbers, and hyphens (and not reserved).")
|
||
return
|
||
}
|
||
desc = web.Clip(desc, 2000)
|
||
region = web.Clip(region, 120)
|
||
if err := s.Store.UpdateOrg(r.Context(), id, slug, name, desc, region); errors.Is(err, store.ErrDuplicate) {
|
||
fail("That URL slug is already taken.")
|
||
return
|
||
} else if err != nil {
|
||
fail("Could not save changes.")
|
||
return
|
||
}
|
||
// The slug may have changed; navigate to the new canonical URL (closing the modal).
|
||
s.hxRedirect(w, r, "/orgs/"+slug)
|
||
}
|
||
|
||
// handleSetOrgLinks replaces an org's whole set of social/site links from the
|
||
// repeatable rows posted by the profile editor (admin only). Rows with a blank
|
||
// URL are dropped, so removing a link is just clearing its row and saving.
|
||
func (s *Handlers) handleSetOrgLinks(w http.ResponseWriter, r *http.Request) {
|
||
id, ok := s.requireOrgAdmin(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
if err := r.ParseForm(); err != nil {
|
||
orgErr(w, r, "Could not save links.")
|
||
return
|
||
}
|
||
// The three fields are submitted as index-aligned parallel arrays: one entry
|
||
// each per row, in row order.
|
||
platforms := r.Form["link_platform"]
|
||
labels := r.Form["link_label"]
|
||
urls := r.Form["link_url"]
|
||
// entered mirrors the submitted rows verbatim so a validation error can
|
||
// re-render the editor with the user's work intact (see fail).
|
||
var entered []store.OrgLink
|
||
for i, u := range urls {
|
||
p := ""
|
||
if i < len(platforms) {
|
||
p = platforms[i]
|
||
}
|
||
l := ""
|
||
if i < len(labels) {
|
||
l = labels[i]
|
||
}
|
||
entered = append(entered, store.OrgLink{Platform: p, Label: strings.TrimSpace(l), URL: strings.TrimSpace(u)})
|
||
}
|
||
fail := func(msg string) {
|
||
if r.Header.Get("HX-Request") != "" {
|
||
s.renderLinksModal(w, r, orgParam(r), entered, msg)
|
||
return
|
||
}
|
||
orgErr(w, r, msg)
|
||
}
|
||
var links []store.OrgLink
|
||
for i, raw := range urls {
|
||
u := strings.TrimSpace(raw)
|
||
if u == "" {
|
||
continue // empty row — skip it
|
||
}
|
||
platform := ""
|
||
if i < len(platforms) {
|
||
platform = platforms[i]
|
||
}
|
||
p, ok := store.OrgLinkPlatform(platform)
|
||
if !ok {
|
||
fail("Choose a type for each link.")
|
||
return
|
||
}
|
||
// Validate/canonicalise per kind, leaving `u` as the value to persist.
|
||
switch p.Kind {
|
||
case store.KindText: // Discord — a username shown as text, or an invite link.
|
||
if store.LooksLikeURL(u) {
|
||
u = store.NormalizeLinkURL(u)
|
||
if !store.ValidLinkURL(u) {
|
||
fail("Enter a valid username or invite link.")
|
||
return
|
||
}
|
||
} else {
|
||
v := strings.TrimPrefix(u, "@")
|
||
if v == "" || len(v) > 64 || strings.ContainsAny(v, " \t\n\r") {
|
||
fail("Enter a valid username (no spaces) or an invite link.")
|
||
return
|
||
}
|
||
u = v
|
||
}
|
||
case store.KindHandle:
|
||
canon, ok := p.CanonicalHandleURL(u)
|
||
if !ok {
|
||
fail("Enter a valid " + p.Name + " username or profile URL.")
|
||
return
|
||
}
|
||
u = canon
|
||
default: // KindURL — accept a bare domain by assuming https:// before validating.
|
||
u = store.NormalizeLinkURL(u)
|
||
if !store.ValidLinkURL(u) {
|
||
fail("Each link must be a valid http:// or https:// URL.")
|
||
return
|
||
}
|
||
}
|
||
label := ""
|
||
if i < len(labels) {
|
||
label = strings.TrimSpace(labels[i])
|
||
}
|
||
u = web.Clip(u, 300)
|
||
label = web.Clip(label, 60)
|
||
links = append(links, store.OrgLink{Platform: platform, Label: label, URL: u})
|
||
if len(links) >= store.MaxOrgLinks {
|
||
break
|
||
}
|
||
}
|
||
if err := s.Store.ReplaceOrgLinks(r.Context(), id, links); err != nil {
|
||
fail("Could not save links.")
|
||
return
|
||
}
|
||
s.hxRedirect(w, r, "/orgs/"+orgParam(r))
|
||
}
|
||
|
||
// requireOrgAdmin resolves {id} and verifies the current user is an org admin.
|
||
func (s *Handlers) requireOrgAdmin(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||
uid := s.Auth.CurrentUserID(r.Context())
|
||
id, ok := s.orgID(r)
|
||
if !ok {
|
||
s.NotFound(w, r)
|
||
return 0, false
|
||
}
|
||
admin, err := s.Store.IsOrgAdmin(r.Context(), id, uid)
|
||
if err != nil || !admin {
|
||
s.NotFound(w, r)
|
||
return 0, false
|
||
}
|
||
return id, true
|
||
}
|
||
|
||
func (s *Handlers) handleLeaveOrg(w http.ResponseWriter, r *http.Request) {
|
||
uid := s.Auth.CurrentUserID(r.Context())
|
||
id, ok := s.orgID(r)
|
||
if !ok {
|
||
s.NotFound(w, r)
|
||
return
|
||
}
|
||
err := s.Store.RemoveOrgMember(r.Context(), id, uid)
|
||
if errors.Is(err, store.ErrLastAdmin) {
|
||
orgErr(w, r, "You're the last admin — promote someone else first.")
|
||
return
|
||
}
|
||
if err != nil {
|
||
orgErr(w, r, "Could not leave.")
|
||
return
|
||
}
|
||
http.Redirect(w, r, "/orgs", http.StatusSeeOther)
|
||
}
|
||
|
||
// pageJoinOrg shows the join confirmation. Because repeaters are shared with an
|
||
// org by default (opt-out), joining is an explicit choice: share all your current
|
||
// repeaters, or none of them.
|
||
func (s *Handlers) pageJoinOrg(w http.ResponseWriter, r *http.Request) {
|
||
uid := s.Auth.CurrentUserID(r.Context())
|
||
id, ok := s.orgID(r)
|
||
if !ok {
|
||
s.NotFound(w, r)
|
||
return
|
||
}
|
||
org, err := s.Store.GetOrg(r.Context(), id)
|
||
if err != nil {
|
||
s.NotFound(w, r)
|
||
return
|
||
}
|
||
_, isMember, err := s.Store.OrgRole(r.Context(), id, uid)
|
||
if err != nil {
|
||
s.ServerError(w, r, "could not load organization", err)
|
||
return
|
||
}
|
||
if isMember {
|
||
http.Redirect(w, r, "/orgs/"+org.Slug, http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin
|
||
return
|
||
}
|
||
hasRepeaters, err := s.Store.OwnsAnyRepeater(r.Context(), uid)
|
||
if err != nil {
|
||
s.ServerError(w, r, "could not load repeaters", err)
|
||
return
|
||
}
|
||
s.Render(w, r, "join_org.html", map[string]any{"Org": org, "HasRepeaters": hasRepeaters})
|
||
}
|
||
|
||
// handleJoinOrg adds the current user to an org as a member. mode "none" opts all
|
||
// of the user's current repeaters out of the org; otherwise they're shared (the
|
||
// default). Idempotent on membership.
|
||
func (s *Handlers) handleJoinOrg(w http.ResponseWriter, r *http.Request) {
|
||
uid := s.Auth.CurrentUserID(r.Context())
|
||
id, ok := s.orgID(r)
|
||
if !ok {
|
||
s.NotFound(w, r)
|
||
return
|
||
}
|
||
if err := s.Store.AddOrgMember(r.Context(), id, uid, "member"); err != nil {
|
||
orgErr(w, r, "Could not join.")
|
||
return
|
||
}
|
||
if r.FormValue("mode") == "none" {
|
||
if err := s.Store.ExcludeOwnerRepeatersFromOrg(r.Context(), id, uid); err != nil {
|
||
orgErr(w, r, "Joined, but could not opt your repeaters out — adjust them on the sharing page.")
|
||
return
|
||
}
|
||
}
|
||
http.Redirect(w, r, "/orgs/"+orgParam(r), http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin
|
||
}
|
||
|
||
// handleSetOrgMember promotes/demotes/removes a member (admin only).
|
||
func (s *Handlers) handleSetOrgMember(w http.ResponseWriter, r *http.Request) {
|
||
id, ok := s.requireOrgAdmin(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
membersURL := "/orgs/" + orgParam(r) + "/members"
|
||
memberErr := func(msg string) { web.RedirectErr(w, r, membersURL, msg) }
|
||
targetID, err := strconv.ParseInt(chi.URLParam(r, "userID"), 10, 64)
|
||
if err != nil {
|
||
s.NotFound(w, r)
|
||
return
|
||
}
|
||
switch r.FormValue("action") {
|
||
case "promote":
|
||
err = s.Store.SetOrgMemberRole(r.Context(), id, targetID, "admin")
|
||
case "demote":
|
||
err = s.Store.SetOrgMemberRole(r.Context(), id, targetID, "member")
|
||
case "remove":
|
||
err = s.Store.RemoveOrgMember(r.Context(), id, targetID)
|
||
default:
|
||
memberErr("Unknown action.")
|
||
return
|
||
}
|
||
if errors.Is(err, store.ErrLastAdmin) {
|
||
memberErr("That would leave the organization with no admin.")
|
||
return
|
||
}
|
||
if err != nil {
|
||
memberErr("Could not update member.")
|
||
return
|
||
}
|
||
http.Redirect(w, r, membersURL, http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin
|
||
}
|