mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-01 17:38:15 +00:00
Better command and consent display
This commit is contained in:
+21
-1
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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 -}}
|
||||
|
||||
Reference in New Issue
Block a user