mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-02 01:38:17 +00:00
Simpler permissions and consent
This commit is contained in:
@@ -10,3 +10,6 @@ auto = true
|
||||
|
||||
[tasks.dev]
|
||||
run = "go run ./cmd/meshtender"
|
||||
|
||||
[tasks.reset]
|
||||
run = "go run ./cmd/meshtender --reset"
|
||||
|
||||
@@ -4,6 +4,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -27,6 +28,11 @@ func main() {
|
||||
}
|
||||
|
||||
func run(logger *slog.Logger) error {
|
||||
var reset bool
|
||||
flag.BoolVar(&reset, "reset", false,
|
||||
"truncate all data except users, passkeys, sessions, and the server identity, then exit")
|
||||
flag.Parse()
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
@@ -46,6 +52,14 @@ func run(logger *slog.Logger) error {
|
||||
}
|
||||
logger.Info("migrations applied")
|
||||
|
||||
if reset {
|
||||
if err := st.Reset(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info("database reset — kept users, passkeys, sessions, and the server identity")
|
||||
return nil
|
||||
}
|
||||
|
||||
idSvc, err := identity.LoadOrCreate(ctx, st, cfg.MasterKey)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -37,7 +37,5 @@ func orderFeatures(present []string) {
|
||||
})
|
||||
}
|
||||
|
||||
// The feature×operation table builder (FeatureTableFor) lives in internal/web so
|
||||
// both the app host and the root host can build it for the read-only consent /
|
||||
// requested-access views; this file keeps only the feature ordering, still used
|
||||
// by groupByFeature for the catalog/editor groupings.
|
||||
// This file keeps the feature ordering used by groupByFeature for the catalog and
|
||||
// share/org command-selection groupings.
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/jleight/meshtender/internal/store"
|
||||
"github.com/jleight/meshtender/internal/web"
|
||||
)
|
||||
|
||||
// orgContext resolves the {id} repeater (owned) and {orgID} the user belongs to.
|
||||
func (s *Handlers) orgContext(w http.ResponseWriter, r *http.Request) (*store.Repeater, int64, bool) {
|
||||
owner := s.Auth.CurrentUserID(r.Context())
|
||||
id, ok := s.repeaterID(r)
|
||||
orgID, oerr := s.Store.OrgIDBySlug(r.Context(), chi.URLParam(r, "orgID"))
|
||||
if !ok || oerr != nil {
|
||||
http.NotFound(w, r)
|
||||
return nil, 0, false
|
||||
}
|
||||
rep, err := s.Store.GetRepeaterOwned(r.Context(), owner, id)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return nil, 0, false
|
||||
}
|
||||
if _, isMember, err := s.Store.OrgRole(r.Context(), orgID, owner); err != nil || !isMember {
|
||||
http.NotFound(w, r) // can only contribute to orgs you belong to
|
||||
return nil, 0, false
|
||||
}
|
||||
return rep, orgID, true
|
||||
}
|
||||
|
||||
// pageContribute shows the org's current permission envelope for the owner to
|
||||
// review before consenting (also used for re-consent).
|
||||
func (s *Handlers) pageContribute(w http.ResponseWriter, r *http.Request) {
|
||||
rep, orgID, ok := s.orgContext(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
org, err := s.Store.GetOrg(r.Context(), orgID)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
versionID, version, err := s.Store.CurrentVersion(r.Context(), orgID)
|
||||
if err != nil {
|
||||
http.Error(w, "could not load policy", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
adminIDs, memberIDs, err := s.Store.VersionCommandIDs(r.Context(), versionID)
|
||||
if err != nil {
|
||||
http.Error(w, "could not load policy", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
catalog, err := s.Store.ListCommands(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "could not load commands", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
members := idSet(memberIDs)
|
||||
data := map[string]any{
|
||||
"Repeater": rep,
|
||||
"Org": org,
|
||||
"Version": version,
|
||||
"MemberFeatures": web.FeatureTableFor(catalog, members),
|
||||
// Admins inherit every member command, so their table is member ∪ admin.
|
||||
"AdminFeatures": web.FeatureTableFor(catalog, union(idSet(adminIDs), members)),
|
||||
}
|
||||
|
||||
// If already contributed and behind the current version, show what changed
|
||||
// since the owner last consented.
|
||||
cvID, contributed, _ := s.Store.ConsentedVersionID(r.Context(), orgID, rep.ID)
|
||||
if contributed && cvID != versionID {
|
||||
cAdmin, cMember, err1 := s.Store.VersionCommandIDs(r.Context(), cvID)
|
||||
consentedNum, err2 := s.Store.VersionNumber(r.Context(), cvID)
|
||||
if err1 == nil && err2 == nil {
|
||||
tmpl := map[int64]string{}
|
||||
for _, c := range catalog {
|
||||
tmpl[c.ID] = c.Template
|
||||
}
|
||||
consented := union(idSet(cAdmin), idSet(cMember))
|
||||
current := union(idSet(adminIDs), idSet(memberIDs))
|
||||
data["Reconsent"] = true
|
||||
data["ConsentedVersion"] = consentedNum
|
||||
data["Added"] = templatesFor(current, consented, tmpl) // newly granted
|
||||
data["Removed"] = templatesFor(consented, current, tmpl) // no longer granted
|
||||
if notes, err := s.Store.VersionNotesSince(r.Context(), orgID, consentedNum); err == nil {
|
||||
data["Notes"] = notes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.Render(w, r, "contribute.html", data)
|
||||
}
|
||||
|
||||
// pageConsented shows, read-only, the exact commands this repeater is currently
|
||||
// consented to grant the org — the detail behind "consented to vN" on the
|
||||
// sharing page. It renders the consented version, which may lag the org's
|
||||
// current one.
|
||||
func (s *Handlers) pageConsented(w http.ResponseWriter, r *http.Request) {
|
||||
rep, orgID, ok := s.orgContext(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
org, err := s.Store.GetOrg(r.Context(), orgID)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
cvID, contributed, err := s.Store.ConsentedVersionID(r.Context(), orgID, rep.ID)
|
||||
if err != nil || !contributed {
|
||||
http.NotFound(w, r) // not contributed → nothing consented to view
|
||||
return
|
||||
}
|
||||
version, err := s.Store.VersionNumber(r.Context(), cvID)
|
||||
if err != nil {
|
||||
http.Error(w, "could not load policy", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
adminIDs, memberIDs, err := s.Store.VersionCommandIDs(r.Context(), cvID)
|
||||
if err != nil {
|
||||
http.Error(w, "could not load policy", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
catalog, err := s.Store.ListCommands(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "could not load commands", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
members := idSet(memberIDs)
|
||||
data := map[string]any{
|
||||
"Repeater": rep,
|
||||
"Org": org,
|
||||
"Version": version,
|
||||
"MemberFeatures": web.FeatureTableFor(catalog, members),
|
||||
"AdminFeatures": web.FeatureTableFor(catalog, union(idSet(adminIDs), members)),
|
||||
}
|
||||
// Note (with a re-consent link) when the org has moved past this version.
|
||||
if _, current, err := s.Store.CurrentVersion(r.Context(), orgID); err == nil && current > version {
|
||||
data["CurrentVersion"] = current
|
||||
}
|
||||
s.Render(w, r, "consented.html", data)
|
||||
}
|
||||
|
||||
// union returns the set union of two id sets.
|
||||
func union(a, b map[int64]bool) map[int64]bool {
|
||||
out := make(map[int64]bool, len(a)+len(b))
|
||||
for id := range a {
|
||||
out[id] = true
|
||||
}
|
||||
for id := range b {
|
||||
out[id] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// templatesFor returns the command templates for ids in `in` but not in `notIn`.
|
||||
func templatesFor(in, notIn map[int64]bool, tmpl map[int64]string) []string {
|
||||
var out []string
|
||||
for id := range in {
|
||||
if !notIn[id] {
|
||||
if t := tmpl[id]; t != "" {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// handleContribute pins the repeater to the org's current permission version.
|
||||
func (s *Handlers) handleContribute(w http.ResponseWriter, r *http.Request) {
|
||||
rep, orgID, ok := s.orgContext(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
versionID, _, err := s.Store.CurrentVersion(r.Context(), orgID)
|
||||
if err != nil {
|
||||
http.Error(w, "could not load policy", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
owner := s.Auth.CurrentUserID(r.Context())
|
||||
if err := s.Store.ContributeRepeater(r.Context(), orgID, rep.ID, versionID, owner); err != nil {
|
||||
http.Error(w, "could not contribute", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/repeaters/"+rep.PublicID+"/share", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleWithdraw removes the repeater from the org.
|
||||
func (s *Handlers) handleWithdraw(w http.ResponseWriter, r *http.Request) {
|
||||
rep, orgID, ok := s.orgContext(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := s.Store.WithdrawRepeater(r.Context(), orgID, rep.ID); err != nil {
|
||||
http.Error(w, "could not withdraw", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/repeaters/"+rep.PublicID+"/share", http.StatusSeeOther)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/jleight/meshtender/internal/store"
|
||||
)
|
||||
|
||||
// repeaterOrgContext resolves the {id} repeater (must be owned by the caller) and
|
||||
// the {orgID} org slug (the caller must belong to it). It's the gate for the
|
||||
// per-org participation toggle a repeater owner controls.
|
||||
func (s *Handlers) repeaterOrgContext(w http.ResponseWriter, r *http.Request) (*store.Repeater, int64, bool) {
|
||||
owner := s.Auth.CurrentUserID(r.Context())
|
||||
id, ok := s.repeaterID(r)
|
||||
orgID, oerr := s.Store.OrgIDBySlug(r.Context(), chi.URLParam(r, "orgID"))
|
||||
if !ok || oerr != nil {
|
||||
http.NotFound(w, r)
|
||||
return nil, 0, false
|
||||
}
|
||||
rep, err := s.Store.GetRepeaterOwned(r.Context(), owner, id)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return nil, 0, false
|
||||
}
|
||||
if _, isMember, err := s.Store.OrgRole(r.Context(), orgID, owner); err != nil || !isMember {
|
||||
http.NotFound(w, r) // can only manage participation in orgs you belong to
|
||||
return nil, 0, false
|
||||
}
|
||||
return rep, orgID, true
|
||||
}
|
||||
|
||||
// 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.
|
||||
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"
|
||||
if err := s.Store.SetRepeaterOrgExcluded(r.Context(), orgID, rep.ID, exclude); err != nil {
|
||||
http.Error(w, "could not update participation", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, sharePath(rep.PublicID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// pageOrgCommands lets a member restrict, for one org, which of the commands that
|
||||
// org is permitted to run actually run on the member's repeaters. No restriction
|
||||
// (the default) means every command in the org's ceiling can run.
|
||||
func (s *Handlers) pageOrgCommands(w http.ResponseWriter, r *http.Request) {
|
||||
uid := s.Auth.CurrentUserID(r.Context())
|
||||
id, ok := s.orgID(r)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
org, err := s.Store.GetOrg(r.Context(), id)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if _, isMember, err := s.Store.OrgRole(r.Context(), id, uid); err != nil || !isMember {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
ceiling, err := s.orgCeilingCommands(r)
|
||||
if err != nil {
|
||||
http.Error(w, "could not load commands", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
optIn, _ := s.Store.OrgOptInCommandIDs(r.Context(), id, uid)
|
||||
restricted := len(optIn) > 0
|
||||
// Permissive (no list) shows everything checked: all ceiling commands may run.
|
||||
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, "org_commands.html", map[string]any{
|
||||
"Org": org,
|
||||
"Groups": groupCommands(ceiling, checked),
|
||||
"Restricted": restricted,
|
||||
})
|
||||
}
|
||||
|
||||
// handleSaveOrgCommands saves the member's per-org opt-in list. If every ceiling
|
||||
// command is selected (or none of the modes restrict), the list is cleared back to
|
||||
// permissive so we don't persist a redundant full allowlist.
|
||||
func (s *Handlers) handleSaveOrgCommands(w http.ResponseWriter, r *http.Request) {
|
||||
uid := s.Auth.CurrentUserID(r.Context())
|
||||
id, ok := s.orgID(r)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if _, isMember, err := s.Store.OrgRole(r.Context(), id, uid); err != nil || !isMember {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// "Remove restriction" clears the list regardless of checkboxes.
|
||||
if r.FormValue("clear") != "" {
|
||||
if err := s.Store.SetOrgOptIn(r.Context(), id, uid, nil); err != nil {
|
||||
http.Error(w, "could not save", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/orgs/"+orgParam(r)+"/my-commands", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
ceiling, err := s.orgCeilingCommands(r)
|
||||
if err != nil {
|
||||
http.Error(w, "could not load commands", http.StatusInternalServerError)
|
||||
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.SetOrgOptIn(r.Context(), id, uid, chosen); err != nil {
|
||||
http.Error(w, "could not save", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/orgs/"+orgParam(r)+"/my-commands", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// orgCeilingCommands returns the catalog commands an org is ever permitted to run
|
||||
// (member or admin tier) — the universe the 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 a slice of 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
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/jleight/meshtender/internal/store"
|
||||
"github.com/jleight/meshtender/internal/web"
|
||||
)
|
||||
|
||||
// permGroup is a category of catalog commands for the org permission editor,
|
||||
// with per-tier checkbox state.
|
||||
type permGroup = categoryGroup[permChoice]
|
||||
|
||||
type permChoice struct {
|
||||
ID int64
|
||||
Template string
|
||||
Args string
|
||||
Risky bool
|
||||
AdminChecked bool
|
||||
MemberChecked bool
|
||||
}
|
||||
|
||||
func groupPermissions(catalog []*store.Command, admin, member map[int64]bool) []permGroup {
|
||||
return groupByFeature(catalog, func(c *store.Command) permChoice {
|
||||
return permChoice{
|
||||
ID: c.ID, Template: c.Template, Args: c.Args, Risky: c.Risky,
|
||||
AdminChecked: admin[c.ID], MemberChecked: member[c.ID],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func idSet(ids []int64) map[int64]bool {
|
||||
m := make(map[int64]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
m[id] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// pageOrgPermissions renders the org's requested-access policy read-only. Visible
|
||||
// to any signed-in user (so prospective members can see what they'd consent to);
|
||||
// admins get an Edit button. The root host serves the same page anonymously.
|
||||
func (s *Handlers) pageOrgPermissions(w http.ResponseWriter, r *http.Request) {
|
||||
uid := s.Auth.CurrentUserID(r.Context())
|
||||
id, ok := s.orgID(r)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
org, err := s.Store.GetOrg(r.Context(), id)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
role, isMember, err := s.Store.OrgRole(r.Context(), id, uid)
|
||||
if err != nil {
|
||||
http.Error(w, "could not load org", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
pv, err := web.BuildPermissionsView(r.Context(), s.Store, id)
|
||||
if err != nil {
|
||||
http.Error(w, "could not load policy", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.Render(w, r, "org_permissions.html", map[string]any{
|
||||
"Org": org,
|
||||
"Nav": web.OrgNav(org.Slug, "permissions", isMember),
|
||||
"CanEdit": role == "admin",
|
||||
"Perms": pv,
|
||||
})
|
||||
}
|
||||
|
||||
// pageOrgPermissionsEdit is the admin editor for the org's requested-access policy.
|
||||
func (s *Handlers) pageOrgPermissionsEdit(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 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
versionID, version, err := s.Store.CurrentVersion(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, "could not load policy", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
adminIDs, memberIDs, err := s.Store.VersionCommandIDs(r.Context(), versionID)
|
||||
if err != nil {
|
||||
http.Error(w, "could not load policy", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
catalog, err := s.Store.ListCommands(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "could not load commands", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.Render(w, r, "permissions_edit.html", map[string]any{
|
||||
"Org": org,
|
||||
"Nav": web.OrgNav(org.Slug, "permissions", true),
|
||||
"Version": version,
|
||||
"Groups": groupPermissions(catalog, idSet(adminIDs), idSet(memberIDs)),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Handlers) handleSaveOrgPermissions(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := s.requireOrgAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
parse := func(field string) []int64 {
|
||||
var ids []int64
|
||||
for _, v := range r.Form[field] {
|
||||
if cid, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
ids = append(ids, cid)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
uid := s.Auth.CurrentUserID(r.Context())
|
||||
note := r.FormValue("note")
|
||||
if _, err := s.Store.PublishVersion(r.Context(), id, note, uid, parse("admin"), parse("member")); err != nil {
|
||||
http.Error(w, "could not publish", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/orgs/"+orgParam(r)+"/permissions", http.StatusSeeOther)
|
||||
}
|
||||
+9
-26
@@ -16,7 +16,6 @@ import (
|
||||
// pageShare renders the sharing page for a repeater the user owns: the current
|
||||
// share link (if any) and the list of people who have accepted.
|
||||
func (s *Handlers) pageShare(w http.ResponseWriter, r *http.Request) {
|
||||
uid := s.Auth.CurrentUserID(r.Context())
|
||||
rep, id, ok := s.requireRepeaterOwned(w, r)
|
||||
if !ok {
|
||||
return
|
||||
@@ -31,36 +30,20 @@ func (s *Handlers) pageShare(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "could not load links", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Organizations section: orgs this repeater is contributed to, plus orgs the
|
||||
// owner belongs to but hasn't contributed it to yet.
|
||||
contributed, err := s.Store.ListRepeaterOrgs(r.Context(), id)
|
||||
// Organizations section: every org the owner belongs to, with whether this
|
||||
// repeater participates (the default) or has been opted out.
|
||||
orgs, err := s.Store.ListRepeaterOrgMemberships(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, "could not load orgs", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
memberships, err := s.Store.ListOrgsForUser(r.Context(), uid)
|
||||
if err != nil {
|
||||
http.Error(w, "could not load memberships", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
in := map[int64]bool{}
|
||||
for _, c := range contributed {
|
||||
in[c.OrgID] = true
|
||||
}
|
||||
var available []*store.Org
|
||||
for _, m := range memberships {
|
||||
if !in[m.Org.ID] {
|
||||
available = append(available, m.Org)
|
||||
}
|
||||
}
|
||||
s.Render(w, r, "share.html", map[string]any{
|
||||
"Repeater": rep,
|
||||
"Shares": shares,
|
||||
"Invites": invites,
|
||||
"Contributed": contributed,
|
||||
"Available": available,
|
||||
"BaseURL": s.absoluteURL(r, ""),
|
||||
"Error": r.URL.Query().Get("error"),
|
||||
"Repeater": rep,
|
||||
"Shares": shares,
|
||||
"Invites": invites,
|
||||
"Orgs": orgs,
|
||||
"BaseURL": s.absoluteURL(r, ""),
|
||||
"Error": r.URL.Query().Get("error"),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -11,10 +11,11 @@
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<p class="text-secondary mb-0">
|
||||
The firmware commands MeshTender can send. A repeater owner can run anything; these flags seed what
|
||||
others are offered — <strong>share</strong> = default for a new one-off share, <strong>member</strong>/<strong>admin</strong>
|
||||
= the org default tiers. <strong>Risky</strong> commands can lock the owner out or brick a node. Hover a
|
||||
command to see what it does.
|
||||
The firmware commands MeshTender can send. A repeater owner can run anything. <strong>Share</strong> seeds
|
||||
the default command set for a new one-off share. <strong>Member</strong>/<strong>admin</strong> are the
|
||||
ceiling of what an organization may ever run on a contributed repeater, by tier. <strong>Risky</strong>
|
||||
commands can lock the owner out or brick a node — leave these out of the org tiers. Hover a command to
|
||||
see what it does.
|
||||
</p>
|
||||
{{if .Saved}}<div class="alert alert-success mt-3 mb-0">Saved</div>{{end}}
|
||||
</div>
|
||||
@@ -41,8 +42,8 @@
|
||||
<td class="text-truncate"><code title="{{.Description}}" style="cursor:help">{{.Template}}</code></td>
|
||||
<td class="text-center"><input class="form-check-input cc-risky m-0" type="checkbox" form="cmd-{{.ID}}" name="risky" aria-label="risky" {{if .Risky}}checked{{end}}></td>
|
||||
<td class="text-center"><input class="form-check-input cc-share m-0" type="checkbox" form="cmd-{{.ID}}" name="share" aria-label="share" {{if .InShareDefault}}checked{{end}}></td>
|
||||
<td class="text-center"><input class="form-check-input cc-member m-0" type="checkbox" form="cmd-{{.ID}}" name="org_member" aria-label="member" {{if .InOrgMemberDefault}}checked{{end}}></td>
|
||||
<td class="text-center"><input class="form-check-input cc-admin m-0" type="checkbox" form="cmd-{{.ID}}" name="org_admin" aria-label="admin" {{if .InOrgAdminDefault}}checked{{end}}></td>
|
||||
<td class="text-center"><input class="form-check-input cc-member m-0" type="checkbox" form="cmd-{{.ID}}" name="org_member" aria-label="member" {{if .OrgMemberAllowed}}checked{{end}}></td>
|
||||
<td class="text-center"><input class="form-check-input cc-admin m-0" type="checkbox" form="cmd-{{.ID}}" name="org_admin" aria-label="admin" {{if .OrgAdminAllowed}}checked{{end}}></td>
|
||||
<td class="text-end"><form id="cmd-{{.ID}}" method="post" action="/admin/catalog/{{.ID}}" class="m-0"><button type="submit" class="btn btn-sm">Save</button></form></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
{{define "title"}}{{.Repeater.Name}} · {{.Org.Name}} commands · MeshTender{{end}}
|
||||
{{define "header"}}
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col">
|
||||
<div class="page-pretitle">Consented to {{.Org.Name}} · v{{.Version}}</div>
|
||||
<h2 class="page-title">{{.Repeater.Name}}</h2>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{define "content"}}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<p class="text-secondary mb-0">
|
||||
These are the commands {{.Org.Name}}'s admins and members can run on this repeater over the mesh — the
|
||||
policy you consented to (v{{.Version}}). Hover a command to see what it does.
|
||||
</p>
|
||||
{{if .CurrentVersion}}
|
||||
<div class="alert alert-warning mt-3 mb-0">
|
||||
{{.Org.Name}} has since published v{{.CurrentVersion}}. This repeater stays on v{{.Version}} until you
|
||||
<a class="alert-link" href="/repeaters/{{.Repeater.PublicID}}/orgs/{{.Org.Slug}}/contribute">review the changes and re-consent</a>.
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header"><h3 class="card-title">Members can run</h3></div>
|
||||
<div class="card-body">
|
||||
{{if .MemberFeatures}}{{template "feature-table" .MemberFeatures}}
|
||||
{{else}}<p class="text-secondary mb-0">Members aren't granted any commands.</p>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header"><h3 class="card-title">Admins can run</h3></div>
|
||||
<div class="card-body">
|
||||
{{if .AdminFeatures}}{{template "feature-table" .AdminFeatures}}
|
||||
{{else}}<p class="text-secondary mb-0">Admins aren't granted any commands.</p>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a class="back-link mt-3" href="/repeaters/{{.Repeater.PublicID}}/share">{{template "icon-arrow-left" "me-1"}}Back to sharing</a>
|
||||
{{end}}
|
||||
@@ -1,56 +0,0 @@
|
||||
{{define "title"}}Contribute {{.Repeater.Name}} · MeshTender{{end}}
|
||||
{{define "header"}}
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col">
|
||||
<div class="page-pretitle">Contribute to {{.Org.Name}}</div>
|
||||
<h2 class="page-title">{{.Repeater.Name}}</h2>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{define "content"}}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<p class="text-secondary mb-0">
|
||||
By consenting, you allow {{.Org.Name}}'s admins and members to run these commands on this repeater
|
||||
over the mesh (policy v{{.Version}}). If the org later
|
||||
adds commands, this repeater stays on v{{.Version}} until you review and re-consent. You can
|
||||
withdraw anytime. Hover a command to see what it does.
|
||||
</p>
|
||||
|
||||
{{if .Reconsent}}
|
||||
<div class="alert alert-warning mt-3 mb-0">
|
||||
<h4 class="alert-title">Changes since you consented (v{{.ConsentedVersion}} → v{{.Version}})</h4>
|
||||
{{if .Added}}<div>Newly granted: {{range .Added}}<code>{{.}}</code> {{end}}</div>{{end}}
|
||||
{{if .Removed}}<div>No longer granted: {{range .Removed}}<code>{{.}}</code> {{end}}</div>{{end}}
|
||||
{{if not (or .Added .Removed)}}<div>No command changes (notes/metadata only).</div>{{end}}
|
||||
{{if .Notes}}<div class="text-secondary mt-2">
|
||||
{{range .Notes}}v{{.Version}}{{if .Note}}: {{.Note}}{{end}}<br>{{end}}
|
||||
</div>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header"><h3 class="card-title">Members can run</h3></div>
|
||||
<div class="card-body">
|
||||
{{if .MemberFeatures}}{{template "feature-table" .MemberFeatures}}
|
||||
{{else}}<p class="text-secondary mb-0">Members aren't granted any commands.</p>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header"><h3 class="card-title">Admins can run</h3></div>
|
||||
<div class="card-body">
|
||||
{{if .AdminFeatures}}{{template "feature-table" .AdminFeatures}}
|
||||
{{else}}<p class="text-secondary mb-0">Admins aren't granted any commands.</p>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/repeaters/{{.Repeater.PublicID}}/orgs/{{.Org.Slug}}/contribute">
|
||||
<div class="btn-list mt-3">
|
||||
<button type="submit" class="btn btn-primary">I consent — contribute this repeater</button>
|
||||
<a class="btn" href="/repeaters/{{.Repeater.PublicID}}/share">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
{{end}}
|
||||
@@ -9,13 +9,6 @@
|
||||
{{end}}
|
||||
{{define "content"}}
|
||||
{{if .Error}}<div class="alert alert-danger" role="alert">{{.Error}}</div>{{end}}
|
||||
{{if .Reconsent}}
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<div class="d-flex"><div>{{template "icon-alert" "alert-icon"}}</div>
|
||||
<div>One or more of your repeaters need re-consent after an organization changed its command policy.
|
||||
<a href="/repeaters" class="alert-link">Review on the Repeaters page</a>.</div></div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Summary stats -->
|
||||
<div class="row row-cards">
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
{{define "title"}}Limit commands · {{.Org.Name}} · MeshTender{{end}}
|
||||
{{define "header"}}
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col">
|
||||
<div class="page-pretitle">{{.Org.Name}}</div>
|
||||
<h2 class="page-title">Limit commands</h2>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{define "content"}}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<p class="text-secondary mb-0">
|
||||
Restrict which of the commands <strong>{{.Org.Name}}</strong> is permitted to run actually run on
|
||||
<em>your</em> repeaters shared with it. This applies to every repeater you share with this organization.
|
||||
{{if .Restricted}}
|
||||
You're currently allowing only the checked commands.
|
||||
{{else}}
|
||||
No restriction is set — every command the organization is permitted to run can run. Uncheck commands to
|
||||
restrict them.
|
||||
{{end}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/orgs/{{.Org.Slug}}/my-commands" id="cmdform-perms">
|
||||
{{range .Groups}}
|
||||
<div class="card mt-3">
|
||||
<div class="card-header"><h3 class="card-title">{{.Name}}</h3></div>
|
||||
<div class="card-body">
|
||||
{{range .Commands}}
|
||||
<label class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="cmd" value="{{.ID}}" {{if .Checked}}checked{{end}}>
|
||||
<span class="form-check-label">
|
||||
<code>{{.Template}}</code>
|
||||
{{if .Risky}}<span class="badge bg-warning-lt">risky</span>{{end}}
|
||||
{{if .Args}}<span class="text-secondary">{{.Args}}</span>{{end}}
|
||||
</span>
|
||||
</label>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="btn-list mt-3">
|
||||
<button type="submit" class="btn btn-primary">Save restriction</button>
|
||||
{{if .Restricted}}
|
||||
<button type="submit" name="clear" value="1" class="btn">Remove restriction (allow all)</button>
|
||||
{{end}}
|
||||
<a class="btn" href="/orgs/{{.Org.Slug}}">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
{{end}}
|
||||
@@ -1,55 +0,0 @@
|
||||
{{define "title"}}Edit requested access · {{.Org.Name}} · MeshTender{{end}}
|
||||
{{define "header"}}
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col">
|
||||
<div class="page-pretitle">{{.Org.Name}}</div>
|
||||
<h2 class="page-title">Edit requested access</h2>
|
||||
</div>
|
||||
<div class="col-auto ms-auto d-print-none">
|
||||
<a class="btn" href="/orgs/{{.Org.Slug}}/permissions">Back to requested access</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{define "content"}}
|
||||
{{template "org-tabs" .Nav}}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<p class="text-secondary mb-0">
|
||||
Current version: <strong>v{{.Version}}</strong>. Choose which commands org <strong>admins</strong>
|
||||
and <strong>members</strong> may run on contributed repeaters. Saving publishes a new version;
|
||||
members' repeaters stay on their consented version until owners re-consent to the additions.
|
||||
Admins effectively also get everything members get. Risky commands can take over or brick a node.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/orgs/{{.Org.Slug}}/permissions/edit">
|
||||
{{range .Groups}}
|
||||
<div class="card mt-3">
|
||||
<div class="card-header"><h3 class="card-title">{{.Name}}</h3></div>
|
||||
<div class="card-body">
|
||||
{{range .Commands}}
|
||||
<div class="d-flex align-items-center flex-wrap gap-3 py-1">
|
||||
<code class="flex-fill">{{.Template}}{{if .Risky}} <span class="badge bg-warning-lt">risky</span>{{end}}</code>
|
||||
<label class="form-check form-check-inline m-0"><input class="form-check-input" type="checkbox" name="admin" value="{{.ID}}" {{if .AdminChecked}}checked{{end}}><span class="form-check-label">admin</span></label>
|
||||
<label class="form-check form-check-inline m-0"><input class="form-check-input" type="checkbox" name="member" value="{{.ID}}" {{if .MemberChecked}}checked{{end}}><span class="form-check-label">member</span></label>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="change_note">Change note <span class="text-secondary">(shown to owners when they re-consent)</span></label>
|
||||
<input type="text" class="form-control" id="change_note" name="note" maxlength="200" placeholder="e.g. added set tx for the new region plan">
|
||||
</div>
|
||||
<div class="btn-list">
|
||||
<button type="submit" class="btn btn-primary">Publish new version</button>
|
||||
<a class="btn" href="/orgs/{{.Org.Slug}}/permissions">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{{end}}
|
||||
@@ -93,17 +93,16 @@
|
||||
<span class="text-secondary">{{template "icon-world" ""}}</span>
|
||||
<div class="flex-fill text-truncate">
|
||||
<a class="fw-bold text-reset" href="/orgs/{{.OrgSlug}}">{{.OrgName}}</a>
|
||||
<div class="text-secondary small">consented to v{{.ConsentedVersion}}{{if gt .CurrentVersion .ConsentedVersion}} <span class="badge bg-warning-lt">re-consent</span>{{end}}</div>
|
||||
</div>
|
||||
<div class="btn-list flex-nowrap">
|
||||
<a class="btn btn-sm" href="/repeaters/{{$.Repeater.PublicID}}/orgs/{{.OrgSlug}}/consented" title="What v{{.ConsentedVersion}} permits org members to run on this repeater">{{template "icon-list" "me-1"}}Permissions</a>
|
||||
<a class="btn btn-sm" href="/orgs/{{.OrgSlug}}/my-commands" title="Limit which commands this org may run on your repeaters">{{template "icon-list" "me-1"}}Commands</a>
|
||||
<a class="btn btn-sm" href="/orgs/{{.OrgSlug}}/config{{if $.Repeater.Latitude}}?lat={{$.Repeater.Latitude}}&lon={{$.Repeater.Longitude}}{{end}}" title="This org's recommended config for this repeater">{{template "icon-settings" "me-1"}}Config</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="card-body"><p class="text-secondary mb-0">Not contributed to any organization.</p></div>
|
||||
<div class="card-body"><p class="text-secondary mb-0">Not shared with any organization.</p></div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -43,19 +43,22 @@
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title">Step 4 · Contribute to an organization <span class="text-secondary">(optional)</span></h3>
|
||||
<h3 class="card-title">Step 4 · Organizations <span class="text-secondary">(optional)</span></h3>
|
||||
{{if .Orgs}}
|
||||
<p class="text-secondary">
|
||||
Let an organization's admins and members help operate this repeater. You'll be asked to review and
|
||||
consent to the list of commands that organization members and admins will be able to run on your
|
||||
repeater. You can withdraw anytime.
|
||||
This repeater is now shared with the organizations you belong to, so their admins and members can run
|
||||
the commands those organizations are permitted to run on it. Opt out of any of them here — you can
|
||||
change this anytime, and limit which commands an org may run, from the sharing page.
|
||||
</p>
|
||||
<div class="list-group list-group-flush">
|
||||
{{range .Orgs}}
|
||||
<div class="list-group-item d-flex align-items-center gap-2 px-0">
|
||||
<span class="fw-bold">{{.Org.Name}}</span>
|
||||
{{if eq .Role "admin"}}<span class="badge bg-success-lt">admin</span>{{else}}<span class="badge bg-azure-lt">member</span>{{end}}
|
||||
<a class="btn btn-sm ms-auto" href="/repeaters/{{$.Repeater.PublicID}}/orgs/{{.Org.Slug}}/contribute">Review & contribute</a>
|
||||
<form method="post" action="/repeaters/{{$.Repeater.PublicID}}/orgs/{{.Org.Slug}}/participation" class="m-0 ms-auto">
|
||||
<input type="hidden" name="action" value="exclude">
|
||||
<button type="submit" class="btn btn-sm btn-ghost-danger">Opt out</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
<div class="d-flex align-items-center flex-wrap gap-2">
|
||||
<h3 class="m-0 fs-3"><a class="text-reset text-decoration-none" href="/repeaters/{{.PublicID}}">{{.Name}}</a></h3>
|
||||
{{template "repstatus" .}}
|
||||
{{if index $.Reconsent .ID}}<a class="badge bg-warning-lt" href="/repeaters/{{.PublicID}}/share">re-consent needed</a>{{end}}
|
||||
</div>
|
||||
<div class="d-flex align-items-center flex-wrap gap-2 mt-1 text-secondary">
|
||||
<span class="font-monospace">{{slice .PublicKeyHex 0 8}}…</span>
|
||||
|
||||
@@ -23,44 +23,36 @@
|
||||
<div class="card-body">
|
||||
<h3 class="card-title">Organizations</h3>
|
||||
<p class="text-secondary">
|
||||
Contribute this repeater to an organization so its admins and members can run their permitted
|
||||
commands on it over the mesh — but only the specific commands you review and approve first. You can
|
||||
withdraw anytime.
|
||||
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, or
|
||||
limit which commands an organization may run from its <strong>Limit commands</strong> page.
|
||||
</p>
|
||||
|
||||
<div class="subheader mt-3 mb-2">Contributed to</div>
|
||||
{{if .Contributed}}
|
||||
{{if .Orgs}}
|
||||
<div class="list-group list-group-flush">
|
||||
{{range .Contributed}}
|
||||
{{range .Orgs}}
|
||||
<div class="list-group-item d-flex align-items-center flex-wrap gap-2 px-0">
|
||||
<span class="fw-bold">{{.OrgName}}</span>
|
||||
<span class="text-secondary"><a class="link-secondary" href="/repeaters/{{$.Repeater.PublicID}}/orgs/{{.OrgSlug}}/consented">consented to v{{.ConsentedVersion}}</a>{{if .NeedsReconsent}} · org is now on v{{.CurrentVersion}}{{end}}</span>
|
||||
{{if .NeedsReconsent}}
|
||||
<a class="btn btn-sm" href="/repeaters/{{$.Repeater.PublicID}}/orgs/{{.OrgSlug}}/contribute">Review changes</a>
|
||||
{{if .Excluded}}
|
||||
<span class="badge bg-secondary-lt">Opted out</span>
|
||||
{{else}}
|
||||
<span class="badge bg-success-lt">Shared</span>
|
||||
<a class="text-secondary small" href="/orgs/{{.OrgSlug}}/my-commands">Limit commands</a>
|
||||
{{end}}
|
||||
<form method="post" action="/repeaters/{{$.Repeater.PublicID}}/orgs/{{.OrgSlug}}/withdraw" class="m-0 ms-auto" onsubmit="return confirm('Withdraw this repeater from {{.OrgName}}?')">
|
||||
<button type="submit" class="btn btn-sm btn-ghost-danger">Withdraw</button>
|
||||
<form method="post" action="/repeaters/{{$.Repeater.PublicID}}/orgs/{{.OrgSlug}}/participation" class="m-0 ms-auto">
|
||||
{{if .Excluded}}
|
||||
<input type="hidden" name="action" value="include">
|
||||
<button type="submit" class="btn btn-sm">Share with {{.OrgName}}</button>
|
||||
{{else}}
|
||||
<input type="hidden" name="action" value="exclude">
|
||||
<button type="submit" class="btn btn-sm btn-ghost-danger" onclick="return confirm('Opt this repeater out of {{.OrgName}}?')">Opt out</button>
|
||||
{{end}}
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-secondary">Not contributed to any organization.</p>
|
||||
{{end}}
|
||||
|
||||
{{if .Available}}
|
||||
<div class="subheader mt-3 mb-2">Contribute to</div>
|
||||
<div class="list-group list-group-flush">
|
||||
{{range .Available}}
|
||||
<div class="list-group-item d-flex align-items-center gap-2 px-0">
|
||||
<span class="fw-bold">{{.Name}}</span>
|
||||
<a class="btn btn-sm ms-auto" href="/repeaters/{{$.Repeater.PublicID}}/orgs/{{.Slug}}/contribute">Review & contribute</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else if .Contributed}}
|
||||
{{else}}
|
||||
<p class="text-secondary">Join an organization to contribute this repeater to it.</p>
|
||||
<p class="text-secondary">Join an organization to share this repeater with it.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+3
-19
@@ -142,10 +142,7 @@ func (s *Handlers) appRouter() chi.Router {
|
||||
r.Post("/repeaters/{id}/unshare", s.handleUnshare)
|
||||
r.Get("/repeaters/{id}/share/{userID}/commands", s.pageShareCommands)
|
||||
r.Post("/repeaters/{id}/share/{userID}/commands", s.handleSetShareCommands)
|
||||
r.Get("/repeaters/{id}/orgs/{orgID}/consented", s.pageConsented)
|
||||
r.Get("/repeaters/{id}/orgs/{orgID}/contribute", s.pageContribute)
|
||||
r.Post("/repeaters/{id}/orgs/{orgID}/contribute", s.handleContribute)
|
||||
r.Post("/repeaters/{id}/orgs/{orgID}/withdraw", s.handleWithdraw)
|
||||
r.Post("/repeaters/{id}/orgs/{orgID}/participation", s.handleSetRepeaterOrg)
|
||||
r.Post("/invite/{token}/accept", s.handleAcceptInvite)
|
||||
|
||||
r.Get("/orgs/new", s.pageNewOrg)
|
||||
@@ -154,9 +151,8 @@ func (s *Handlers) appRouter() chi.Router {
|
||||
r.Post("/orgs/{id}/join", s.handleJoinOrg)
|
||||
r.Post("/orgs/{id}/leave", s.handleLeaveOrg)
|
||||
r.Post("/orgs/{id}/members/{userID}", s.handleSetOrgMember)
|
||||
r.Get("/orgs/{id}/permissions", s.pageOrgPermissions)
|
||||
r.Get("/orgs/{id}/permissions/edit", s.pageOrgPermissionsEdit)
|
||||
r.Post("/orgs/{id}/permissions/edit", s.handleSaveOrgPermissions)
|
||||
r.Get("/orgs/{id}/my-commands", s.pageOrgCommands)
|
||||
r.Post("/orgs/{id}/my-commands", s.handleSaveOrgCommands)
|
||||
r.Get("/orgs/{id}/config", s.pageOrgConfig)
|
||||
r.Get("/orgs/{id}/config/edit", s.pageOrgConfigEdit)
|
||||
r.Post("/orgs/{id}/config/edit", s.handleSaveOrgConfig)
|
||||
@@ -199,11 +195,6 @@ func (s *Handlers) pageRepeaters(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "could not load repeaters", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
reconsent, err := s.Store.OwnedRepeatersNeedingReconsent(r.Context(), uid)
|
||||
if err != nil {
|
||||
http.Error(w, "could not load org state", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
shareCounts, err := s.Store.RepeaterSharingCounts(r.Context(), uid)
|
||||
if err != nil {
|
||||
http.Error(w, "could not load sharing", http.StatusInternalServerError)
|
||||
@@ -213,7 +204,6 @@ func (s *Handlers) pageRepeaters(w http.ResponseWriter, r *http.Request) {
|
||||
s.Render(w, r, "repeaters.html", map[string]any{
|
||||
"Owned": owned,
|
||||
"Shared": shared,
|
||||
"Reconsent": reconsent,
|
||||
"ShareCounts": shareCounts,
|
||||
"Error": r.URL.Query().Get("error"),
|
||||
})
|
||||
@@ -232,11 +222,6 @@ func (s *Handlers) pageDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
owned, shared := splitOwnedShared(repeaters)
|
||||
|
||||
reconsent, err := s.Store.OwnedRepeatersNeedingReconsent(ctx, uid)
|
||||
if err != nil {
|
||||
http.Error(w, "could not load org state", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
orgs, err := s.Store.ListOrgsForUser(ctx, uid)
|
||||
if err != nil {
|
||||
http.Error(w, "could not load organizations", http.StatusInternalServerError)
|
||||
@@ -277,7 +262,6 @@ func (s *Handlers) pageDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
"Orgs": first(orgs, 5),
|
||||
"Mapped": mapped,
|
||||
"Recent": recent,
|
||||
"Reconsent": reconsent,
|
||||
"ShareCounts": shareCounts,
|
||||
"Error": r.URL.Query().Get("error"),
|
||||
})
|
||||
|
||||
@@ -44,8 +44,7 @@ func (s *Handlers) Routes() chi.Router {
|
||||
r.Get("/orgs", s.pageOrgs) // public organization directory
|
||||
r.Get("/orgs/{id}", s.pageOrgPublic) // public org page
|
||||
r.Get("/orgs/{id}/repeaters", s.pageOrgRepeaters) // public repeater list + map
|
||||
r.Get("/orgs/{id}/config", s.pageOrgConfig) // public recommended config
|
||||
r.Get("/orgs/{id}/permissions", s.pageOrgPermissions) // public requested access
|
||||
r.Get("/orgs/{id}/config", s.pageOrgConfig) // public recommended config
|
||||
return r
|
||||
}
|
||||
|
||||
|
||||
@@ -119,32 +119,6 @@ func (s *Handlers) pageOrgConfig(w http.ResponseWriter, r *http.Request) {
|
||||
s.Render(w, r, "org_config.html", data)
|
||||
}
|
||||
|
||||
// pageOrgPermissions renders an org's requested-access policy read-only for
|
||||
// anonymous visitors (so prospective members can see it before joining).
|
||||
func (s *Handlers) pageOrgPermissions(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := s.orgID(r)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
org, err := s.Store.GetOrg(r.Context(), id)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
pv, err := web.BuildPermissionsView(r.Context(), s.Store, id)
|
||||
if err != nil {
|
||||
http.Error(w, "could not load policy", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.Render(w, r, "org_permissions.html", map[string]any{
|
||||
"Org": org,
|
||||
"Nav": web.OrgNav(org.Slug, "permissions", false),
|
||||
"CanEdit": false,
|
||||
"Perms": pv,
|
||||
})
|
||||
}
|
||||
|
||||
// pageOrgRepeaters renders an org's public repeaters (those opted into the public
|
||||
// map) with a map, for anonymous visitors.
|
||||
func (s *Handlers) pageOrgRepeaters(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
<span class="avatar bg-primary-lt text-primary me-3">{{template "icon-share" ""}}</span>
|
||||
<h3 class="card-title m-0">Share with precision</h3>
|
||||
</div>
|
||||
<p class="text-secondary mb-0">Share access to your repeaters with single-use share links and command-level permissions. Contribute your repeater to an Organization will full transparency into which commands they can run.</p>
|
||||
<p class="text-secondary mb-0">Share access to your repeaters with single-use share links and command-level permissions. Join an organization and your repeaters are shared with it automatically — opt any out, or limit which commands it can run, whenever you like.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,22 +21,26 @@ type Command struct {
|
||||
Description string
|
||||
// Feature is the grouping area (e.g. "Radio", "Region") and Operation is the
|
||||
// read/write/delete/action bucket, both used by the review/catalog UIs.
|
||||
Feature string
|
||||
Operation string
|
||||
Risky bool
|
||||
InShareDefault bool
|
||||
InOrgMemberDefault bool
|
||||
InOrgAdminDefault bool
|
||||
Feature string
|
||||
Operation string
|
||||
Risky bool
|
||||
// InShareDefault seeds the command set offered for a new one-off share.
|
||||
InShareDefault bool
|
||||
// OrgMemberAllowed / OrgAdminAllowed are the site-admin-controlled ceiling of
|
||||
// what an org member / admin may ever run on a contributed repeater. (No longer
|
||||
// just a seed — these are the authoritative per-tier limits.)
|
||||
OrgMemberAllowed bool
|
||||
OrgAdminAllowed bool
|
||||
}
|
||||
|
||||
const commandCols = `id, key, template, category, args, arity, description, feature, operation, risky,
|
||||
in_share_default, in_org_member_default, in_org_admin_default`
|
||||
in_share_default, org_member_allowed, org_admin_allowed`
|
||||
|
||||
func scanCommand(row pgx.Row) (*Command, error) {
|
||||
var c Command
|
||||
err := row.Scan(&c.ID, &c.Key, &c.Template, &c.Category, &c.Args, &c.Arity, &c.Description,
|
||||
&c.Feature, &c.Operation, &c.Risky,
|
||||
&c.InShareDefault, &c.InOrgMemberDefault, &c.InOrgAdminDefault)
|
||||
&c.InShareDefault, &c.OrgMemberAllowed, &c.OrgAdminAllowed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -66,7 +70,7 @@ func (s *Store) GetCommand(ctx context.Context, id int64) (*Command, error) {
|
||||
func (s *Store) UpdateCommandFlags(ctx context.Context, id int64, risky, share, orgMember, orgAdmin bool) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE command_catalog
|
||||
SET risky = $2, in_share_default = $3, in_org_member_default = $4, in_org_admin_default = $5
|
||||
SET risky = $2, in_share_default = $3, org_member_allowed = $4, org_admin_allowed = $5
|
||||
WHERE id = $1`, id, risky, share, orgMember, orgAdmin)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update command flags: %w", err)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
-- +goose Up
|
||||
-- Rework org command permissions away from per-org versioned policies + repeater
|
||||
-- contribution/consent toward a simpler model:
|
||||
-- (a) the site catalog flags are the hard per-tier ceiling of what an org may
|
||||
-- ever run (org_member_allowed / org_admin_allowed, renamed from the old
|
||||
-- "default" flags that only seeded versions),
|
||||
-- (b) a repeater participates in every org its owner belongs to unless the
|
||||
-- owner opts it out (org_repeater_excludes),
|
||||
-- (c) an owner may optionally restrict, per org, which of the ceiling commands
|
||||
-- that org may run on their repeaters (org_command_optin); no rows = the
|
||||
-- full ceiling applies.
|
||||
-- The versioned policy tables and the consent-pinned contribution table go away.
|
||||
DROP TABLE org_repeaters;
|
||||
DROP TABLE org_permission_commands;
|
||||
DROP TABLE org_permission_versions;
|
||||
|
||||
ALTER TABLE command_catalog RENAME COLUMN in_org_member_default TO org_member_allowed;
|
||||
ALTER TABLE command_catalog RENAME COLUMN in_org_admin_default TO org_admin_allowed;
|
||||
|
||||
-- Opt-out set: a repeater participates in org O iff its owner is a member of O
|
||||
-- AND there is no exclude row. Absence = included (the common case writes nothing).
|
||||
CREATE TABLE org_repeater_excludes (
|
||||
org_id BIGINT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
repeater_id BIGINT NOT NULL REFERENCES repeaters(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (org_id, repeater_id)
|
||||
);
|
||||
CREATE INDEX org_repeater_excludes_repeater_id_idx ON org_repeater_excludes(repeater_id);
|
||||
|
||||
-- Per-(owner, org) optional command allowlist. No rows for an (org, owner) pair
|
||||
-- = permissive (the site ceiling applies); ≥1 row = restricted to exactly those
|
||||
-- commands. (Note: this is the opposite default from share_commands, where no
|
||||
-- rows means deny-all.)
|
||||
CREATE TABLE org_command_optin (
|
||||
org_id BIGINT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
owner_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
command_id BIGINT NOT NULL REFERENCES command_catalog(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (org_id, owner_id, command_id)
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE org_command_optin;
|
||||
DROP TABLE org_repeater_excludes;
|
||||
|
||||
ALTER TABLE command_catalog RENAME COLUMN org_admin_allowed TO in_org_admin_default;
|
||||
ALTER TABLE command_catalog RENAME COLUMN org_member_allowed TO in_org_member_default;
|
||||
|
||||
CREATE TABLE org_permission_versions (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
org_id BIGINT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
version INT NOT NULL,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (org_id, version)
|
||||
);
|
||||
|
||||
CREATE TABLE org_permission_commands (
|
||||
version_id BIGINT NOT NULL REFERENCES org_permission_versions(id) ON DELETE CASCADE,
|
||||
command_id BIGINT NOT NULL REFERENCES command_catalog(id) ON DELETE CASCADE,
|
||||
tier TEXT NOT NULL CHECK (tier IN ('admin', 'member')),
|
||||
PRIMARY KEY (version_id, command_id, tier)
|
||||
);
|
||||
|
||||
CREATE TABLE org_repeaters (
|
||||
org_id BIGINT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
repeater_id BIGINT NOT NULL REFERENCES repeaters(id) ON DELETE CASCADE,
|
||||
consented_version_id BIGINT NOT NULL REFERENCES org_permission_versions(id),
|
||||
contributed_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||
contributed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (org_id, repeater_id)
|
||||
);
|
||||
CREATE INDEX org_repeaters_repeater_id_idx ON org_repeaters(repeater_id);
|
||||
@@ -0,0 +1,43 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// An owner's optional per-org command allowlist. No rows for an (org, owner) pair
|
||||
// means permissive — the site ceiling (org_member_allowed / org_admin_allowed)
|
||||
// applies unchanged. One or more rows restrict that org to exactly the listed
|
||||
// commands on the owner's repeaters (still intersected with the ceiling and tier).
|
||||
|
||||
// OrgOptInCommandIDs returns the command ids an owner has opted into for an org.
|
||||
// An empty result means no restriction (permissive).
|
||||
func (s *Store) OrgOptInCommandIDs(ctx context.Context, orgID, ownerID int64) ([]int64, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT command_id FROM org_command_optin WHERE org_id = $1 AND owner_id = $2`, orgID, ownerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("org opt-in commands: %w", err)
|
||||
}
|
||||
return collectRows(rows, scanID)
|
||||
}
|
||||
|
||||
// SetOrgOptIn replaces an owner's opt-in command list for an org. Passing no ids
|
||||
// clears the restriction (reverts to permissive).
|
||||
func (s *Store) SetOrgOptIn(ctx context.Context, orgID, ownerID int64, commandIDs []int64) error {
|
||||
return s.inTx(ctx, func(tx pgx.Tx) error {
|
||||
if _, err := tx.Exec(ctx,
|
||||
`DELETE FROM org_command_optin WHERE org_id = $1 AND owner_id = $2`, orgID, ownerID); err != nil {
|
||||
return fmt.Errorf("clear org opt-in: %w", err)
|
||||
}
|
||||
for _, id := range commandIDs {
|
||||
if _, err := tx.Exec(ctx,
|
||||
`INSERT INTO org_command_optin (org_id, owner_id, command_id) VALUES ($1, $2, $3)
|
||||
ON CONFLICT DO NOTHING`, orgID, ownerID, id); err != nil {
|
||||
return fmt.Errorf("insert org opt-in: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// CurrentVersion returns the org's latest permission version (id and number).
|
||||
func (s *Store) CurrentVersion(ctx context.Context, orgID int64) (id int64, version int, err error) {
|
||||
err = s.pool.QueryRow(ctx,
|
||||
`SELECT id, version FROM org_permission_versions WHERE org_id = $1 ORDER BY version DESC LIMIT 1`,
|
||||
orgID).Scan(&id, &version)
|
||||
if err != nil {
|
||||
return 0, 0, notFoundOr(err, "current version")
|
||||
}
|
||||
return id, version, nil
|
||||
}
|
||||
|
||||
// VersionCommandIDs returns the command ids in a version, split by tier.
|
||||
func (s *Store) VersionCommandIDs(ctx context.Context, versionID int64) (admin, member []int64, err error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT command_id, tier FROM org_permission_commands WHERE version_id = $1`, versionID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("version commands: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var tier string
|
||||
if err := rows.Scan(&id, &tier); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if tier == "admin" {
|
||||
admin = append(admin, id)
|
||||
} else {
|
||||
member = append(member, id)
|
||||
}
|
||||
}
|
||||
return admin, member, rows.Err()
|
||||
}
|
||||
|
||||
// VersionNumber returns the version number for a permission version id.
|
||||
func (s *Store) VersionNumber(ctx context.Context, versionID int64) (int, error) {
|
||||
var v int
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT version FROM org_permission_versions WHERE id = $1`, versionID).Scan(&v)
|
||||
if err != nil {
|
||||
return 0, notFoundOr(err, "version number")
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// VersionNote is a changelog entry.
|
||||
type VersionNote struct {
|
||||
Version int
|
||||
Note string
|
||||
}
|
||||
|
||||
// VersionNotesSince returns the notes for org versions newer than afterVersion,
|
||||
// oldest first — the changelog an owner reviews before re-consenting.
|
||||
func (s *Store) VersionNotesSince(ctx context.Context, orgID int64, afterVersion int) ([]VersionNote, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT version, note FROM org_permission_versions
|
||||
WHERE org_id = $1 AND version > $2 ORDER BY version`, orgID, afterVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("version notes: %w", err)
|
||||
}
|
||||
return collectRows(rows, func(r pgx.Row) (VersionNote, error) {
|
||||
var n VersionNote
|
||||
err := r.Scan(&n.Version, &n.Note)
|
||||
return n, err
|
||||
})
|
||||
}
|
||||
|
||||
// PublishVersion creates the org's next permission version with the given
|
||||
// admin/member command sets, returning the new version number.
|
||||
func (s *Store) PublishVersion(ctx context.Context, orgID int64, note string, createdBy int64, adminIDs, memberIDs []int64) (int, error) {
|
||||
var next int
|
||||
err := s.inTx(ctx, func(tx pgx.Tx) error {
|
||||
if err := tx.QueryRow(ctx,
|
||||
`SELECT COALESCE(max(version), 0) + 1 FROM org_permission_versions WHERE org_id = $1`,
|
||||
orgID).Scan(&next); err != nil {
|
||||
return fmt.Errorf("next version: %w", err)
|
||||
}
|
||||
var versionID int64
|
||||
if err := tx.QueryRow(ctx,
|
||||
`INSERT INTO org_permission_versions (org_id, version, note, created_by)
|
||||
VALUES ($1, $2, $3, $4) RETURNING id`,
|
||||
orgID, next, note, createdBy).Scan(&versionID); err != nil {
|
||||
return fmt.Errorf("insert version: %w", err)
|
||||
}
|
||||
insert := func(ids []int64, tier string) error {
|
||||
for _, id := range ids {
|
||||
if _, err := tx.Exec(ctx,
|
||||
`INSERT INTO org_permission_commands (version_id, command_id, tier) VALUES ($1, $2, $3)
|
||||
ON CONFLICT DO NOTHING`, versionID, id, tier); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := insert(adminIDs, "admin"); err != nil {
|
||||
return fmt.Errorf("insert admin commands: %w", err)
|
||||
}
|
||||
if err := insert(memberIDs, "member"); err != nil {
|
||||
return fmt.Errorf("insert member commands: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
@@ -7,48 +7,46 @@ import (
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// RepeaterOrg describes an org a repeater is contributed to, with the version
|
||||
// the owner consented to vs the org's current version (current > consented means
|
||||
// re-consent is available).
|
||||
// A repeater participates in an org iff its owner is a member of that org and the
|
||||
// owner hasn't opted it out (no org_repeater_excludes row). This file builds the
|
||||
// org↔repeater listings around that rule and manages the opt-out set.
|
||||
|
||||
// RepeaterOrg is an org a repeater participates in.
|
||||
type RepeaterOrg struct {
|
||||
OrgID int64
|
||||
OrgSlug string
|
||||
OrgName string
|
||||
ConsentedVersion int
|
||||
CurrentVersion int
|
||||
OrgID int64
|
||||
OrgSlug string
|
||||
OrgName string
|
||||
}
|
||||
|
||||
// NeedsReconsent reports whether the org has published a newer version than the
|
||||
// owner consented to.
|
||||
func (r RepeaterOrg) NeedsReconsent() bool { return r.CurrentVersion > r.ConsentedVersion }
|
||||
// RepeaterOrgMembership is an org the repeater's owner belongs to, with whether
|
||||
// the owner has opted this repeater out of it — drives the per-org include/exclude
|
||||
// toggles on the owner's repeater/share pages.
|
||||
type RepeaterOrgMembership struct {
|
||||
OrgID int64
|
||||
OrgSlug string
|
||||
OrgName string
|
||||
Excluded bool
|
||||
}
|
||||
|
||||
// ContributeRepeater contributes a repeater to an org pinned to consentedVersionID
|
||||
// (also used to re-consent: re-pins to a newer version).
|
||||
func (s *Store) ContributeRepeater(ctx context.Context, orgID, repeaterID, consentedVersionID, contributedBy int64) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO org_repeaters (org_id, repeater_id, consented_version_id, contributed_by)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (org_id, repeater_id)
|
||||
DO UPDATE SET consented_version_id = EXCLUDED.consented_version_id,
|
||||
contributed_by = EXCLUDED.contributed_by, contributed_at = now()`,
|
||||
orgID, repeaterID, consentedVersionID, contributedBy)
|
||||
// SetRepeaterOrgExcluded opts a repeater out of (excluded=true) or back into
|
||||
// (excluded=false) an org. Idempotent.
|
||||
func (s *Store) SetRepeaterOrgExcluded(ctx context.Context, orgID, repeaterID int64, excluded bool) error {
|
||||
var err error
|
||||
if excluded {
|
||||
_, err = s.pool.Exec(ctx,
|
||||
`INSERT INTO org_repeater_excludes (org_id, repeater_id) VALUES ($1, $2)
|
||||
ON CONFLICT DO NOTHING`, orgID, repeaterID)
|
||||
} else {
|
||||
_, err = s.pool.Exec(ctx,
|
||||
`DELETE FROM org_repeater_excludes WHERE org_id = $1 AND repeater_id = $2`, orgID, repeaterID)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("contribute repeater: %w", err)
|
||||
return fmt.Errorf("set repeater org excluded: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WithdrawRepeater removes a repeater from an org.
|
||||
func (s *Store) WithdrawRepeater(ctx context.Context, orgID, repeaterID int64) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`DELETE FROM org_repeaters WHERE org_id = $1 AND repeater_id = $2`, orgID, repeaterID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("withdraw repeater: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OrgRepeaterInfo is a contributed repeater shown on the org page.
|
||||
// OrgRepeaterInfo is a participating repeater shown on the org page.
|
||||
type OrgRepeaterInfo struct {
|
||||
RepeaterID int64
|
||||
RepeaterPublicID string
|
||||
@@ -58,16 +56,17 @@ type OrgRepeaterInfo struct {
|
||||
Lat, Lon float64
|
||||
}
|
||||
|
||||
// ListOrgRepeaters returns the repeaters contributed to an org (with location
|
||||
// when the owner consented to storing it).
|
||||
// ListOrgRepeaters returns the repeaters participating in an org (owned by a
|
||||
// member, not opted out), with location when the owner stored it.
|
||||
func (s *Store) ListOrgRepeaters(ctx context.Context, orgID int64) ([]OrgRepeaterInfo, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT r.id, r.public_id, r.name, COALESCE(NULLIF(ou.display_name, ''), ou.username, '?'),
|
||||
r.latitude, r.longitude
|
||||
FROM org_repeaters orp
|
||||
JOIN repeaters r ON r.id = orp.repeater_id
|
||||
FROM repeaters r
|
||||
JOIN org_members om ON om.org_id = $1 AND om.user_id = r.owner_id
|
||||
JOIN users ou ON ou.id = r.owner_id
|
||||
WHERE orp.org_id = $1
|
||||
WHERE NOT EXISTS (SELECT 1 FROM org_repeater_excludes e
|
||||
WHERE e.org_id = $1 AND e.repeater_id = r.id)
|
||||
ORDER BY r.name`, orgID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list org repeaters: %w", err)
|
||||
@@ -89,16 +88,18 @@ func setLocation(ri *OrgRepeaterInfo, lat, lon *float64) {
|
||||
}
|
||||
}
|
||||
|
||||
// ListPublicMapRepeaters returns the contributed repeaters an org may show on
|
||||
// ListPublicMapRepeaters returns the participating repeaters an org may show on
|
||||
// its public map: those whose owner opted into public_map and have coordinates.
|
||||
func (s *Store) ListPublicMapRepeaters(ctx context.Context, orgID int64) ([]OrgRepeaterInfo, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT r.id, r.name, COALESCE(NULLIF(ou.display_name, ''), ou.username, '?'),
|
||||
r.latitude, r.longitude
|
||||
FROM org_repeaters orp
|
||||
JOIN repeaters r ON r.id = orp.repeater_id
|
||||
FROM repeaters r
|
||||
JOIN org_members om ON om.org_id = $1 AND om.user_id = r.owner_id
|
||||
JOIN users ou ON ou.id = r.owner_id
|
||||
WHERE orp.org_id = $1 AND r.public_map AND r.latitude IS NOT NULL AND r.longitude IS NOT NULL
|
||||
WHERE r.public_map AND r.latitude IS NOT NULL AND r.longitude IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM org_repeater_excludes e
|
||||
WHERE e.org_id = $1 AND e.repeater_id = r.id)
|
||||
ORDER BY r.name`, orgID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list public map repeaters: %w", err)
|
||||
@@ -112,62 +113,46 @@ func (s *Store) ListPublicMapRepeaters(ctx context.Context, orgID int64) ([]OrgR
|
||||
})
|
||||
}
|
||||
|
||||
// ConsentedVersionID returns the permission version a repeater is pinned to for
|
||||
// an org, or (0, false) if it isn't contributed there.
|
||||
func (s *Store) ConsentedVersionID(ctx context.Context, orgID, repeaterID int64) (int64, bool, error) {
|
||||
var id int64
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT consented_version_id FROM org_repeaters WHERE org_id = $1 AND repeater_id = $2`,
|
||||
orgID, repeaterID).Scan(&id)
|
||||
if err != nil {
|
||||
return 0, false, nil //nolint:nilerr // absence is not an error here
|
||||
}
|
||||
return id, true, nil
|
||||
}
|
||||
|
||||
// OwnedRepeatersNeedingReconsent returns the set of repeater ids owned by the
|
||||
// user that are contributed to an org which has published a newer version than
|
||||
// the owner consented to.
|
||||
func (s *Store) OwnedRepeatersNeedingReconsent(ctx context.Context, ownerID int64) (map[int64]bool, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT DISTINCT orp.repeater_id
|
||||
FROM org_repeaters orp
|
||||
JOIN repeaters r ON r.id = orp.repeater_id AND r.owner_id = $1
|
||||
JOIN org_permission_versions cv ON cv.id = orp.consented_version_id
|
||||
WHERE cv.version < (SELECT max(version) FROM org_permission_versions WHERE org_id = orp.org_id)`,
|
||||
ownerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reconsent set: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[int64]bool{}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[id] = true
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListRepeaterOrgs returns the orgs a repeater is contributed to, with consented
|
||||
// vs current version numbers.
|
||||
// ListRepeaterOrgs returns the orgs a repeater participates in (owner is a member
|
||||
// and hasn't opted it out).
|
||||
func (s *Store) ListRepeaterOrgs(ctx context.Context, repeaterID int64) ([]RepeaterOrg, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT o.id, o.slug, o.name, cv.version,
|
||||
(SELECT max(version) FROM org_permission_versions WHERE org_id = o.id)
|
||||
FROM org_repeaters orp
|
||||
JOIN organizations o ON o.id = orp.org_id
|
||||
JOIN org_permission_versions cv ON cv.id = orp.consented_version_id
|
||||
WHERE orp.repeater_id = $1
|
||||
SELECT o.id, o.slug, o.name
|
||||
FROM repeaters r
|
||||
JOIN org_members om ON om.user_id = r.owner_id
|
||||
JOIN organizations o ON o.id = om.org_id
|
||||
WHERE r.id = $1
|
||||
AND NOT EXISTS (SELECT 1 FROM org_repeater_excludes e
|
||||
WHERE e.org_id = o.id AND e.repeater_id = r.id)
|
||||
ORDER BY o.name`, repeaterID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list repeater orgs: %w", err)
|
||||
}
|
||||
return collectRows(rows, func(r pgx.Row) (RepeaterOrg, error) {
|
||||
var ro RepeaterOrg
|
||||
err := r.Scan(&ro.OrgID, &ro.OrgSlug, &ro.OrgName, &ro.ConsentedVersion, &ro.CurrentVersion)
|
||||
err := r.Scan(&ro.OrgID, &ro.OrgSlug, &ro.OrgName)
|
||||
return ro, err
|
||||
})
|
||||
}
|
||||
|
||||
// ListRepeaterOrgMemberships returns every org the repeater's owner belongs to,
|
||||
// flagged with whether the owner has opted this repeater out of it.
|
||||
func (s *Store) ListRepeaterOrgMemberships(ctx context.Context, repeaterID int64) ([]RepeaterOrgMembership, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT o.id, o.slug, o.name,
|
||||
EXISTS (SELECT 1 FROM org_repeater_excludes e
|
||||
WHERE e.org_id = o.id AND e.repeater_id = r.id)
|
||||
FROM repeaters r
|
||||
JOIN org_members om ON om.user_id = r.owner_id
|
||||
JOIN organizations o ON o.id = om.org_id
|
||||
WHERE r.id = $1
|
||||
ORDER BY o.name`, repeaterID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list repeater org memberships: %w", err)
|
||||
}
|
||||
return collectRows(rows, func(r pgx.Row) (RepeaterOrgMembership, error) {
|
||||
var m RepeaterOrgMembership
|
||||
err := r.Scan(&m.OrgID, &m.OrgSlug, &m.OrgName, &m.Excluded)
|
||||
return m, err
|
||||
})
|
||||
}
|
||||
|
||||
+10
-21
@@ -124,25 +124,8 @@ func (s *Store) CreateOrg(ctx context.Context, name string, creatorID int64) (*O
|
||||
o.ID, creatorID); err != nil {
|
||||
return fmt.Errorf("add creator: %w", err)
|
||||
}
|
||||
|
||||
// Seed version 1 from the catalog default sets.
|
||||
var versionID int64
|
||||
if err := tx.QueryRow(ctx,
|
||||
`INSERT INTO org_permission_versions (org_id, version, note, created_by)
|
||||
VALUES ($1, 1, 'Initial policy', $2) RETURNING id`,
|
||||
o.ID, creatorID).Scan(&versionID); err != nil {
|
||||
return fmt.Errorf("seed version: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx,
|
||||
`INSERT INTO org_permission_commands (version_id, command_id, tier)
|
||||
SELECT $1, id, 'admin' FROM command_catalog WHERE in_org_admin_default`, versionID); err != nil {
|
||||
return fmt.Errorf("seed admin commands: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx,
|
||||
`INSERT INTO org_permission_commands (version_id, command_id, tier)
|
||||
SELECT $1, id, 'member' FROM command_catalog WHERE in_org_member_default`, versionID); err != nil {
|
||||
return fmt.Errorf("seed member commands: %w", err)
|
||||
}
|
||||
// No permission policy to seed: what an org may run is the site-wide
|
||||
// catalog ceiling, and owners restrict per org via org_command_optin.
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
@@ -302,7 +285,10 @@ func (s *Store) ListPublicOrgsPage(ctx context.Context, p OrgListParams) ([]OrgS
|
||||
FROM (
|
||||
SELECT o.id, o.slug, o.name, o.description, o.region, o.created_at,
|
||||
(SELECT count(*) FROM org_members m WHERE m.org_id = o.id) AS member_count,
|
||||
(SELECT count(*) FROM org_repeaters orp WHERE orp.org_id = o.id) AS repeater_count
|
||||
(SELECT count(*) FROM repeaters r
|
||||
JOIN org_members om ON om.org_id = o.id AND om.user_id = r.owner_id
|
||||
WHERE NOT EXISTS (SELECT 1 FROM org_repeater_excludes e
|
||||
WHERE e.org_id = o.id AND e.repeater_id = r.id)) AS repeater_count
|
||||
FROM organizations o
|
||||
%s
|
||||
) t
|
||||
@@ -333,7 +319,10 @@ func (s *Store) ListPublicOrgsPage(ctx context.Context, p OrgListParams) ([]OrgS
|
||||
func (s *Store) OrgCounts(ctx context.Context, orgID int64) (members, repeaters int, err error) {
|
||||
err = s.pool.QueryRow(ctx, `
|
||||
SELECT (SELECT count(*) FROM org_members WHERE org_id = $1),
|
||||
(SELECT count(*) FROM org_repeaters WHERE org_id = $1)`, orgID).
|
||||
(SELECT count(*) FROM repeaters r
|
||||
JOIN org_members om ON om.org_id = $1 AND om.user_id = r.owner_id
|
||||
WHERE NOT EXISTS (SELECT 1 FROM org_repeater_excludes e
|
||||
WHERE e.org_id = $1 AND e.repeater_id = r.id))`, orgID).
|
||||
Scan(&members, &repeaters)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("org counts: %w", err)
|
||||
|
||||
@@ -48,10 +48,22 @@ func TestOrgCommandResolution(t *testing.T) {
|
||||
}
|
||||
return id
|
||||
}
|
||||
// poweroff is a risky, owner-only command that's in no org tier — used to
|
||||
// check that even an org admin can't run a command outside the policy.
|
||||
// poweroff is a risky, owner-only command kept out of both org tiers — used to
|
||||
// check that even an org admin can't run a command outside the site ceiling.
|
||||
setRadio, advert, poweroff, setTx := cmdID("set.radio"), cmdID("advert"), cmdID("poweroff"), cmdID("set.tx")
|
||||
|
||||
// Set the site ceiling: admin tier = {set.radio, set.tx}, member tier = {advert},
|
||||
// poweroff in neither. (risky/share flags don't matter for these checks.)
|
||||
setCeiling := func(id int64, member, admin bool) {
|
||||
if err := st.UpdateCommandFlags(ctx, id, false, false, member, admin); err != nil {
|
||||
t.Fatalf("set ceiling %d: %v", id, err)
|
||||
}
|
||||
}
|
||||
setCeiling(setRadio, false, true)
|
||||
setCeiling(setTx, false, true)
|
||||
setCeiling(advert, true, false)
|
||||
setCeiling(poweroff, false, false)
|
||||
|
||||
mkUser := func(name string) int64 {
|
||||
u, err := st.CreateUser(ctx, name, "")
|
||||
if err != nil {
|
||||
@@ -82,15 +94,7 @@ func TestOrgCommandResolution(t *testing.T) {
|
||||
if err := st.AddOrgMember(ctx, org.ID, plainM, "member"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Controlled v2: admin={set.radio}, member={advert}; contribute pinned to it.
|
||||
if _, err := st.PublishVersion(ctx, org.ID, "v2", owner, []int64{setRadio}, []int64{advert}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vid, _, _ := st.CurrentVersion(ctx, org.ID)
|
||||
if err := st.ContributeRepeater(ctx, org.ID, rep.ID, vid, owner); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The owner's repeater participates in the org automatically (no opt-out).
|
||||
|
||||
can := func(u, c int64) bool {
|
||||
ok, err := st.CanSendCommand(ctx, u, rep.ID, c)
|
||||
@@ -108,9 +112,10 @@ func TestOrgCommandResolution(t *testing.T) {
|
||||
|
||||
// Owner: anything.
|
||||
check("owner/poweroff", can(owner, poweroff), true)
|
||||
// Org-admin: admin tier + member tier (⊇), but not commands outside policy.
|
||||
// Org-admin: admin tier + member tier (⊇), but not commands outside the ceiling.
|
||||
check("admin/set.radio", can(adminM, setRadio), true)
|
||||
check("admin/advert", can(adminM, advert), true)
|
||||
check("admin/set.tx", can(adminM, setTx), true)
|
||||
check("admin/poweroff", can(adminM, poweroff), false)
|
||||
// Plain member: member tier only.
|
||||
check("member/advert", can(plainM, advert), true)
|
||||
@@ -118,24 +123,29 @@ func TestOrgCommandResolution(t *testing.T) {
|
||||
// Outsider: nothing.
|
||||
check("outsider/advert", can(outsider, advert), false)
|
||||
|
||||
// Add set.tx to admin in v3; repeater still pinned to v2 → blocked until re-consent.
|
||||
if _, err := st.PublishVersion(ctx, org.ID, "v3 add set.tx", owner, []int64{setRadio, setTx}, []int64{advert}); err != nil {
|
||||
// Owner opts the org into only {advert}: the admin loses the admin-tier
|
||||
// commands not in the list, but keeps advert.
|
||||
if err := st.SetOrgOptIn(ctx, org.ID, owner, []int64{advert}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check("admin/set.tx before reconsent", can(adminM, setTx), false)
|
||||
v3, _, _ := st.CurrentVersion(ctx, org.ID)
|
||||
if err := st.ContributeRepeater(ctx, org.ID, rep.ID, v3, owner); err != nil { // re-consent
|
||||
check("admin/set.radio with opt-in", can(adminM, setRadio), false)
|
||||
check("admin/advert with opt-in", can(adminM, advert), true)
|
||||
// Clearing the opt-in restores the full ceiling.
|
||||
if err := st.SetOrgOptIn(ctx, org.ID, owner, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check("admin/set.tx after reconsent", can(adminM, setTx), true)
|
||||
check("admin/set.radio after clear", can(adminM, setRadio), true)
|
||||
|
||||
// Remove set.radio in v4 (admin={set.tx}); removal auto-applies even though
|
||||
// the repeater is still consented to v3 (which had set.radio).
|
||||
if _, err := st.PublishVersion(ctx, org.ID, "v4 drop set.radio", owner, []int64{setTx}, []int64{advert}); err != nil {
|
||||
// Opting the repeater out of the org blocks all org access regardless of tier.
|
||||
if err := st.SetRepeaterOrgExcluded(ctx, org.ID, rep.ID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check("admin/set.radio after removal", can(adminM, setRadio), false)
|
||||
check("admin/set.tx still", can(adminM, setTx), true)
|
||||
check("admin/advert when excluded", can(adminM, advert), false)
|
||||
check("member/advert when excluded", can(plainM, advert), false)
|
||||
if err := st.SetRepeaterOrgExcluded(ctx, org.ID, rep.ID, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check("admin/advert when re-included", can(adminM, advert), true)
|
||||
}
|
||||
|
||||
func TestOrgRepeaterAccess(t *testing.T) {
|
||||
@@ -150,8 +160,7 @@ func TestOrgRepeaterAccess(t *testing.T) {
|
||||
}
|
||||
org, _ := st.CreateOrg(ctx, "Org", owner.ID)
|
||||
_ = st.AddOrgMember(ctx, org.ID, member.ID, "member")
|
||||
vid, _, _ := st.CurrentVersion(ctx, org.ID)
|
||||
_ = st.ContributeRepeater(ctx, org.ID, rep.ID, vid, owner.ID)
|
||||
// The owner's repeater participates in the org automatically.
|
||||
|
||||
// Member can fetch (and thus operate) the repeater via org access...
|
||||
if _, err := st.GetRepeaterForUser(ctx, member.ID, rep.ID); err != nil {
|
||||
|
||||
@@ -151,8 +151,12 @@ func (s *Store) GetRepeaterForUser(ctx context.Context, userID, repeaterID int64
|
||||
WHERE r.id = $2
|
||||
AND (r.owner_id = $1
|
||||
OR r.id IN (SELECT repeater_id FROM repeater_shares WHERE user_id = $1)
|
||||
OR r.id IN (SELECT orp.repeater_id FROM org_repeaters orp
|
||||
JOIN org_members om ON om.org_id = orp.org_id AND om.user_id = $1))`,
|
||||
OR EXISTS (SELECT 1
|
||||
FROM org_members ownm
|
||||
JOIN org_members usrm ON usrm.org_id = ownm.org_id AND usrm.user_id = $1
|
||||
WHERE ownm.user_id = r.owner_id
|
||||
AND NOT EXISTS (SELECT 1 FROM org_repeater_excludes e
|
||||
WHERE e.org_id = ownm.org_id AND e.repeater_id = r.id)))`,
|
||||
userID, repeaterID)
|
||||
r, err := scanRepeater(row)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// resetPreservedTables are the tables Reset keeps: everything needed to still sign
|
||||
// in (users + their passkeys + live sessions), the server-wide MeshCore identity
|
||||
// (so re-added repeaters still trust MeshTender), the command catalog (site config,
|
||||
// not user data), and goose's migration bookkeeping. Everything else — orgs,
|
||||
// repeaters, shares, config profiles, logs, console/auth ephemera — is wiped.
|
||||
var resetPreservedTables = map[string]bool{
|
||||
"users": true,
|
||||
"webauthn_credentials": true,
|
||||
"sessions": true,
|
||||
"server_identity": true,
|
||||
"command_catalog": true,
|
||||
"goose_db_version": true,
|
||||
}
|
||||
|
||||
// Reset truncates all application data except the identity/login/catalog tables
|
||||
// (see resetPreservedTables). It's a development convenience: after a reset you can
|
||||
// still log in and your repeaters still trust the server, but you start fresh on
|
||||
// orgs, repeaters, and everything else. Discovers tables dynamically so new
|
||||
// migrations are covered automatically.
|
||||
func (s *Store) Reset(ctx context.Context) error {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT tablename FROM pg_tables WHERE schemaname = 'public'`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reset: list tables: %w", err)
|
||||
}
|
||||
tables, err := collectRows(rows, func(r pgx.Row) (string, error) {
|
||||
var t string
|
||||
return t, r.Scan(&t)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("reset: scan tables: %w", err)
|
||||
}
|
||||
var quoted []string
|
||||
for _, t := range tables {
|
||||
if !resetPreservedTables[t] {
|
||||
quoted = append(quoted, pgx.Identifier{t}.Sanitize())
|
||||
}
|
||||
}
|
||||
if len(quoted) == 0 {
|
||||
return nil
|
||||
}
|
||||
// RESTART IDENTITY resets sequences; CASCADE handles FK ordering (no preserved
|
||||
// table references a wiped one, so CASCADE never reaches the kept tables).
|
||||
stmt := "TRUNCATE " + strings.Join(quoted, ", ") + " RESTART IDENTITY CASCADE"
|
||||
if _, err := s.pool.Exec(ctx, stmt); err != nil {
|
||||
return fmt.Errorf("reset: truncate: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReset(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, ctx := orgTestStore(t)
|
||||
|
||||
count := func(table string) int {
|
||||
var n int
|
||||
if err := st.pool.QueryRow(ctx, "SELECT count(*) FROM "+table).Scan(&n); err != nil {
|
||||
t.Fatalf("count %s: %v", table, err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Seed preserved data (user + server identity) and disposable data (repeater + org).
|
||||
owner, err := st.CreateUser(ctx, "owner", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.InsertServerIdentity(ctx, strings.Repeat("a", 64), []byte("sealed")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rep, err := st.CreateRepeater(ctx, &Repeater{
|
||||
OwnerID: owner.ID, Name: "R", PublicKeyHex: strings.Repeat("b", 64),
|
||||
RadioFreqHz: 1, RadioBwHz: 1, RadioSF: 11, RadioCR: 5,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.CreateOrg(ctx, "Region", owner.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = rep
|
||||
|
||||
catalogBefore := count("command_catalog")
|
||||
if catalogBefore == 0 {
|
||||
t.Fatal("expected a seeded command catalog")
|
||||
}
|
||||
|
||||
if err := st.Reset(ctx); err != nil {
|
||||
t.Fatalf("reset: %v", err)
|
||||
}
|
||||
|
||||
// Preserved: login + identity + catalog survive.
|
||||
if got := count("users"); got != 1 {
|
||||
t.Errorf("users after reset = %d, want 1", got)
|
||||
}
|
||||
if got := count("server_identity"); got != 1 {
|
||||
t.Errorf("server_identity after reset = %d, want 1", got)
|
||||
}
|
||||
if got := count("command_catalog"); got != catalogBefore {
|
||||
t.Errorf("command_catalog after reset = %d, want %d", got, catalogBefore)
|
||||
}
|
||||
// Wiped: user content is gone.
|
||||
if got := count("repeaters"); got != 0 {
|
||||
t.Errorf("repeaters after reset = %d, want 0", got)
|
||||
}
|
||||
if got := count("organizations"); got != 0 {
|
||||
t.Errorf("organizations after reset = %d, want 0", got)
|
||||
}
|
||||
if got := count("org_members"); got != 0 {
|
||||
t.Errorf("org_members after reset = %d, want 0", got)
|
||||
}
|
||||
|
||||
// The kept user can still be looked up (login still works).
|
||||
if _, err := st.GetUserByID(ctx, owner.ID); err != nil {
|
||||
t.Errorf("GetUserByID after reset: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,11 @@ import (
|
||||
// to repeaterID. Allowed if any of:
|
||||
// - they own the repeater (any command), or
|
||||
// - a share grants them that specific command, or
|
||||
// - the repeater is contributed to an org they're a member of, and the command
|
||||
// is in BOTH the consented and the current policy version for their effective
|
||||
// tier (member → member tier; admin → member OR admin tier). This implements
|
||||
// effective = consented ∩ current, with admins ⊇ members.
|
||||
// - the repeater participates in an org they and the owner both belong to (the
|
||||
// owner is a member and hasn't excluded the repeater), the command is within
|
||||
// the site ceiling for their tier (member → org_member_allowed; admin →
|
||||
// org_member_allowed OR org_admin_allowed), AND the owner either set no opt-in
|
||||
// list for that org (permissive) or listed this command.
|
||||
func (s *Store) CanSendCommand(ctx context.Context, userID, repeaterID, commandID int64) (bool, error) {
|
||||
var ok bool
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
@@ -23,20 +24,20 @@ func (s *Store) CanSendCommand(ctx context.Context, userID, repeaterID, commandI
|
||||
OR EXISTS (SELECT 1 FROM share_commands WHERE repeater_id = $2 AND user_id = $1 AND command_id = $3)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM org_repeaters orp
|
||||
JOIN org_members om ON om.org_id = orp.org_id AND om.user_id = $1
|
||||
JOIN org_permission_commands consented
|
||||
ON consented.version_id = orp.consented_version_id
|
||||
AND consented.command_id = $3
|
||||
AND (consented.tier = 'member' OR (om.role = 'admin' AND consented.tier = 'admin'))
|
||||
JOIN org_permission_versions cur
|
||||
ON cur.org_id = orp.org_id
|
||||
AND cur.version = (SELECT max(version) FROM org_permission_versions WHERE org_id = orp.org_id)
|
||||
JOIN org_permission_commands current
|
||||
ON current.version_id = cur.id
|
||||
AND current.command_id = $3
|
||||
AND (current.tier = 'member' OR (om.role = 'admin' AND current.tier = 'admin'))
|
||||
WHERE orp.repeater_id = $2
|
||||
FROM repeaters r
|
||||
JOIN org_members ownm ON ownm.user_id = r.owner_id -- owner's org memberships
|
||||
JOIN org_members usrm ON usrm.org_id = ownm.org_id AND usrm.user_id = $1
|
||||
JOIN command_catalog c ON c.id = $3
|
||||
WHERE r.id = $2
|
||||
AND NOT EXISTS (SELECT 1 FROM org_repeater_excludes e
|
||||
WHERE e.org_id = ownm.org_id AND e.repeater_id = r.id)
|
||||
AND (c.org_member_allowed OR (usrm.role = 'admin' AND c.org_admin_allowed))
|
||||
AND (
|
||||
NOT EXISTS (SELECT 1 FROM org_command_optin o
|
||||
WHERE o.org_id = ownm.org_id AND o.owner_id = r.owner_id)
|
||||
OR EXISTS (SELECT 1 FROM org_command_optin o
|
||||
WHERE o.org_id = ownm.org_id AND o.owner_id = r.owner_id AND o.command_id = $3)
|
||||
)
|
||||
)`,
|
||||
userID, repeaterID, commandID).Scan(&ok)
|
||||
if err != nil {
|
||||
|
||||
@@ -75,16 +75,19 @@ func (s *Store) RemoveShare(ctx context.Context, repeaterID, userID int64) error
|
||||
// ShareCounts summarizes how widely a repeater is shared.
|
||||
type ShareCounts struct {
|
||||
Users int // direct user shares
|
||||
Orgs int // organization contributions
|
||||
Orgs int // organizations this repeater participates in
|
||||
}
|
||||
|
||||
// RepeaterSharingCounts returns per-repeater share and org-contribution counts
|
||||
// RepeaterSharingCounts returns per-repeater share and org-participation counts
|
||||
// for every repeater owned by ownerID, keyed by repeater id.
|
||||
func (s *Store) RepeaterSharingCounts(ctx context.Context, ownerID int64) (map[int64]ShareCounts, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT r.id,
|
||||
(SELECT count(*) FROM repeater_shares rs WHERE rs.repeater_id = r.id),
|
||||
(SELECT count(*) FROM org_repeaters orp WHERE orp.repeater_id = r.id)
|
||||
(SELECT count(*) FROM org_members om
|
||||
WHERE om.user_id = r.owner_id
|
||||
AND NOT EXISTS (SELECT 1 FROM org_repeater_excludes e
|
||||
WHERE e.org_id = om.org_id AND e.repeater_id = r.id))
|
||||
FROM repeaters r WHERE r.owner_id = $1`, ownerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("repeater sharing counts: %w", err)
|
||||
|
||||
+2
-2
@@ -22,14 +22,14 @@ import (
|
||||
"github.com/jleight/meshtender/internal/store"
|
||||
)
|
||||
|
||||
//go:embed templates/base.html templates/icons.html templates/org_tabs.html templates/org_public.html templates/org_config.html templates/org_permissions.html templates/org_repeaters.html
|
||||
//go:embed templates/base.html templates/icons.html templates/org_tabs.html templates/org_public.html templates/org_config.html templates/org_repeaters.html
|
||||
var sharedTemplatesFS embed.FS
|
||||
|
||||
// sharedPages are full content pages (not just layout partials) that more than
|
||||
// one surface renders. They're composed onto the base layout for every surface,
|
||||
// so the root host (anonymous) and the app host (signed-in) can render the same
|
||||
// public org page without duplicating the template.
|
||||
var sharedPages = []string{"templates/org_public.html", "templates/org_config.html", "templates/org_permissions.html", "templates/org_repeaters.html"}
|
||||
var sharedPages = []string{"templates/org_public.html", "templates/org_config.html", "templates/org_repeaters.html"}
|
||||
|
||||
//go:embed static/*
|
||||
var staticFS embed.FS
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"github.com/jleight/meshtender/internal/store"
|
||||
@@ -127,131 +126,3 @@ func PreviewLatLon(r *http.Request) (lat, lon float64, ok bool) {
|
||||
return lat, lon, err1 == nil && err2 == nil
|
||||
}
|
||||
|
||||
// CmdCell is one command in a feature-table cell (the "feature-table" partial in
|
||||
// base.html renders .Template/.Description/.Risky).
|
||||
type CmdCell struct {
|
||||
Template string
|
||||
Description string // shown as a hover tooltip
|
||||
Risky bool
|
||||
}
|
||||
|
||||
// FeatureRow is one feature's allowed commands bucketed by operation, for the
|
||||
// read-only feature×operation tables (consent, requested-access, contribute).
|
||||
type FeatureRow struct {
|
||||
Feature string
|
||||
Read, Write, Delete, Action []CmdCell
|
||||
}
|
||||
|
||||
// FeatureTableFor groups the commands in `allowed` (the id-set a single tier may
|
||||
// run) by feature × operation, ordered by featureOrder — one table per tier. This
|
||||
// is the canonical home for the consent/permission feature table so both the app
|
||||
// host and the root host (which can't import core) can build it.
|
||||
func FeatureTableFor(catalog []*store.Command, allowed map[int64]bool) []FeatureRow {
|
||||
byFeature := map[string]*FeatureRow{}
|
||||
var present []string
|
||||
for _, c := range catalog {
|
||||
if !allowed[c.ID] {
|
||||
continue
|
||||
}
|
||||
row := byFeature[c.Feature]
|
||||
if row == nil {
|
||||
row = &FeatureRow{Feature: c.Feature}
|
||||
byFeature[c.Feature] = row
|
||||
present = append(present, c.Feature)
|
||||
}
|
||||
cell := CmdCell{Template: c.Template, Description: c.Description, Risky: c.Risky}
|
||||
switch c.Operation {
|
||||
case "read":
|
||||
row.Read = append(row.Read, cell)
|
||||
case "delete":
|
||||
row.Delete = append(row.Delete, cell)
|
||||
case "action":
|
||||
row.Action = append(row.Action, cell)
|
||||
default: // "write"
|
||||
row.Write = append(row.Write, cell)
|
||||
}
|
||||
}
|
||||
orderFeatures(present)
|
||||
out := make([]FeatureRow, 0, len(present))
|
||||
for _, f := range present {
|
||||
out = append(out, *byFeature[f])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PermissionsView is an org's current requested-access policy for display: one
|
||||
// feature×operation table per tier. Admins inherit every member command, so the
|
||||
// admin table is member ∪ admin.
|
||||
type PermissionsView struct {
|
||||
Version int
|
||||
MemberFeatures []FeatureRow
|
||||
AdminFeatures []FeatureRow
|
||||
HasRisky bool // any granted command is risky (drives the warning copy)
|
||||
}
|
||||
|
||||
// BuildPermissionsView loads the org's current permission version and builds the
|
||||
// member and admin feature tables.
|
||||
func BuildPermissionsView(ctx context.Context, st *store.Store, orgID int64) (PermissionsView, error) {
|
||||
versionID, version, err := st.CurrentVersion(ctx, orgID)
|
||||
if err != nil {
|
||||
return PermissionsView{}, err
|
||||
}
|
||||
adminIDs, memberIDs, err := st.VersionCommandIDs(ctx, versionID)
|
||||
if err != nil {
|
||||
return PermissionsView{}, err
|
||||
}
|
||||
catalog, err := st.ListCommands(ctx)
|
||||
if err != nil {
|
||||
return PermissionsView{}, err
|
||||
}
|
||||
member := idSet(memberIDs)
|
||||
adminUnion := idSet(adminIDs)
|
||||
for id := range member {
|
||||
adminUnion[id] = true
|
||||
}
|
||||
pv := PermissionsView{
|
||||
Version: version,
|
||||
MemberFeatures: FeatureTableFor(catalog, member),
|
||||
AdminFeatures: FeatureTableFor(catalog, adminUnion),
|
||||
}
|
||||
for _, c := range catalog {
|
||||
if c.Risky && adminUnion[c.ID] {
|
||||
pv.HasRisky = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return pv, nil
|
||||
}
|
||||
|
||||
func idSet(ids []int64) map[int64]bool {
|
||||
m := make(map[int64]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
m[id] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// featureOrder mirrors core's command_features.go display order so the public
|
||||
// permissions view groups features the same way the in-app review/editor does.
|
||||
// Unknown features sort after these, alphabetically.
|
||||
var featureOrder = []string{"Radio", "Routing", "Advertising", "Location", "GPS", "Clock",
|
||||
"Region", "Neighbors", "Sensors", "Identity", "Access", "Power", "Diagnostics", "Firmware"}
|
||||
|
||||
func featureRank(f string) int {
|
||||
for i, x := range featureOrder {
|
||||
if x == f {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return len(featureOrder)
|
||||
}
|
||||
|
||||
func orderFeatures(present []string) {
|
||||
sort.SliceStable(present, func(i, j int) bool {
|
||||
ri, rj := featureRank(present[i]), featureRank(present[j])
|
||||
if ri != rj {
|
||||
return ri < rj
|
||||
}
|
||||
return present[i] < present[j]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -187,37 +187,6 @@
|
||||
</details>
|
||||
{{end}}
|
||||
|
||||
{{/* feature-table renders a []featureRow (commands grouped by feature × operation)
|
||||
for permission review/consent. Dot is the slice of rows. */}}
|
||||
{{define "feature-table"}}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter">
|
||||
<thead><tr>
|
||||
<th>Feature</th><th>Read</th><th>Write</th><th>Delete</th><th>Action</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{{range .}}
|
||||
<tr>
|
||||
<td class="fw-bold align-top">{{.Feature}}</td>
|
||||
{{template "feature-cell" .Read}}
|
||||
{{template "feature-cell" .Write}}
|
||||
{{template "feature-cell" .Delete}}
|
||||
{{template "feature-cell" .Action}}
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* feature-cell renders one cell of the feature table (a []cmdCell). Hovering a
|
||||
command shows its description via the title tooltip. */}}
|
||||
{{define "feature-cell"}}
|
||||
<td class="align-top">
|
||||
{{- if . }}{{range .}}<div class="mb-1"><code title="{{.Description}}" style="cursor:help">{{.Template}}</code>{{if .Risky}} <span class="badge bg-warning-lt">risky</span>{{end}}</div>{{end}}{{else}}<span class="text-secondary">—</span>{{end -}}
|
||||
</td>
|
||||
{{end}}
|
||||
|
||||
{{/* repstatus renders confirmation provenance + access badges for a *store.Repeater. */}}
|
||||
{{define "repstatus"}}
|
||||
{{- if not .Confirmed -}}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
{{define "title"}}Requested access · {{.Org.Name}} · MeshTender{{end}}
|
||||
{{define "header"}}
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col">
|
||||
<div class="page-pretitle">{{.Org.Name}}</div>
|
||||
<h2 class="page-title">Requested access</h2>
|
||||
</div>
|
||||
{{if .CanEdit}}
|
||||
<div class="col-auto ms-auto d-print-none">
|
||||
<a class="btn" href="/orgs/{{.Org.Slug}}/permissions/edit">{{template "icon-pencil" "me-1"}}Edit</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{define "content"}}
|
||||
{{template "org-tabs" .Nav}}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<p class="text-secondary mb-0">
|
||||
The commands this organization may run on a repeater you contribute, by tier
|
||||
(current policy <strong>v{{.Perms.Version}}</strong>). Members consent to this when they
|
||||
contribute a repeater; you stay on the version you consented to until you re-consent to changes.{{if .Perms.HasRisky}} Risky commands can take over or brick a node.{{end}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header"><h3 class="card-title">Members can run</h3></div>
|
||||
<div class="card-body">
|
||||
{{if .Perms.MemberFeatures}}{{template "feature-table" .Perms.MemberFeatures}}
|
||||
{{else}}<p class="text-secondary mb-0">Members aren't granted any commands.</p>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header"><h3 class="card-title">Admins can run</h3></div>
|
||||
<div class="card-body">
|
||||
{{if .Perms.AdminFeatures}}{{template "feature-table" .Perms.AdminFeatures}}
|
||||
{{else}}<p class="text-secondary mb-0">Admins aren't granted any commands.</p>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -14,7 +14,7 @@
|
||||
<!-- Left: list -->
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-header"><h3 class="card-title">Contributed repeaters</h3></div>
|
||||
<div class="card-header"><h3 class="card-title">Shared repeaters</h3></div>
|
||||
{{if .Reps.Repeaters}}
|
||||
<div class="list-group list-group-flush">
|
||||
{{range .Reps.Repeaters}}
|
||||
@@ -31,7 +31,7 @@
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="card-body"><p class="text-secondary mb-0">
|
||||
{{if .Reps.Full}}No repeaters have been contributed yet. Members can contribute their own from a repeater's “Organizations” page.
|
||||
{{if .Reps.Full}}No repeaters are shared yet. A member's repeaters are shared automatically; they can opt one out from its sharing page.
|
||||
{{else}}No repeaters are shown publicly. Members can opt a repeater into the public map when editing it.{{end}}
|
||||
</p></div>
|
||||
{{end}}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{{/* org-tabs renders the organization sub-navigation. Data: a map with Slug,
|
||||
Active ("home" | "repeaters" | "members" | "config" | "permissions") and IsMember
|
||||
Active ("home" | "repeaters" | "members" | "config") and IsMember
|
||||
(the Members tab only shows for members — it exposes personal info). Nil-safe: a
|
||||
missing map renders empty links (harmless for the empty-data compose test). Links
|
||||
are same-host relative so they work on both the app host (signed-in) and the root
|
||||
@@ -10,6 +10,5 @@ host (anonymous). */}}
|
||||
{{if .IsMember}}<li class="nav-item"><a class="nav-link{{if eq .Active "members"}} active{{end}}" href="/orgs/{{.Slug}}/members">{{template "icon-users" "me-1"}}Members</a></li>{{end}}
|
||||
<li class="nav-item"><a class="nav-link{{if eq .Active "repeaters"}} active{{end}}" href="/orgs/{{.Slug}}/repeaters">{{template "icon-antenna" "me-1"}}Repeaters</a></li>
|
||||
<li class="nav-item"><a class="nav-link{{if eq .Active "config"}} active{{end}}" href="/orgs/{{.Slug}}/config">{{template "icon-settings" "me-1"}}Configuration</a></li>
|
||||
<li class="nav-item"><a class="nav-link{{if eq .Active "permissions"}} active{{end}}" href="/orgs/{{.Slug}}/permissions">{{template "icon-list" "me-1"}}Requested access</a></li>
|
||||
</ul>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user