From b6d1dff788508593ae423bc3d56a8e926fb2a5fc Mon Sep 17 00:00:00 2001 From: Jonathon Leight Date: Mon, 22 Jun 2026 09:41:26 -0400 Subject: [PATCH] Better command and consent display --- internal/core/admin.go | 22 ++- internal/core/command_features.go | 90 ++++++++++++ internal/core/console_resolve_test.go | 33 ++++- internal/core/contribute.go | 40 ++---- internal/core/templates/admin_catalog.html | 61 +++++--- internal/core/templates/consented.html | 45 +++--- internal/core/templates/contribute.html | 55 ++++--- internal/store/commands.go | 13 +- .../0019_command_feature_operation.sql | 136 ++++++++++++++++++ internal/web/static/app.css | 9 ++ internal/web/templates/base.html | 31 ++++ 11 files changed, 427 insertions(+), 108 deletions(-) create mode 100644 internal/core/command_features.go create mode 100644 internal/store/migrations/0019_command_feature_operation.sql diff --git a/internal/core/admin.go b/internal/core/admin.go index 9521c17..8fab038 100644 --- a/internal/core/admin.go +++ b/internal/core/admin.go @@ -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) { diff --git a/internal/core/command_features.go b/internal/core/command_features.go new file mode 100644 index 0000000..72dc820 --- /dev/null +++ b/internal/core/command_features.go @@ -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 +} diff --git a/internal/core/console_resolve_test.go b/internal/core/console_resolve_test.go index c60342d..f3f29d9 100644 --- a/internal/core/console_resolve_test.go +++ b/internal/core/console_resolve_test.go @@ -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 "" 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 { diff --git a/internal/core/contribute.go b/internal/core/contribute.go index ef8b381..df10dc0 100644 --- a/internal/core/contribute.go +++ b/internal/core/contribute.go @@ -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 { diff --git a/internal/core/templates/admin_catalog.html b/internal/core/templates/admin_catalog.html index c7fdb1d..2a4b1fe 100644 --- a/internal/core/templates/admin_catalog.html +++ b/internal/core/templates/admin_catalog.html @@ -10,30 +10,47 @@ {{define "content"}}
-

- 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: share = default for new - shares; member/admin = the org default sets (Phase 2). - Risky commands can take over or brick a node. +

+ The firmware commands MeshTender can send. A repeater owner can run anything; these flags seed what + others are offered — share = default for a new one-off share, member/admin + = the org default tiers. Risky commands can lock the owner out or brick a node. Hover a + command to see what it does.

- {{if .Saved}}
Saved
{{end}} - - {{range .Groups}} -
-
{{.Name}}
- {{range .Commands}} -
- {{.Template}} - - - - - -
- {{end}} -
- {{end}} + {{if .Saved}}
Saved
{{end}}
+ +{{range .Groups}} +
+

{{.Name}}

+
+ + + + + + + + + + + + + {{range .Commands}} + + + + + + + + + {{end}} + +
CommandRiskyShareMemberAdmin
{{.Template}}
+
+
+{{end}} + {{template "icon-arrow-left" "me-1"}}Admin {{end}} diff --git a/internal/core/templates/consented.html b/internal/core/templates/consented.html index fbb5a72..0b93464 100644 --- a/internal/core/templates/consented.html +++ b/internal/core/templates/consented.html @@ -10,36 +10,35 @@ {{define "content"}}
-

+

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.

- {{if .CurrentVersion}} -
+
{{.Org.Name}} has since published v{{.CurrentVersion}}. This repeater stays on v{{.Version}} until you review the changes and re-consent.
{{end}} - - {{if .Envelope}} - {{range .Envelope}} -
-
{{.Name}}
- {{range .Commands}} -
- {{.Template}}{{if .Risky}} risky{{end}} - {{if .AdminChecked}}admin{{end}} - {{if .MemberChecked}}member{{end}} -
- {{end}} -
- {{end}} - {{else}} -

This version grants no commands.

- {{end}} - - {{template "icon-arrow-left" "me-1"}}Back to sharing
+ +
+

Members can run

+
+ {{if .MemberFeatures}}{{template "feature-table" .MemberFeatures}} + {{else}}

Members aren't granted any commands.

{{end}} +
+
+ +
+

Admins can run

+
+ {{if .AdminFeatures}}{{template "feature-table" .AdminFeatures}} + {{else}}

Admins aren't granted any commands.

{{end}} +
+
+ +{{template "icon-arrow-left" "me-1"}}Back to sharing {{end}} diff --git a/internal/core/templates/contribute.html b/internal/core/templates/contribute.html index 6d3b0a5..3ef8721 100644 --- a/internal/core/templates/contribute.html +++ b/internal/core/templates/contribute.html @@ -10,15 +10,15 @@ {{define "content"}}
-

+

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.

{{if .Reconsent}} -
+

Changes since you consented (v{{.ConsentedVersion}} → v{{.Version}})

{{if .Added}}
Newly granted: {{range .Added}}{{.}} {{end}}
{{end}} {{if .Removed}}
No longer granted: {{range .Removed}}{{.}} {{end}}
{{end}} @@ -28,30 +28,29 @@
{{end}}
{{end}} - - {{if .Envelope}} - {{range .Envelope}} -
-
{{.Name}}
- {{range .Commands}} -
- {{.Template}}{{if .Risky}} risky{{end}} - {{if .AdminChecked}}admin{{end}} - {{if .MemberChecked}}member{{end}} -
- {{end}} -
- {{end}} - {{else}} -

This org's policy currently grants no commands.

- {{end}} - -
-
- - Cancel -
-
+ +
+

Members can run

+
+ {{if .MemberFeatures}}{{template "feature-table" .MemberFeatures}} + {{else}}

Members aren't granted any commands.

{{end}} +
+
+ +
+

Admins can run

+
+ {{if .AdminFeatures}}{{template "feature-table" .AdminFeatures}} + {{else}}

Admins aren't granted any commands.

{{end}} +
+
+ +
+
+ + Cancel +
+
{{end}} diff --git a/internal/store/commands.go b/internal/store/commands.go index 1e5aa82..3ff3b25 100644 --- a/internal/store/commands.go +++ b/internal/store/commands.go @@ -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 "). 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 diff --git a/internal/store/migrations/0019_command_feature_operation.sql b/internal/store/migrations/0019_command_feature_operation.sql new file mode 100644 index 0000000..7b5da80 --- /dev/null +++ b/internal/store/migrations/0019_command_feature_operation.sql @@ -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; diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 50c1556..7895169 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -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 diff --git a/internal/web/templates/base.html b/internal/web/templates/base.html index 6d8f1d6..6b9d2b0 100644 --- a/internal/web/templates/base.html +++ b/internal/web/templates/base.html @@ -187,6 +187,37 @@ {{end}} +{{/* feature-table renders a []featureRow (commands grouped by feature × operation) + for permission review/consent. Dot is the slice of rows. */}} +{{define "feature-table"}} +
+ + + + + + {{range .}} + + + {{template "feature-cell" .Read}} + {{template "feature-cell" .Write}} + {{template "feature-cell" .Delete}} + {{template "feature-cell" .Action}} + + {{end}} + +
FeatureReadWriteDeleteAction
{{.Feature}}
+
+{{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"}} + +{{- if . }}{{range .}}
{{.Template}}{{if .Risky}} risky{{end}}
{{end}}{{else}}{{end -}} + +{{end}} + {{/* repstatus renders confirmation provenance + access badges for a *store.Repeater. */}} {{define "repstatus"}} {{- if not .Confirmed -}}