mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-16 16:42:37 +00:00
Fix wizard's org access management
This commit is contained in:
@@ -193,39 +193,8 @@ func TestRepeaterSharePosts(t *testing.T) {
|
||||
un.Body.Close()
|
||||
assertRedirect(t, un, share, "unshare")
|
||||
|
||||
// #87 participation: the per-org "Shared" switch. Unchecked submits no
|
||||
// "include" field (opt out); checked submits include=1 (participate).
|
||||
org, err := st.CreateOrg(ctx, "Participation Org", owner.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
excluded := func() bool {
|
||||
orgs, err := st.ListRepeaterOrgMemberships(ctx, rep.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list memberships: %v", err)
|
||||
}
|
||||
for _, o := range orgs {
|
||||
if o.OrgID == org.ID {
|
||||
return o.Excluded
|
||||
}
|
||||
}
|
||||
t.Fatalf("org %d not in memberships", org.ID)
|
||||
return false
|
||||
}
|
||||
// The {orgID} route param is the org slug, not the numeric id.
|
||||
partURL := "/repeaters/" + pid + "/orgs/" + org.Slug + "/participation"
|
||||
out := post(t, ts, h.app, partURL, url.Values{}, sess) // switch off → opt out
|
||||
out.Body.Close()
|
||||
assertRedirect(t, out, share, "org opt out")
|
||||
if !excluded() {
|
||||
t.Fatal("switch off did not opt the repeater out")
|
||||
}
|
||||
in := post(t, ts, h.app, partURL, url.Values{"include": {"1"}}, sess) // switch on
|
||||
in.Body.Close()
|
||||
assertRedirect(t, in, share, "org opt in")
|
||||
if excluded() {
|
||||
t.Fatal("switch on did not re-include the repeater")
|
||||
}
|
||||
// Org participation is exercised via the "manage access" limits save in
|
||||
// TestRepeaterOrgLimitsPosts (there is no standalone participation endpoint).
|
||||
}
|
||||
|
||||
// TestRepeaterOrgLimitsPosts covers the per-(repeater, org) command-limits modal:
|
||||
@@ -258,10 +227,10 @@ func TestRepeaterOrgLimitsPosts(t *testing.T) {
|
||||
t.Fatalf("need >=2 ceiling commands, got %d", len(ceiling))
|
||||
}
|
||||
|
||||
// GET renders the modal fragment (no page chrome), with the org name and cmd boxes.
|
||||
// GET renders the modal fragment (no page chrome): the Shared switch + cmd boxes.
|
||||
frag := readBody(t, do(t, ts, h.app, base, sess))
|
||||
if !strings.Contains(frag, "Command limits") || !strings.Contains(frag, `name="cmd"`) {
|
||||
t.Fatalf("limits fragment missing expected content:\n%s", frag)
|
||||
if !strings.Contains(frag, "Manage access") || !strings.Contains(frag, `name="cmd"`) || !strings.Contains(frag, `name="include"`) {
|
||||
t.Fatalf("manage-access fragment missing expected content:\n%s", frag)
|
||||
}
|
||||
if strings.Contains(frag, "back-link") {
|
||||
t.Fatal("limits fragment should be modal chrome, not a full page")
|
||||
@@ -290,17 +259,27 @@ func TestRepeaterOrgLimitsPosts(t *testing.T) {
|
||||
}
|
||||
return ids
|
||||
}
|
||||
excluded := func() bool {
|
||||
ex, err := st.IsRepeaterOrgExcluded(ctx, org.ID, rep.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("excluded: %v", err)
|
||||
}
|
||||
return ex
|
||||
}
|
||||
|
||||
// Restrict to exactly the first ceiling command.
|
||||
save := post(t, ts, h.app, base, url.Values{"cmd": {strconv.FormatInt(ceiling[0], 10)}}, sess)
|
||||
// Save restricts to exactly the first ceiling command, with the Shared switch on.
|
||||
save := post(t, ts, h.app, base, url.Values{"include": {"1"}, "cmd": {strconv.FormatInt(ceiling[0], 10)}}, sess)
|
||||
save.Body.Close()
|
||||
assertRedirect(t, save, "/repeaters/"+rep.PublicID+"/share", "save limits")
|
||||
if got := optIn(); len(got) != 1 || got[0] != ceiling[0] {
|
||||
t.Fatalf("opt-in = %v, want [%d]", got, ceiling[0])
|
||||
}
|
||||
if excluded() {
|
||||
t.Fatal("saving with the Shared switch on opted the repeater out")
|
||||
}
|
||||
|
||||
// Selecting the full ceiling collapses back to permissive (no rows stored).
|
||||
full := url.Values{}
|
||||
full := url.Values{"include": {"1"}}
|
||||
for _, id := range ceiling {
|
||||
full.Add("cmd", strconv.FormatInt(id, 10))
|
||||
}
|
||||
@@ -311,14 +290,54 @@ func TestRepeaterOrgLimitsPosts(t *testing.T) {
|
||||
t.Fatalf("full selection should store nothing (permissive), got %v", got)
|
||||
}
|
||||
|
||||
// Restrict again, then "Remove restriction" clears it.
|
||||
post(t, ts, h.app, base, url.Values{"cmd": {strconv.FormatInt(ceiling[0], 10)}}, sess).Body.Close()
|
||||
clr := post(t, ts, h.app, base, url.Values{"clear": {"1"}}, sess)
|
||||
// Restrict again, then "Remove restriction" clears it (switch still on).
|
||||
post(t, ts, h.app, base, url.Values{"include": {"1"}, "cmd": {strconv.FormatInt(ceiling[0], 10)}}, sess).Body.Close()
|
||||
clr := post(t, ts, h.app, base, url.Values{"include": {"1"}, "clear": {"1"}}, sess)
|
||||
clr.Body.Close()
|
||||
assertRedirect(t, clr, "/repeaters/"+rep.PublicID+"/share", "clear limits")
|
||||
if got := optIn(); len(got) != 0 {
|
||||
t.Fatalf("clear should remove all rows, got %v", got)
|
||||
}
|
||||
|
||||
// The Shared switch drives participation: saving with it off opts out; on opts in.
|
||||
off := post(t, ts, h.app, base, url.Values{}, sess) // switch off (no include field)
|
||||
off.Body.Close()
|
||||
assertRedirect(t, off, "/repeaters/"+rep.PublicID+"/share", "save opted out")
|
||||
if !excluded() {
|
||||
t.Fatal("saving with the Shared switch off did not opt the repeater out")
|
||||
}
|
||||
on := post(t, ts, h.app, base, url.Values{"include": {"1"}}, sess)
|
||||
on.Body.Close()
|
||||
assertRedirect(t, on, "/repeaters/"+rep.PublicID+"/share", "save opted in")
|
||||
if excluded() {
|
||||
t.Fatal("saving with the Shared switch on did not re-include the repeater")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRepeaterAddedPageOrgAccess: the add-repeater wizard's final step lists the
|
||||
// owner's orgs with the shared "Manage access" control (same as the share page),
|
||||
// not the removed one-click /participation opt-out.
|
||||
func TestRepeaterAddedPageOrgAccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, ctx, ts, h := splitServer(t)
|
||||
owner, sess := appLogin(t, ts, st, ctx, h.app, "addedowner")
|
||||
org, err := st.CreateOrg(ctx, "Added Org", owner.ID) // owner is a member
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rep := newOwnedRepeater(t, st, ctx, owner.ID, "Added Rep")
|
||||
|
||||
body := readBody(t, do(t, ts, h.app, "/repeaters/"+rep.PublicID+"/added", sess))
|
||||
if !strings.Contains(body, org.Name) {
|
||||
t.Fatalf("added page missing the owner's org %q:\n%s", org.Name, body)
|
||||
}
|
||||
if !strings.Contains(body, `data-testid="manage-access"`) || !strings.Contains(body, `id="limits-modal"`) {
|
||||
t.Fatal("added page missing the Manage access control / modal")
|
||||
}
|
||||
// The old dead route must be gone.
|
||||
if strings.Contains(body, "/participation") {
|
||||
t.Fatal("added page still references the removed /participation endpoint")
|
||||
}
|
||||
}
|
||||
|
||||
// #88 POST /invite/{token}/accept — a second user redeems a share link.
|
||||
|
||||
@@ -32,28 +32,11 @@ func (s *Handlers) repeaterOrgContext(w http.ResponseWriter, r *http.Request) (*
|
||||
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. Driven by the share page's per-org "Shared" switch: a
|
||||
// checked switch submits include=1 (participate), unchecked submits nothing (opt
|
||||
// out).
|
||||
func (s *Handlers) handleSetRepeaterOrg(w http.ResponseWriter, r *http.Request) {
|
||||
rep, orgID, ok := s.repeaterOrgContext(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
exclude := r.FormValue("include") != "1"
|
||||
if err := s.Store.SetRepeaterOrgExcluded(r.Context(), orgID, rep.ID, exclude); err != nil {
|
||||
s.ServerError(w, r, "could not update participation", err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, sharePath(rep.PublicID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// pageRepeaterOrgLimits renders the per-org command-limits modal fragment for one
|
||||
// repeater: which of the commands the org may run are allowed to run on this box.
|
||||
// No opt-in rows = permissive (every ceiling command checked). Editable regardless
|
||||
// of participation, so an owner can pre-set limits before opting an org back in.
|
||||
// pageRepeaterOrgLimits renders the per-org "manage access" modal fragment for one
|
||||
// repeater: the Shared (participation) switch plus which of the commands the org
|
||||
// may run are allowed to run on this box. No opt-in rows = permissive (every
|
||||
// ceiling command checked). Editable regardless of participation, so an owner can
|
||||
// pre-set limits before opting an org in.
|
||||
func (s *Handlers) pageRepeaterOrgLimits(w http.ResponseWriter, r *http.Request) {
|
||||
rep, orgID, ok := s.repeaterOrgContext(w, r)
|
||||
if !ok {
|
||||
@@ -106,9 +89,10 @@ func (s *Handlers) pageRepeaterOrgLimits(w http.ResponseWriter, r *http.Request)
|
||||
})
|
||||
}
|
||||
|
||||
// handleSaveRepeaterOrgLimits saves the per-(repeater, org) command opt-in list. A
|
||||
// full selection (or the "Remove restriction" button) clears it back to permissive
|
||||
// so we don't persist a redundant full allowlist.
|
||||
// handleSaveRepeaterOrgLimits applies the "manage access" modal: it sets the org's
|
||||
// participation (the Shared switch) and the per-(repeater, org) command opt-in list
|
||||
// together. A full selection (or the "Remove restriction" button) clears the list
|
||||
// back to permissive so we don't persist a redundant full allowlist.
|
||||
func (s *Handlers) handleSaveRepeaterOrgLimits(w http.ResponseWriter, r *http.Request) {
|
||||
rep, orgID, ok := s.repeaterOrgContext(w, r)
|
||||
if !ok {
|
||||
@@ -118,6 +102,12 @@ func (s *Handlers) handleSaveRepeaterOrgLimits(w http.ResponseWriter, r *http.Re
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Participation: the Shared switch submits include=1 when on, nothing when off.
|
||||
exclude := r.FormValue("include") != "1"
|
||||
if err := s.Store.SetRepeaterOrgExcluded(r.Context(), orgID, rep.ID, exclude); err != nil {
|
||||
s.ServerError(w, r, "could not update participation", err)
|
||||
return
|
||||
}
|
||||
var chosen []int64
|
||||
// "Remove restriction" clears the list regardless of checkboxes.
|
||||
if r.FormValue("clear") == "" {
|
||||
|
||||
@@ -145,12 +145,13 @@ func addErr(w http.ResponseWriter, r *http.Request, msg string) {
|
||||
// repeater: optionally confirm it with a modem now, and manage which organizations
|
||||
// it's shared with. People-sharing lives on the repeater's own sharing page.
|
||||
func (s *Handlers) pageRepeaterAdded(w http.ResponseWriter, r *http.Request) {
|
||||
uid := s.Auth.CurrentUserID(r.Context())
|
||||
rep, _, ok := s.requireRepeaterOwned(w, r)
|
||||
rep, id, ok := s.requireRepeaterOwned(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
orgs, err := s.Store.ListOrgsForUser(r.Context(), uid)
|
||||
// Same per-org data (with participation/limit state) the share page uses, so
|
||||
// the wizard's "Manage access" buttons and status badges match it exactly.
|
||||
orgs, err := s.Store.ListRepeaterOrgMemberships(r.Context(), id)
|
||||
if err != nil {
|
||||
s.ServerError(w, r, "could not load organizations", err)
|
||||
return
|
||||
|
||||
@@ -46,21 +46,11 @@
|
||||
{{if .Orgs}}
|
||||
<p class="text-secondary">
|
||||
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 organization may run, from the sharing page.
|
||||
the commands those organizations are permitted to run on it. Use <strong>Manage access</strong> to opt
|
||||
out of any of them or limit which commands it may run — you can also change this anytime 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}}
|
||||
<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>
|
||||
{{template "org-access-rows" .}}
|
||||
{{else}}
|
||||
<p class="text-secondary">
|
||||
You're not in any organizations yet. Joining one lets its admins help keep your repeater in spec.
|
||||
@@ -72,6 +62,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{template "limits-modal-shell" .}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -96,39 +96,12 @@
|
||||
<h3 class="card-title">Organizations</h3>
|
||||
<p class="text-secondary">
|
||||
This repeater is shared with every organization you belong to, so their admins and members can run the
|
||||
commands those organizations are permitted to run on it. Turn an organization off to opt out, or edit
|
||||
its command limits for <em>this</em> repeater.
|
||||
commands those organizations are permitted to run on it. Use <strong>Manage access</strong> to opt it
|
||||
out of an organization or limit which commands that organization may run on <em>this</em> repeater.
|
||||
</p>
|
||||
|
||||
{{if .Orgs}}
|
||||
<div class="list-group list-group-flush">
|
||||
{{range .Orgs}}
|
||||
<div class="list-group-item px-0" data-testid="share-org-row">
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
<span class="fw-bold">{{.OrgName}}</span>
|
||||
{{if .Excluded}}
|
||||
<span class="badge bg-secondary-lt">Opted out</span>
|
||||
{{else if .Restricted}}
|
||||
<span class="badge bg-yellow-lt">Limited commands</span>
|
||||
{{else}}
|
||||
<span class="badge bg-success-lt">All commands</span>
|
||||
{{end}}
|
||||
<div class="ms-auto d-flex align-items-center gap-3">
|
||||
<button type="button" class="btn btn-sm" data-bs-toggle="modal" data-bs-target="#limits-modal"
|
||||
data-testid="edit-limits"
|
||||
hx-get="/repeaters/{{$.Repeater.PublicID}}/orgs/{{.OrgSlug}}/limits"
|
||||
hx-target="#limits-modal-content" hx-swap="innerHTML">Edit limits</button>
|
||||
<form method="post" action="/repeaters/{{$.Repeater.PublicID}}/orgs/{{.OrgSlug}}/participation" class="m-0">
|
||||
<label class="form-check form-switch m-0">
|
||||
<input class="form-check-input" type="checkbox" name="include" value="1" {{if not .Excluded}}checked{{end}}
|
||||
data-autosubmit aria-label="Share this repeater with {{.OrgName}}">
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{template "org-access-rows" .}}
|
||||
{{else}}
|
||||
<p class="text-secondary">Join an organization to share this repeater with it.</p>
|
||||
{{end}}
|
||||
@@ -137,18 +110,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shared modals; the People "Create single-use link" and each org's "Edit
|
||||
limits" button load their fragments here via htmx. -->
|
||||
<!-- Shared modals; the People "Create single-use link" button and each org's
|
||||
"Manage access" button load their fragments here via htmx. -->
|
||||
<div class="modal modal-blur fade" id="invite-modal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg" role="document">
|
||||
<div class="modal-content" id="invite-modal-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal modal-blur fade" id="limits-modal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg" role="document">
|
||||
<div class="modal-content" id="limits-modal-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
{{template "limits-modal-shell" .}}
|
||||
|
||||
<a class="back-link mt-3" href="/">{{template "icon-arrow-left" "me-1"}}Back to dashboard</a>
|
||||
{{end}}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
{{/* org-limits-modal is the htmx fragment swapped into the share page's shared
|
||||
#limits-modal. The form posts normally (full navigation back to the share
|
||||
page), which also closes the modal — no client JS needed. */}}
|
||||
#limits-modal. It controls both this org's participation (the "Shared" switch)
|
||||
and its per-repeater command limits; one Save applies both. The form posts
|
||||
normally (full navigation back to the share page), which also closes the
|
||||
modal — no client JS needed. */}}
|
||||
{{define "org-limits-modal"}}
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Command limits — {{.Org.Name}}</h5>
|
||||
<h5 class="modal-title">Manage access — {{.Org.Name}}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<form method="post" action="/repeaters/{{.Repeater.PublicID}}/orgs/{{.Org.Slug}}/limits">
|
||||
<div class="modal-body">
|
||||
<label class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" name="include" value="1" {{if not .Excluded}}checked{{end}}>
|
||||
<span class="form-check-label"><strong>Shared with {{.Org.Name}}</strong></span>
|
||||
</label>
|
||||
{{if .Excluded}}
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<div class="d-flex">
|
||||
@@ -16,8 +22,8 @@
|
||||
<h4 class="alert-title">This repeater is opted out of {{.Org.Name}}</h4>
|
||||
<p class="mb-0">
|
||||
While it's opted out, <strong>no one</strong> in {{.Org.Name}} can run <strong>any</strong>
|
||||
command below. Set these limits now if you like — they'll take effect when you opt the repeater
|
||||
back in with the <strong>Shared</strong> switch on the sharing page.
|
||||
command below. You can still set these limits now — turn on <strong>Shared with {{.Org.Name}}</strong>
|
||||
above and Save to opt back in.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -38,7 +44,7 @@
|
||||
<button type="submit" name="clear" value="1" class="btn btn-link link-secondary me-auto">Remove restriction (allow all)</button>
|
||||
{{end}}
|
||||
<button type="button" class="btn btn-link link-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save limits</button>
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
{{end}}
|
||||
|
||||
@@ -207,7 +207,6 @@ func (s *Handlers) appRouter() chi.Router {
|
||||
r.Get("/repeaters/{id}/share/{userID}/commands", s.pageShareCommands)
|
||||
r.Post("/repeaters/{id}/share/{userID}/commands", s.handleSetShareCommands)
|
||||
r.Post("/repeaters/{id}/share/{userID}/steward", s.handleSetShareSteward)
|
||||
r.Post("/repeaters/{id}/orgs/{orgID}/participation", s.handleSetRepeaterOrg)
|
||||
r.Get("/repeaters/{id}/orgs/{orgID}/limits", s.pageRepeaterOrgLimits)
|
||||
r.Post("/repeaters/{id}/orgs/{orgID}/limits", s.handleSaveRepeaterOrgLimits)
|
||||
r.Post("/invite/{token}/accept", s.handleAcceptInvite)
|
||||
|
||||
@@ -59,8 +59,8 @@ func TestE2EShareOrgLimitsModal(t *testing.T) {
|
||||
setSessionCookie(cookie),
|
||||
chromedp.Navigate(shareURL),
|
||||
// Open the per-org limits modal; htmx loads the command grid into it.
|
||||
chromedp.WaitVisible(`[data-testid="edit-limits"]`, chromedp.ByQuery),
|
||||
chromedp.Click(`[data-testid="edit-limits"]`, chromedp.ByQuery),
|
||||
chromedp.WaitVisible(`[data-testid="manage-access"]`, chromedp.ByQuery),
|
||||
chromedp.Click(`[data-testid="manage-access"]`, chromedp.ByQuery),
|
||||
chromedp.WaitVisible(`#limits-modal-content [data-check-scope]`, chromedp.ByQuery),
|
||||
chromedp.Evaluate(countChecked, &initial),
|
||||
// Default is permissive: everything checked. Uncheck only the first section.
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
//go:build browser
|
||||
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
cdplog "github.com/chromedp/cdproto/log"
|
||||
"github.com/chromedp/cdproto/network"
|
||||
"github.com/chromedp/chromedp"
|
||||
|
||||
"github.com/jleight/meshtender/internal/store"
|
||||
)
|
||||
|
||||
// TestE2EWizardManageAccessModal verifies the add-repeater wizard's final step
|
||||
// hosts the same "Manage access" modal as the share page: the button opens the
|
||||
// shared modal (Bootstrap under the strict CSP) and htmx loads the org-limits
|
||||
// fragment into it. Guards the fix for the wizard's old dead-route opt-out button.
|
||||
func TestE2EWizardManageAccessModal(t *testing.T) {
|
||||
srv := newE2EServer(t)
|
||||
user, cookie := srv.login(t, "e2ewizard")
|
||||
|
||||
// CreateOrg makes the creator a member, so the new repeater participates and
|
||||
// the wizard lists it with a Manage access button.
|
||||
if _, err := srv.store.CreateOrg(srv.ctx, "Wizard Org", user.ID); err != nil {
|
||||
t.Fatalf("create org: %v", err)
|
||||
}
|
||||
rep, err := srv.store.CreateRepeater(srv.ctx, &store.Repeater{
|
||||
OwnerID: user.ID, Name: "Rep", PublicKeyHex: strings.Repeat("a", 64),
|
||||
RadioFreqHz: 1, RadioBwHz: 1, RadioSF: 11, RadioCR: 5,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create repeater: %v", err)
|
||||
}
|
||||
|
||||
bctx, cancel, watch := startBrowser(t)
|
||||
defer cancel()
|
||||
|
||||
addedURL := srv.appURL + "/repeaters/" + rep.PublicID + "/added"
|
||||
var hasSwitch bool
|
||||
if err := chromedp.Run(bctx,
|
||||
network.Enable(),
|
||||
cdplog.Enable(),
|
||||
setSessionCookie(cookie),
|
||||
chromedp.Navigate(addedURL),
|
||||
chromedp.WaitVisible(`[data-testid="manage-access"]`, chromedp.ByQuery),
|
||||
chromedp.Click(`[data-testid="manage-access"]`, chromedp.ByQuery),
|
||||
// htmx loads the org-limits fragment: the Shared switch + command grid.
|
||||
chromedp.WaitVisible(`#limits-modal-content [data-check-scope]`, chromedp.ByQuery),
|
||||
chromedp.Evaluate(`!!document.querySelector('#limits-modal-content input[name=include]')`, &hasSwitch),
|
||||
); err != nil {
|
||||
t.Fatalf("browser run against %s: %v", addedURL, err)
|
||||
}
|
||||
if !hasSwitch {
|
||||
t.Fatal("manage-access modal missing the Shared switch")
|
||||
}
|
||||
watch.assertClean(t)
|
||||
}
|
||||
+2
-2
@@ -26,7 +26,7 @@ import (
|
||||
"github.com/jleight/meshtender/internal/store"
|
||||
)
|
||||
|
||||
//go:embed templates/base.html templates/icons.html templates/org_tabs.html templates/repeater_tabs.html templates/command_grid.html templates/org_public.html templates/org_config.html templates/org_repeaters.html templates/error.html
|
||||
//go:embed templates/base.html templates/icons.html templates/org_tabs.html templates/repeater_tabs.html templates/command_grid.html templates/org_access.html templates/org_public.html templates/org_config.html templates/org_repeaters.html templates/error.html
|
||||
var sharedTemplatesFS embed.FS
|
||||
|
||||
// sharedPages are full content pages (not just layout partials) that more than
|
||||
@@ -229,7 +229,7 @@ func TimeElement(t time.Time, kind string) template.HTML {
|
||||
}
|
||||
|
||||
func NewRenderer(cfg *config.Config, surfaceTemplates fs.FS) (*Renderer, error) {
|
||||
base, err := template.New("").Funcs(templateFuncs).ParseFS(sharedTemplatesFS, "templates/base.html", "templates/icons.html", "templates/org_tabs.html", "templates/repeater_tabs.html", "templates/command_grid.html")
|
||||
base, err := template.New("").Funcs(templateFuncs).ParseFS(sharedTemplatesFS, "templates/base.html", "templates/icons.html", "templates/org_tabs.html", "templates/repeater_tabs.html", "templates/command_grid.html", "templates/org_access.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
{{/* org-access-rows renders the per-org access list shared by the share page and
|
||||
the add-repeater wizard: each org the repeater's owner belongs to, with a
|
||||
status badge and a "Manage access" button that loads the shared #limits-modal
|
||||
via htmx. Data: $.Repeater (needs .PublicID) and .Orgs ([]RepeaterOrgMembership
|
||||
with .OrgName / .OrgSlug / .Excluded / .Restricted). Pair it with
|
||||
limits-modal-shell on the same page. */}}
|
||||
{{define "org-access-rows"}}
|
||||
<div class="list-group list-group-flush">
|
||||
{{range .Orgs}}
|
||||
<div class="list-group-item d-flex align-items-center gap-2 flex-wrap px-0" data-testid="share-org-row">
|
||||
<span class="fw-bold">{{.OrgName}}</span>
|
||||
{{if .Excluded}}
|
||||
<span class="badge bg-secondary-lt">Opted out</span>
|
||||
{{else if .Restricted}}
|
||||
<span class="badge bg-yellow-lt">Limited commands</span>
|
||||
{{else}}
|
||||
<span class="badge bg-success-lt">All commands</span>
|
||||
{{end}}
|
||||
<button type="button" class="btn btn-sm ms-auto" data-bs-toggle="modal" data-bs-target="#limits-modal"
|
||||
data-testid="manage-access"
|
||||
hx-get="/repeaters/{{$.Repeater.PublicID}}/orgs/{{.OrgSlug}}/limits"
|
||||
hx-target="#limits-modal-content" hx-swap="innerHTML">Manage access</button>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* limits-modal-shell is the empty shared modal the "Manage access" buttons load
|
||||
their org-limits fragment into via htmx. */}}
|
||||
{{define "limits-modal-shell"}}
|
||||
<div class="modal modal-blur fade" id="limits-modal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg" role="document">
|
||||
<div class="modal-content" id="limits-modal-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user