Better command and consent display

This commit is contained in:
Jonathon Leight
2026-06-22 09:41:26 -04:00
parent d973764740
commit b6d1dff788
11 changed files with 427 additions and 108 deletions
+21 -1
View File
@@ -55,11 +55,31 @@ func groupByCategory[T any](catalog []*store.Command, mk func(*store.Command) T)
return groups
}
// groupByFeature buckets the catalog by feature area (store.Command.Feature) —
// the same grouping the consent page uses — ordered by featureOrder, mapping
// each command via mk.
func groupByFeature[T any](catalog []*store.Command, mk func(*store.Command) T) []categoryGroup[T] {
byFeature := map[string][]T{}
var present []string
for _, c := range catalog {
if _, ok := byFeature[c.Feature]; !ok {
present = append(present, c.Feature)
}
byFeature[c.Feature] = append(byFeature[c.Feature], mk(c))
}
orderFeatures(present)
groups := make([]categoryGroup[T], 0, len(present))
for _, f := range present {
groups = append(groups, categoryGroup[T]{Name: f, Commands: byFeature[f]})
}
return groups
}
// catalogGroup buckets raw catalog commands for the admin catalog page.
type catalogGroup = categoryGroup[*store.Command]
func groupCatalog(catalog []*store.Command) []catalogGroup {
return groupByCategory(catalog, func(c *store.Command) *store.Command { return c })
return groupByFeature(catalog, func(c *store.Command) *store.Command { return c })
}
func (s *Handlers) pageAdmin(w http.ResponseWriter, r *http.Request) {
+90
View File
@@ -0,0 +1,90 @@
package core
import (
"sort"
"github.com/jleight/meshtender/internal/store"
)
// Presentation grouping for the permission review/consent and catalog UIs. Each
// command's feature area and operation (read/write/delete/action) live on the
// catalog row (store.Command.Feature/.Operation, set in the DB); this file only
// orders and buckets them. The security model (arity, per-command auth) is
// unaffected.
// featureOrder is the display order of feature groups. Features not listed here
// (e.g. a newly introduced one) sort after these, alphabetically — so nothing is
// ever dropped from the UI even before this list is updated.
var featureOrder = []string{"Radio", "Routing", "Advertising", "Location", "GPS", "Clock",
"Region", "Neighbors", "Sensors", "Identity", "Access", "Power", "Diagnostics", "Firmware"}
// featureRank gives a feature's position in featureOrder, or a large value
// (sorting it after the known features) when it isn't listed.
func featureRank(f string) int {
for i, x := range featureOrder {
if x == f {
return i
}
}
return len(featureOrder)
}
// orderFeatures sorts the distinct feature names by featureOrder, with unknown
// features appended alphabetically.
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]
})
}
// cmdCell is one command shown in a feature-table cell.
type cmdCell struct {
Template string
Description string // shown as a hover tooltip
Risky bool
}
// featureRow is one feature's commands bucketed by operation, for the review UI.
type featureRow struct {
Feature string
Read, Write, Delete, Action []cmdCell
}
// featureTableFor groups the commands in `allowed` (the id-set a single role may
// run) by feature × operation, ordered by featureOrder — one table per role.
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
}
+30 -3
View File
@@ -142,14 +142,36 @@ func TestValidCommandText(t *testing.T) {
}
}
// TestCommandFeatureCoverage ensures every catalog command carries a feature and
// a valid operation, and that its feature is listed in featureOrder so it renders
// in a known position (a feature missing from featureOrder still shows, but at
// the end — this catches the Go list drifting from the DB). Requires the *_test
// database. The DB also enforces feature<>'' and operation IN (...) via CHECK.
func TestCommandFeatureCoverage(t *testing.T) {
cat := loadRealCatalog(t)
validOp := map[string]bool{"read": true, "write": true, "delete": true, "action": true}
for _, c := range cat {
if c.Feature == "" {
t.Errorf("command %q has no feature", c.Key)
} else if featureRank(c.Feature) == len(featureOrder) {
t.Errorf("command %q feature %q is not in featureOrder", c.Key, c.Feature)
}
if !validOp[c.Operation] {
t.Errorf("command %q has invalid operation %q", c.Key, c.Operation)
}
}
}
// TestResolveCommandRealCatalog enforces, against the actual seeded catalog, the
// two invariants the console parser's safety depends on:
// 1. No two commands share a (token, arity) — resolution is never ambiguous.
// 2. Every fixed-arity command's arity equals its template's "<arg>" count, and
// each command's own template round-trips back to itself through the parser.
//
// Requires the *_test database (it runs migrations).
func TestResolveCommandRealCatalog(t *testing.T) {
// loadRealCatalog migrates the *_test database and returns the seeded catalog,
// skipping the test when the DB isn't configured.
func loadRealCatalog(t *testing.T) []*store.Command {
t.Helper()
dsn := os.Getenv("MESHTENDER_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("set MESHTENDER_TEST_DATABASE_URL to run this integration test")
@@ -162,7 +184,7 @@ func TestResolveCommandRealCatalog(t *testing.T) {
if err != nil {
t.Fatalf("store: %v", err)
}
defer st.Close()
t.Cleanup(st.Close)
if err := st.Migrate(ctx); err != nil {
t.Fatalf("migrate: %v", err)
}
@@ -173,7 +195,12 @@ func TestResolveCommandRealCatalog(t *testing.T) {
if len(cat) == 0 {
t.Fatal("empty catalog")
}
return cat
}
// Requires the *_test database (it runs migrations).
func TestResolveCommandRealCatalog(t *testing.T) {
cat := loadRealCatalog(t)
argGroup := regexp.MustCompile(`<[^>]*>`)
seen := map[string]string{} // (token|arity) -> key
for _, c := range cat {
+13 -27
View File
@@ -56,11 +56,14 @@ func (s *Handlers) pageContribute(w http.ResponseWriter, r *http.Request) {
http.Error(w, "could not load commands", http.StatusInternalServerError)
return
}
members := idSet(memberIDs)
data := map[string]any{
"Repeater": rep,
"Org": org,
"Version": version,
"Envelope": permEnvelope(catalog, idSet(adminIDs), idSet(memberIDs)),
"Repeater": rep,
"Org": org,
"Version": version,
"MemberFeatures": featureTableFor(catalog, members),
// Admins inherit every member command, so their table is member admin.
"AdminFeatures": featureTableFor(catalog, union(idSet(adminIDs), members)),
}
// If already contributed and behind the current version, show what changed
@@ -89,25 +92,6 @@ func (s *Handlers) pageContribute(w http.ResponseWriter, r *http.Request) {
s.Render(w, r, "contribute.html", data)
}
// permEnvelope groups the catalog by category, keeping only commands granted to
// at least one tier in the given admin/member id sets — the set of commands a
// permission version actually allows.
func permEnvelope(catalog []*store.Command, adminSet, memberSet map[int64]bool) []permGroup {
var envelope []permGroup
for _, g := range groupPermissions(catalog, adminSet, memberSet) {
var cmds []permChoice
for _, c := range g.Commands {
if c.AdminChecked || c.MemberChecked {
cmds = append(cmds, c)
}
}
if len(cmds) > 0 {
envelope = append(envelope, permGroup{Name: g.Name, Commands: cmds})
}
}
return envelope
}
// 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
@@ -142,11 +126,13 @@ func (s *Handlers) pageConsented(w http.ResponseWriter, r *http.Request) {
http.Error(w, "could not load commands", http.StatusInternalServerError)
return
}
members := idSet(memberIDs)
data := map[string]any{
"Repeater": rep,
"Org": org,
"Version": version,
"Envelope": permEnvelope(catalog, idSet(adminIDs), idSet(memberIDs)),
"Repeater": rep,
"Org": org,
"Version": version,
"MemberFeatures": featureTableFor(catalog, members),
"AdminFeatures": 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 {
+39 -22
View File
@@ -10,30 +10,47 @@
{{define "content"}}
<div class="card">
<div class="card-body">
<p class="text-secondary">
The firmware commands MeshTender can send. There's no global on/off — a repeater owner can run
anything. These flags seed what other people are offered: <strong>share</strong> = default for new
shares; <strong>member</strong>/<strong>admin</strong> = the org default sets (Phase 2).
<strong>Risky</strong> commands can take over or brick a node.
<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.
</p>
{{if .Saved}}<div class="alert alert-success">Saved</div>{{end}}
{{range .Groups}}
<fieldset class="form-fieldset">
<div class="form-label fw-bold text-capitalize">{{.Name}}</div>
{{range .Commands}}
<form method="post" action="/admin/catalog/{{.ID}}" class="d-flex align-items-center flex-wrap gap-3 py-1 border-bottom">
<code class="flex-fill">{{.Template}}</code>
<label class="form-check form-check-inline m-0"><input class="form-check-input" type="checkbox" name="risky" {{if .Risky}}checked{{end}}><span class="form-check-label">risky</span></label>
<label class="form-check form-check-inline m-0"><input class="form-check-input" type="checkbox" name="share" {{if .InShareDefault}}checked{{end}}><span class="form-check-label">share</span></label>
<label class="form-check form-check-inline m-0"><input class="form-check-input" type="checkbox" name="org_member" {{if .InOrgMemberDefault}}checked{{end}}><span class="form-check-label">member</span></label>
<label class="form-check form-check-inline m-0"><input class="form-check-input" type="checkbox" name="org_admin" {{if .InOrgAdminDefault}}checked{{end}}><span class="form-check-label">admin</span></label>
<button type="submit" class="btn btn-sm">Save</button>
</form>
{{end}}
</fieldset>
{{end}}
{{if .Saved}}<div class="alert alert-success mt-3 mb-0">Saved</div>{{end}}
</div>
</div>
{{range .Groups}}
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">{{.Name}}</h3></div>
<div class="table-responsive">
<table class="table table-vcenter card-table catalog-table">
<thead>
<tr>
<th>Command</th>
<th class="text-center" style="width:7rem">Risky</th>
<th class="text-center" style="width:7rem">Share</th>
<th class="text-center" style="width:7rem">Member</th>
<th class="text-center" style="width:7rem">Admin</th>
<th style="width:6rem"></th>
</tr>
</thead>
<tbody>
{{range .Commands}}
<tr>
<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-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}}
</tbody>
</table>
</div>
</div>
{{end}}
<a class="back-link mt-3" href="/admin">{{template "icon-arrow-left" "me-1"}}Admin</a>
{{end}}
+22 -23
View File
@@ -10,36 +10,35 @@
{{define "content"}}
<div class="card">
<div class="card-body">
<p class="text-secondary">
<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}}). Org-admins also get everything members get.
policy you consented to (v{{.Version}}). Org admins also get everything members get. Hover a command to
see what it does.
</p>
{{if .CurrentVersion}}
<div class="alert alert-warning">
<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}}
{{if .Envelope}}
{{range .Envelope}}
<fieldset class="form-fieldset">
<div class="form-label fw-bold text-capitalize">{{.Name}}</div>
{{range .Commands}}
<div class="d-flex align-items-center flex-wrap gap-2 py-1">
<code class="flex-fill">{{.Template}}{{if .Risky}} <span class="badge bg-warning-lt">risky</span>{{end}}</code>
{{if .AdminChecked}}<span class="badge bg-success-lt">admin</span>{{end}}
{{if .MemberChecked}}<span class="badge bg-azure-lt">member</span>{{end}}
</div>
{{end}}
</fieldset>
{{end}}
{{else}}
<p class="text-secondary">This version grants no commands.</p>
{{end}}
<a class="back-link mt-3" href="/repeaters/{{.Repeater.PublicID}}/share">{{template "icon-arrow-left" "me-1"}}Back to sharing</a>
</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}}
+27 -28
View File
@@ -10,15 +10,15 @@
{{define "content"}}
<div class="card">
<div class="card-body">
<p class="text-secondary">
<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}}). Org-admins also get everything members get. If the org later
over the mesh (policy v{{.Version}}). Org admins also get everything members get. If the org later
adds commands, this repeater stays on v{{.Version}} until you review and re-consent. You can
withdraw anytime.
withdraw anytime. Hover a command to see what it does.
</p>
{{if .Reconsent}}
<div class="alert alert-warning">
<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}}
@@ -28,30 +28,29 @@
</div>{{end}}
</div>
{{end}}
{{if .Envelope}}
{{range .Envelope}}
<fieldset class="form-fieldset">
<div class="form-label fw-bold text-capitalize">{{.Name}}</div>
{{range .Commands}}
<div class="d-flex align-items-center flex-wrap gap-2 py-1">
<code class="flex-fill">{{.Template}}{{if .Risky}} <span class="badge bg-warning-lt">risky</span>{{end}}</code>
{{if .AdminChecked}}<span class="badge bg-success-lt">admin</span>{{end}}
{{if .MemberChecked}}<span class="badge bg-azure-lt">member</span>{{end}}
</div>
{{end}}
</fieldset>
{{end}}
{{else}}
<p class="text-secondary">This org's policy currently grants no commands.</p>
{{end}}
<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>
</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 -4
View File
@@ -17,20 +17,25 @@ type Command struct {
// Arity is the exact number of whitespace-separated argument tokens the
// command takes (after its command token). -1 means variadic / rest-of-line
// (e.g. "set name <text>"). The console parser authorizes by (token, arity).
Arity int
Description string
Arity int
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
}
const commandCols = `id, key, template, category, args, arity, description, risky,
const commandCols = `id, key, template, category, args, arity, description, feature, operation, risky,
in_share_default, in_org_member_default, in_org_admin_default`
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.Risky,
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)
if err != nil {
return nil, err
@@ -0,0 +1,136 @@
-- +goose Up
-- Move the command "feature" (grouping area) and "operation" (read/write/delete/
-- action) out of hard-coded Go tables and onto the catalog row, so adding a
-- command requires classifying it (NOT NULL, no default) and it can't be
-- forgotten. Values backfilled from the prior hard-coded classification.
ALTER TABLE command_catalog ADD COLUMN feature TEXT NOT NULL DEFAULT '';
ALTER TABLE command_catalog ADD COLUMN operation TEXT NOT NULL DEFAULT '';
UPDATE command_catalog c SET feature = v.feature, operation = v.op
FROM (VALUES
('advert', 'Advertising', 'action'),
('advert.zerohop', 'Advertising', 'action'),
('board', 'Diagnostics', 'read'),
('clear-stats', 'Diagnostics', 'delete'),
('clkreboot', 'Clock', 'action'),
('clock', 'Clock', 'read'),
('clock.sync', 'Clock', 'write'),
('discover.neighbors', 'Neighbors', 'action'),
('get.adc_multiplier', 'Power', 'read'),
('get.advert_interval', 'Advertising', 'read'),
('get.agc_reset_interval', 'Routing', 'read'),
('get.airtime_factor', 'Routing', 'read'),
('get.allow_read_only', 'Access', 'read'),
('get.cad', 'Routing', 'read'),
('get.direct_txdelay', 'Routing', 'read'),
('get.dutycycle', 'Routing', 'read'),
('get.flood_advert_interval', 'Advertising', 'read'),
('get.flood_max', 'Routing', 'read'),
('get.flood_max_advert', 'Routing', 'read'),
('get.flood_max_unscoped', 'Routing', 'read'),
('get.freq', 'Radio', 'read'),
('get.guest_password', 'Access', 'read'),
('get.int_thresh', 'Routing', 'read'),
('get.lat', 'Location', 'read'),
('get.lon', 'Location', 'read'),
('get.loop_detect', 'Routing', 'read'),
('get.multi_acks', 'Routing', 'read'),
('get.name', 'Identity', 'read'),
('get.owner_info', 'Identity', 'read'),
('get.path_hash_mode', 'Routing', 'read'),
('get.public_key', 'Identity', 'read'),
('get.radio', 'Radio', 'read'),
('get.radio_fem_rxgain', 'Radio', 'read'),
('get.radio_rxgain', 'Radio', 'read'),
('get.repeat', 'Routing', 'read'),
('get.role', 'Identity', 'read'),
('get.rxdelay', 'Routing', 'read'),
('get.tx', 'Radio', 'read'),
('get.txdelay', 'Routing', 'read'),
('gps.advert', 'GPS', 'write'),
('gps.advert.set', 'GPS', 'write'),
('gps.off', 'GPS', 'write'),
('gps.on', 'GPS', 'write'),
('gps.setloc', 'GPS', 'write'),
('gps.sync', 'GPS', 'action'),
('guest.password', 'Access', 'write'),
('log.erase', 'Diagnostics', 'delete'),
('log.start', 'Diagnostics', 'action'),
('log.stop', 'Diagnostics', 'action'),
('neighbor.remove', 'Neighbors', 'delete'),
('neighbors', 'Neighbors', 'read'),
('password', 'Access', 'write'),
('poweroff', 'Power', 'action'),
('powersaving.off', 'Power', 'write'),
('powersaving.on', 'Power', 'write'),
('powersaving.status', 'Power', 'read'),
('reboot', 'Power', 'action'),
('region', 'Region', 'read'),
('region.allowf', 'Region', 'write'),
('region.def', 'Region', 'write'),
('region.default_get', 'Region', 'read'),
('region.default_set', 'Region', 'write'),
('region.denyf', 'Region', 'write'),
('region.get', 'Region', 'read'),
('region.home_get', 'Region', 'read'),
('region.home_set', 'Region', 'write'),
('region.list', 'Region', 'read'),
('region.load', 'Region', 'write'),
('region.put_root', 'Region', 'write'),
('region.put_sub', 'Region', 'write'),
('region.remove', 'Region', 'delete'),
('region.save', 'Region', 'write'),
('sensor.get', 'Sensors', 'read'),
('sensor.list', 'Sensors', 'read'),
('sensor.set', 'Sensors', 'write'),
('set.adc_multiplier', 'Power', 'write'),
('set.advert_interval', 'Advertising', 'write'),
('set.agc_reset_interval', 'Routing', 'write'),
('set.airtime_factor', 'Routing', 'write'),
('set.allow_read_only', 'Access', 'write'),
('set.cad', 'Routing', 'write'),
('set.direct_txdelay', 'Routing', 'write'),
('set.dutycycle', 'Routing', 'write'),
('set.flood_advert_interval', 'Advertising', 'write'),
('set.flood_max', 'Routing', 'write'),
('set.flood_max_advert', 'Routing', 'write'),
('set.flood_max_unscoped', 'Routing', 'write'),
('set.int_thresh', 'Routing', 'write'),
('set.lat', 'Location', 'write'),
('set.lon', 'Location', 'write'),
('set.loop_detect', 'Routing', 'write'),
('set.multi_acks', 'Routing', 'write'),
('set.name', 'Identity', 'write'),
('set.owner_info', 'Identity', 'write'),
('set.path_hash_mode', 'Routing', 'write'),
('set.public_key', 'Identity', 'write'),
('set.radio', 'Radio', 'write'),
('set.radio_fem_rxgain', 'Radio', 'write'),
('set.radio_rxgain', 'Radio', 'write'),
('set.repeat', 'Routing', 'write'),
('set.role', 'Identity', 'write'),
('set.rxdelay', 'Routing', 'write'),
('set.tx', 'Radio', 'write'),
('set.txdelay', 'Routing', 'write'),
('setperm.remove', 'Access', 'delete'),
('setperm.set', 'Access', 'write'),
('shutdown', 'Power', 'action'),
('start.ota', 'Firmware', 'action'),
('tempradio', 'Radio', 'write'),
('time', 'Clock', 'write'),
('ver', 'Diagnostics', 'read')
) AS v(key, feature, op)
WHERE c.key = v.key;
-- Drop the defaults so a new command MUST specify feature + operation, and guard
-- the values.
ALTER TABLE command_catalog ALTER COLUMN feature DROP DEFAULT;
ALTER TABLE command_catalog ALTER COLUMN operation DROP DEFAULT;
ALTER TABLE command_catalog ADD CONSTRAINT command_feature_nonempty CHECK (feature <> '');
ALTER TABLE command_catalog ADD CONSTRAINT command_operation_valid CHECK (operation IN ('read','write','delete','action'));
-- +goose Down
ALTER TABLE command_catalog DROP CONSTRAINT command_operation_valid;
ALTER TABLE command_catalog DROP CONSTRAINT command_feature_nonempty;
ALTER TABLE command_catalog DROP COLUMN operation;
ALTER TABLE command_catalog DROP COLUMN feature;
+9
View File
@@ -75,6 +75,15 @@ code.pubkey {
.badge.bg-purple-lt { color: #d98fe6 !important; }
.badge.bg-secondary-lt { color: var(--tblr-secondary-text-emphasis) !important; }
/* ---------- Command catalog flag table (admin) ----------
Fixed layout so the flag columns line up across the per-feature cards (each is
its own table), plus a distinct checked color per flag for fast scanning. */
.catalog-table { table-layout: fixed; }
.form-check-input.cc-risky:checked { background-color: var(--tblr-red); border-color: var(--tblr-red); }
.form-check-input.cc-share:checked { background-color: var(--tblr-green); border-color: var(--tblr-green); }
.form-check-input.cc-member:checked { background-color: var(--tblr-blue); border-color: var(--tblr-blue); }
.form-check-input.cc-admin:checked { background-color: var(--tblr-orange); border-color: var(--tblr-orange); }
/* ---------- Members role filter (org page) ----------
The All/Members/Admins button group filters the members list purely in CSS:
the checked radio in the card header drives which rows show via :has(). Rows
+31
View File
@@ -187,6 +187,37 @@
</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 -}}