Files
MeshTender/internal/core/orgs.go
T
2026-07-08 00:01:06 -04:00

593 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 340 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
}