Hide command permissions when steward is active

This commit is contained in:
Jonathon Leight
2026-08-09 14:42:07 -04:00
parent 737a6985b6
commit 0f291698ec
3 changed files with 114 additions and 10 deletions
+19 -10
View File
@@ -10,22 +10,31 @@
</div>
<div class="modal-body">
<form method="post" action="/repeaters/{{.Repeater.PublicID}}/share/{{.Target.ID}}/access" id="person-access-form">
<div class="hr-text hr-text-left mt-0 mb-3">Steward</div>
{{/* Steward supersedes the per-command grants (store.ListSendableCommandIDs
gives a steward every command), so the two are not parallel choices and
showing them side by side invited setting both. The switch collapses the
grid via data-hide-target; the checkboxes keep submitting, so turning
steward back off restores the selection rather than starting from none. */}}
<div class="hr-text hr-text-left mt-0 mb-3">Access level</div>
<label class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="steward" value="1" {{if .Steward}}checked{{end}}>
<input class="form-check-input" type="checkbox" name="steward" value="1"
data-hide-target="#person-commands" {{if .Steward}}checked{{end}}>
<span class="form-check-label"><strong>Steward — full access</strong></span>
</label>
<p class="form-hint">
<p class="form-hint mb-0">
A steward can run <strong>every</strong> command (including risky ones), just like you, and is listed
as a backup maintainer on this repeater's public page.
as a backup maintainer on this repeater's public page. Leave it off to grant individual commands
instead.
</p>
<div class="hr-text hr-text-left mt-5 mb-3">Permissions</div>
<p class="text-secondary">
Choose which commands {{.Target.Name}} may send. These apply when steward is off — a steward already
has every command. Risky commands can take over or brick the node; grant them only with care.
</p>
{{template "command-grid" .}}
<div id="person-commands">
<div class="hr-text hr-text-left mt-4 mb-3">Commands</div>
<p class="text-secondary">
Choose which commands {{.Target.Name}} may send. Risky commands can take over or brick the node;
grant them only with care.
</p>
{{template "command-grid" .}}
</div>
</form>
<form method="post" action="/repeaters/{{.Repeater.PublicID}}/unshare" id="person-revoke-form">
<input type="hidden" name="user_id" value="{{.Target.ID}}">
+67
View File
@@ -58,3 +58,70 @@ func TestE2EPersonAccessModal(t *testing.T) {
}
watch.assertClean(t)
}
// TestE2EStewardSwitchCollapsesCommands: a steward already runs every command, so
// the per-command grid is moot while the switch is on and the modal collapses it.
// Browser-only behavior worth pinning — it's a delegated ui.js handler acting on an
// htmx-swapped fragment, which is exactly the combination that breaks silently
// (a fragment can't run inline script, and the strict CSP forbids one anyway).
func TestE2EStewardSwitchCollapsesCommands(t *testing.T) {
srv := newE2EServer(t)
owner, cookie := srv.login(t, "e2esteward")
rep, err := srv.store.CreateRepeater(srv.ctx, &store.Repeater{
OwnerID: owner.ID, Name: "Rep", PublicKeyHex: strings.Repeat("b", 64),
RadioFreqHz: 1, RadioBwHz: 1, RadioSF: 11, RadioCR: 5,
})
if err != nil {
t.Fatalf("create repeater: %v", err)
}
sharee, err := srv.store.CreateUser(srv.ctx, "e2estewardee", "")
if err != nil {
t.Fatalf("create sharee: %v", err)
}
if _, err := srv.store.AddShare(srv.ctx, rep.ID, sharee.ID); err != nil {
t.Fatalf("add share: %v", err)
}
// Already a steward, so the fragment arrives with the switch checked: the
// collapse has to be applied on swap, not only on a change event.
if err := srv.store.SetShareSteward(srv.ctx, rep.ID, sharee.ID, true); err != nil {
t.Fatalf("set steward: %v", err)
}
bctx, cancel, watch := startBrowser(t)
defer cancel()
const visible = `(() => {
const el = document.querySelector('#person-commands');
return !!el && !el.hidden;
})()`
shareURL := srv.appURL + "/repeaters/" + rep.PublicID + "/share"
var shownAsSteward, shownAfterOff, shownAfterOn bool
if err := chromedp.Run(bctx,
network.Enable(),
cdplog.Enable(),
setSessionCookie(cookie),
chromedp.Navigate(shareURL),
chromedp.WaitVisible(`[data-testid="manage-person"]`, chromedp.ByQuery),
chromedp.Click(`[data-testid="manage-person"]`, chromedp.ByQuery),
chromedp.WaitVisible(`#person-modal-content input[name=steward]`, chromedp.ByQuery),
chromedp.Evaluate(visible, &shownAsSteward),
// Turning steward off reveals the grants that apply instead.
chromedp.Click(`#person-modal-content input[name=steward]`, chromedp.ByQuery),
chromedp.Evaluate(visible, &shownAfterOff),
chromedp.Click(`#person-modal-content input[name=steward]`, chromedp.ByQuery),
chromedp.Evaluate(visible, &shownAfterOn),
); err != nil {
t.Fatalf("browser run against %s: %v", shareURL, err)
}
if shownAsSteward {
t.Error("command grid is visible for an existing steward; the swapped-in fragment was not synced")
}
if !shownAfterOff {
t.Error("turning steward off did not reveal the per-command grants")
}
if shownAfterOn {
t.Error("turning steward back on did not collapse the per-command grants")
}
watch.assertClean(t)
}
+28
View File
@@ -12,6 +12,14 @@
// it is (un)checked.
// [data-gated] — a link/button whose activation is blocked while
// aria-disabled="true".
// [data-hide-target="#sel"] — a checkbox that hides the target while checked,
// for a switch that makes a section moot (the
// steward toggle vs. per-command grants). The
// target's inputs still submit: hiding says the
// section doesn't apply right now, not that its
// values are gone. Synced on load and after an
// htmx swap, so a fragment rendered with the box
// already checked starts collapsed.
// [data-check-all] / — a button that checks (all) or unchecks (none)
// [data-check-none] every enabled checkbox within its scope. The
// scope is the closest [data-check-scope]
@@ -126,9 +134,23 @@
}
}
// A checkbox that hides a section its state makes irrelevant. Inputs inside stay
// enabled so they keep submitting — see the data-hide-target note at the top.
function syncHideTargets(scope) {
var boxes = (scope || document).querySelectorAll("input[type=checkbox][data-hide-target]");
for (var i = 0; i < boxes.length; i++) applyHideTarget(boxes[i]);
}
function applyHideTarget(box) {
var target = document.querySelector(box.getAttribute("data-hide-target"));
if (!target) return;
target.hidden = !!box.checked;
}
function onReady() {
formatTimes(document);
localTodayInputs(document);
syncHideTargets(document);
}
if (document.readyState === "loading") {
@@ -139,6 +161,7 @@
// htmx swaps in server HTML that may contain <time> elements.
document.body && document.addEventListener("htmx:afterSwap", function (e) {
formatTimes(e.target || document);
syncHideTargets(e.target || document);
});
document.addEventListener("submit", function (e) {
@@ -215,6 +238,11 @@
return;
}
if (el.matches("input[type=checkbox][data-hide-target]")) {
applyHideTarget(el);
return;
}
if (el.matches("[data-consent-target]")) {
var target = document.querySelector(el.getAttribute("data-consent-target"));
if (!target) return;