diff --git a/internal/core/endpoints_repeater_test.go b/internal/core/endpoints_repeater_test.go index a1df3d8..5597a13 100644 --- a/internal/core/endpoints_repeater_test.go +++ b/internal/core/endpoints_repeater_test.go @@ -193,16 +193,116 @@ func TestRepeaterSharePosts(t *testing.T) { un.Body.Close() assertRedirect(t, un, share, "unshare") - // #87 participation: exclude this repeater from an org the owner belongs to. + // #87 participation: the per-org "Shared" switch. Unchecked submits no + // "include" field (opt out); checked submits include=1 (participate). org, err := st.CreateOrg(ctx, "Participation Org", owner.ID) if err != nil { t.Fatal(err) } + excluded := func() bool { + orgs, err := st.ListRepeaterOrgMemberships(ctx, rep.ID) + if err != nil { + t.Fatalf("list memberships: %v", err) + } + for _, o := range orgs { + if o.OrgID == org.ID { + return o.Excluded + } + } + t.Fatalf("org %d not in memberships", org.ID) + return false + } // The {orgID} route param is the org slug, not the numeric id. - part := post(t, ts, h.app, "/repeaters/"+pid+"/orgs/"+org.Slug+"/participation", - url.Values{"action": {"exclude"}}, sess) - part.Body.Close() - assertRedirect(t, part, share, "org participation") + partURL := "/repeaters/" + pid + "/orgs/" + org.Slug + "/participation" + out := post(t, ts, h.app, partURL, url.Values{}, sess) // switch off → opt out + out.Body.Close() + assertRedirect(t, out, share, "org opt out") + if !excluded() { + t.Fatal("switch off did not opt the repeater out") + } + in := post(t, ts, h.app, partURL, url.Values{"include": {"1"}}, sess) // switch on + in.Body.Close() + assertRedirect(t, in, share, "org opt in") + if excluded() { + t.Fatal("switch on did not re-include the repeater") + } +} + +// TestRepeaterOrgLimitsPosts covers the per-(repeater, org) command-limits modal: +// the GET fragment renders the editor, and the POST restricts / collapses back to +// permissive. This is the share-page home for limits after they moved off the +// org-wide page and became per repeater. +func TestRepeaterOrgLimitsPosts(t *testing.T) { + t.Parallel() + st, ctx, ts, h := splitServer(t) + owner, sess := appLogin(t, ts, st, ctx, h.app, "limitowner") + rep := newOwnedRepeater(t, st, ctx, owner.ID, "Limited Rep") + org, err := st.CreateOrg(ctx, "Limits Org", owner.ID) // owner is an admin member + if err != nil { + t.Fatal(err) + } + base := "/repeaters/" + rep.PublicID + "/orgs/" + org.Slug + "/limits" + + // The ceiling: commands an org may ever run. Restrict to the first one. + catalog, err := st.ListCommands(ctx) + if err != nil { + t.Fatal(err) + } + var ceiling []int64 + for _, c := range catalog { + if c.OrgMemberAllowed || c.OrgAdminAllowed { + ceiling = append(ceiling, c.ID) + } + } + if len(ceiling) < 2 { + t.Fatalf("need >=2 ceiling commands, got %d", len(ceiling)) + } + + // GET renders the modal fragment (no page chrome), with the org name and cmd boxes. + frag := readBody(t, do(t, ts, h.app, base, sess)) + if !strings.Contains(frag, "Command limits") || !strings.Contains(frag, `name="cmd"`) { + t.Fatalf("limits fragment missing expected content:\n%s", frag) + } + if strings.Contains(frag, "back-link") { + t.Fatal("limits fragment should be modal chrome, not a full page") + } + + optIn := func() []int64 { + ids, err := st.RepeaterOrgOptInCommandIDs(ctx, org.ID, rep.ID) + if err != nil { + t.Fatalf("opt-in ids: %v", err) + } + return ids + } + + // Restrict to exactly the first ceiling command. + save := post(t, ts, h.app, base, url.Values{"cmd": {strconv.FormatInt(ceiling[0], 10)}}, sess) + save.Body.Close() + assertRedirect(t, save, "/repeaters/"+rep.PublicID+"/share", "save limits") + if got := optIn(); len(got) != 1 || got[0] != ceiling[0] { + t.Fatalf("opt-in = %v, want [%d]", got, ceiling[0]) + } + + // Selecting the full ceiling collapses back to permissive (no rows stored). + full := url.Values{} + for _, id := range ceiling { + full.Add("cmd", strconv.FormatInt(id, 10)) + } + fullResp := post(t, ts, h.app, base, full, sess) + fullResp.Body.Close() + assertRedirect(t, fullResp, "/repeaters/"+rep.PublicID+"/share", "save full ceiling") + if got := optIn(); len(got) != 0 { + t.Fatalf("full selection should store nothing (permissive), got %v", got) + } + + // Restrict again, then "Remove restriction" clears it. + post(t, ts, h.app, base, url.Values{"cmd": {strconv.FormatInt(ceiling[0], 10)}}, sess).Body.Close() + clr := post(t, ts, h.app, base, url.Values{"clear": {"1"}}, sess) + clr.Body.Close() + assertRedirect(t, clr, "/repeaters/"+rep.PublicID+"/share", "clear limits") + if got := optIn(); len(got) != 0 { + t.Fatalf("clear should remove all rows, got %v", got) + } } // #88 POST /invite/{token}/accept — a second user redeems a share link. diff --git a/internal/core/ignored_errors_test.go b/internal/core/ignored_errors_test.go index ca04ac2..5aeb2a3 100644 --- a/internal/core/ignored_errors_test.go +++ b/internal/core/ignored_errors_test.go @@ -42,3 +42,29 @@ func TestShareCommandsPageReadErrorFailsClosed(t *testing.T) { t.Fatalf("status = %d, want 500 (a swallowed error would render 200 with an empty, data-wiping form)", resp.StatusCode) } } + +// TestRepeaterOrgLimitsReadErrorFailsClosed: if loading a repeater's per-org opt-in +// list fails, the limits modal must 500 rather than render as "permissive" (all +// checked), which a Save would persist as clearing the real restriction. +func TestRepeaterOrgLimitsReadErrorFailsClosed(t *testing.T) { + t.Parallel() + st, ctx, ts, h := splitServer(t) + owner, sess := appLogin(t, ts, st, ctx, h.app, "limitowner") + rep := newOwnedRepeater(t, st, ctx, owner.ID, "Rep") + org, err := st.CreateOrg(ctx, "Org", owner.ID) // creator is an admin member + if err != nil { + t.Fatal(err) + } + + // Make RepeaterOrgOptInCommandIDs fail; the handler's earlier queries (org, + // membership, catalog ceiling) use other tables. + if _, err := st.Pool().Exec(ctx, `DROP TABLE org_repeater_command_optin`); err != nil { + t.Fatalf("drop org_repeater_command_optin: %v", err) + } + + resp := do(t, ts, h.app, "/repeaters/"+rep.PublicID+"/orgs/"+org.Slug+"/limits", sess) + resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500 (a swallowed error would render 200 as permissive, wiping the restriction on save)", resp.StatusCode) + } +} diff --git a/internal/core/org_participation.go b/internal/core/org_participation.go index a6e29b2..c93526d 100644 --- a/internal/core/org_participation.go +++ b/internal/core/org_participation.go @@ -2,6 +2,7 @@ package core import ( "net/http" + "strconv" "github.com/go-chi/chi/v5" @@ -33,16 +34,127 @@ func (s *Handlers) repeaterOrgContext(w http.ResponseWriter, r *http.Request) (* // handleSetRepeaterOrg opts a repeater into or out of an org (owner only). A // repeater participates in every org its owner belongs to by default; this writes -// or clears the opt-out. +// or clears the opt-out. Driven by the share page's per-org "Shared" switch: a +// checked switch submits include=1 (participate), unchecked submits nothing (opt +// out). func (s *Handlers) handleSetRepeaterOrg(w http.ResponseWriter, r *http.Request) { rep, orgID, ok := s.repeaterOrgContext(w, r) if !ok { return } - exclude := r.FormValue("action") == "exclude" + exclude := r.FormValue("include") != "1" if err := s.Store.SetRepeaterOrgExcluded(r.Context(), orgID, rep.ID, exclude); err != nil { s.ServerError(w, r, "could not update participation", err) return } http.Redirect(w, r, sharePath(rep.PublicID), http.StatusSeeOther) } + +// pageRepeaterOrgLimits renders the per-org command-limits modal fragment for one +// repeater: which of the commands the org may run are allowed to run on this box. +// No opt-in rows = permissive (every ceiling command checked). Editable regardless +// of participation, so an owner can pre-set limits before opting an org back in. +func (s *Handlers) pageRepeaterOrgLimits(w http.ResponseWriter, r *http.Request) { + rep, orgID, ok := s.repeaterOrgContext(w, r) + if !ok { + return + } + org, err := s.Store.GetOrg(r.Context(), orgID) + if err != nil { + s.NotFound(w, r) + return + } + ceiling, err := s.orgCeilingCommands(r) + if err != nil { + s.ServerError(w, r, "could not load commands", err) + return + } + // Must not swallow this error: an empty list reads as "permissive" (everything + // checked), and saving that would clear a real restriction. + optIn, err := s.Store.RepeaterOrgOptInCommandIDs(r.Context(), orgID, rep.ID) + if err != nil { + s.ServerError(w, r, "could not load commands", err) + return + } + restricted := len(optIn) > 0 + checked := make(map[int64]bool, len(ceiling)) + if restricted { + for _, cid := range optIn { + checked[cid] = true + } + } else { + for _, c := range ceiling { + checked[c.ID] = true + } + } + s.Render(w, r, "share_org_limits.html", map[string]any{ + "Repeater": rep, + "Org": org, + "Groups": groupCommands(ceiling, checked), + "Restricted": restricted, + "ShowAccess": true, + "Layout": "org-limits-modal", + }) +} + +// handleSaveRepeaterOrgLimits saves the per-(repeater, org) command opt-in list. A +// full selection (or the "Remove restriction" button) clears it back to permissive +// so we don't persist a redundant full allowlist. +func (s *Handlers) handleSaveRepeaterOrgLimits(w http.ResponseWriter, r *http.Request) { + rep, orgID, ok := s.repeaterOrgContext(w, r) + if !ok { + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", http.StatusBadRequest) + return + } + var chosen []int64 + // "Remove restriction" clears the list regardless of checkboxes. + if r.FormValue("clear") == "" { + ceiling, err := s.orgCeilingCommands(r) + if err != nil { + s.ServerError(w, r, "could not load commands", err) + return + } + chosen = parseCommandIDs(r.Form["cmd"]) + // Selecting the full ceiling is equivalent to permissive — store nothing. + if len(chosen) >= len(ceiling) { + chosen = nil + } + } + if err := s.Store.SetRepeaterOrgOptIn(r.Context(), orgID, rep.ID, chosen); err != nil { + s.ServerError(w, r, "could not save limits", err) + return + } + http.Redirect(w, r, sharePath(rep.PublicID), http.StatusSeeOther) +} + +// orgCeilingCommands returns the catalog commands an org is ever permitted to run +// (member or admin tier) — the universe the per-repeater opt-in editor restricts +// within. +func (s *Handlers) orgCeilingCommands(r *http.Request) ([]*store.Command, error) { + catalog, err := s.Store.ListCommands(r.Context()) + if err != nil { + return nil, err + } + var out []*store.Command + for _, c := range catalog { + if c.OrgMemberAllowed || c.OrgAdminAllowed { + out = append(out, c) + } + } + return out, nil +} + +// parseCommandIDs parses form values into catalog command ids, skipping any that +// aren't valid integers. +func parseCommandIDs(values []string) []int64 { + var ids []int64 + for _, v := range values { + if cid, err := strconv.ParseInt(v, 10, 64); err == nil { + ids = append(ids, cid) + } + } + return ids +} diff --git a/internal/core/shares.go b/internal/core/shares.go index da48978..129066b 100644 --- a/internal/core/shares.go +++ b/internal/core/shares.go @@ -295,12 +295,7 @@ func (s *Handlers) handleSetShareCommands(w http.ResponseWriter, r *http.Request http.Error(w, "bad form", http.StatusBadRequest) return } - var cmdIDs []int64 - for _, v := range r.Form["cmd"] { - if cid, err := strconv.ParseInt(v, 10, 64); err == nil { - cmdIDs = append(cmdIDs, cid) - } - } + cmdIDs := parseCommandIDs(r.Form["cmd"]) if err := s.Store.SetShareCommands(r.Context(), id, targetID, cmdIDs); err != nil { s.ServerError(w, r, "could not save commands", err) return diff --git a/internal/core/templates/share.html b/internal/core/templates/share.html index 30f7a38..e79097d 100644 --- a/internal/core/templates/share.html +++ b/internal/core/templates/share.html @@ -13,45 +13,9 @@ -
- This repeater is shared with every organization you belong to, so their admins and members can run - the commands those organizations are permitted to run on it. Opt it out of any organization here. To - limit which commands an organization may run across all your shared repeaters, use - Actions → Limit commands on that organization's page. -
- - {{if .Orgs}} -Join an organization to share this repeater with it.
- {{end}} -@@ -125,6 +89,61 @@ {{end}}
+ This repeater is shared with every organization you belong to, so their admins and members can run the + commands those organizations are permitted to run on it. Turn an organization off to opt out, or edit + its command limits for this repeater. +
+ + {{if .Orgs}} +Join an organization to share this repeater with it.
+ {{end}} +