clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

review access requests on the People page

Dami committed 6d1dafb
commit
6d1dafb4feb3215a1661dee89c8ac814e703fb9e
parent
2151df9512eed751536e29eed3a8c8cfb6ed73ee
author
Dami <dami.etoile@gmail.com>
6 files changed, +191 -3
Minternal/webui/manage.html8++-
Minternal/webui/page.html3+-
Ainternal/webui/people_test.go122++++++++++++++++++++++++++++++
Minternal/webui/style.css9++
Minternal/webui/templates.go2+
Minternal/webui/webui.go50++++++++++++-
M internal/webui/manage.html +7 -1
@@ -11,7 +11,13 @@
1111 </rpc-form>1212 <rpc-form method="removemember"><label>Pubkey <input name="param" required></label><button>Remove member</button></rpc-form>1313 <rpc-form method="removesubtree"><label>Member pubkey <input name="param" required></label><button>Remove invite subtree</button></rpc-form>14-<h3>Invites</h3>14+{{if .Review}}<h3>Access requests</h3>15+<p><small>People who asked to join without an invite. Approving adds them as members.</small></p>16+{{with pendingRequests .Requests}}<table id="requests"><thead><tr><th>person</th><th>reason</th><th>asked</th><th></th></tr></thead><tbody>17+{{range .}}{{$r := asMap .}}{{$pk := str (index $r "pubkey")}}<tr><td>{{template "nostrName" $pk}}</td><td>{{with str (index $r "reason")}}{{.}}{{else}}<small>no reason given</small>{{end}}</td><td><time datetime="{{datetime (index $r "requested_at")}}">{{short (index $r "requested_at")}}</time></td><td><rpc-form method="approvejoin" refresh><input type="hidden" name="param" value="&quot;{{$pk}}&quot;"><button>Approve</button></rpc-form><rpc-form method="denyjoin" refresh><input type="hidden" name="param" value="&quot;{{$pk}}&quot;"><button>Deny</button></rpc-form></td></tr>18+{{end}}</tbody></table>{{else}}<p>No pending requests.</p>{{end}}19+{{with decidedRequests .Requests}}<ul id="decided">{{range .}}{{$r := asMap .}}{{$status := str (index $r "status")}}<li data-state="{{$status}}">{{template "nostrName" (str (index $r "pubkey"))}} <small>{{$status}}{{with index $r "decided_at"}}{{if short .}} <time datetime="{{datetime .}}">{{short .}}</time>{{end}}{{end}}{{with str (index $r "decided_by")}} by {{template "nostrName" .}}{{end}}</small></li>{{end}}</ul>{{end}}20+{{end}}<h3>Invites</h3>1521 <rpc-form method="createinvite">1622 <label>TTL seconds <input name="param" type="number" value="259200"></label>1723 <label>Maximum uses <input name="param" type="number" value="1"></label>
M internal/webui/page.html +2 -1
@@ -139,7 +139,8 @@
139139 {{with index $r "refs"}}<section><h4>Refs</h4><form method="get" action="/repo"><input type="hidden" name="owner" value="{{$.Query.Get "owner"}}"><input type="hidden" name="repo" value="{{$.Query.Get "repo"}}"><input type="hidden" name="path" value="{{$.Query.Get "path"}}"><input type="hidden" name="id" value="{{$.Query.Get "id"}}"><input type="hidden" name="view" value="{{repoView $.Query}}"><label for="ref-select">branch or tag</label><select id="ref-select" name="ref"><option value="">default branch</option>{{range $name, $oid := .}}<option value="{{$name}}"{{if eq ($.Query.Get "ref") $name}} selected{{end}}>{{$name}}</option>{{end}}</select><button>go</button></form></section>{{end}}{{end}}140140 141141 {{define "panel-manage"}}<section><h4>Policy</h4><table><tr><th>name</th><td>{{.Policy.Name}}</td></tr><tr><th>reads</th><td>{{.Policy.Reads}}</td></tr><tr><th>writes</th><td>{{.Policy.Writes}}</td></tr><tr><th>owner</th><td>{{template "nostrKey" .Policy.Owner}}</td></tr></table></section>142-<section><h4>Session</h4><table><tr><th>you</th><td>{{if .Actor}}{{template "nostrKey" .Actor}}{{else}}guest{{end}}</td></tr><tr><th>role</th><td>{{if .Owner}}owner{{else if .Actor}}member{{else}}none{{end}}</td></tr></table></section>142+{{if .Review}}<section><h4>People</h4><table><tr><th>requests</th><td>{{len (pendingRequests .Requests)}} pending</td></tr></table></section>143+{{end}}<section><h4>Session</h4><table><tr><th>you</th><td>{{if .Actor}}{{template "nostrKey" .Actor}}{{else}}guest{{end}}</td></tr><tr><th>role</th><td>{{if .Owner}}owner{{else if .Actor}}member{{else}}none{{end}}</td></tr></table></section>143144 <section><h4>Result</h4><p><small>Each form shows its result below the form. Every call is signed by your connected signer.</small></p></section>{{end}}144145 145146 {{define "panel-agents"}}{{$counts := agentCounts .Feed}}{{$callbacks := callbackCounts .Callbacks}}<section><h4>Agents</h4><table><tr><th>active</th><td>{{index $counts "active"}}</td></tr><tr><th>paused</th><td>{{index $counts "paused"}}</td></tr><tr><th>revoked</th><td>{{index $counts "revoked"}}</td></tr><tr><th>callbacks</th><td>{{index $callbacks "active"}} active, {{index $callbacks "paused"}} paused</td></tr></table></section>
A internal/webui/people_test.go +122 -0
@@ -0,0 +1,122 @@
1+package webui2+3+import (4+ "context"5+ "encoding/json"6+ "net/http"7+ "net/http/httptest"8+ "strings"9+ "testing"10+ "time"11+12+ "github.com/FelineStateMachine/tinyrelay/internal/policy"13+)14+15+// peopleBackend answers listjoinrequests with the rows it holds, pending16+// first as the relay lists them, and refuses callers other than the owner.17+type peopleBackend struct {18+ fakeBackend19+ requests []any20+ calls []string21+}22+23+var (24+ askerPending = strings.Repeat("1", 64)25+ askerQuiet = strings.Repeat("2", 64)26+ askerDenied = strings.Repeat("3", 64)27+)28+29+func (b *peopleBackend) Query(_ context.Context, method string, params []json.RawMessage, actor string) (any, error) {30+ b.calls = append(b.calls, method+":"+actor)31+ if method == "listjoinrequests" {32+ if actor != b.policy.Owner {33+ return nil, context.Canceled34+ }35+ return b.requests, nil36+ }37+ return map[string]any{"method": method}, nil38+}39+40+func TestPeoplePageListsAccessRequestsWithDecisions(t *testing.T) {41+ now := time.Now().Unix()42+ owner := strings.Repeat("a", 64)43+ backend := &peopleBackend{fakeBackend: fakeBackend{policy: policy.Defaults(owner)}, requests: []any{44+ map[string]any{"pubkey": askerPending, "reason": "Met you at the meetup, would like to follow the build.", "requested_at": now - 600, "status": "pending", "decided_by": "", "decided_at": 0},45+ map[string]any{"pubkey": askerQuiet, "reason": "", "requested_at": now - 60, "status": "pending", "decided_by": "", "decided_at": 0},46+ map[string]any{"pubkey": askerDenied, "reason": "spam", "requested_at": now - 7200, "status": "denied", "decided_by": owner, "decided_at": now - 3600},47+ }}48+ app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return owner, nil }})49+ if err != nil {50+ t.Fatal(err)51+ }52+ recorder := httptest.NewRecorder()53+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/people", nil))54+ body := recorder.Body.String()55+ if recorder.Code != http.StatusOK {56+ t.Fatalf("status=%d body=%s", recorder.Code, body)57+ }58+ if strings.Contains(body, "class=") {59+ t.Fatal("people page carries a class attribute")60+ }61+ if strings.Contains(body, "\u00b7") {62+ t.Fatal("people page contains a middle dot")63+ }64+ if backend.calls[0] != "listjoinrequests:"+owner {65+ t.Fatalf("requests were not loaded server-side: %v", backend.calls)66+ }67+ for _, want := range []string{68+ `<h3>Access requests</h3>`,69+ `<table id="requests"><thead><tr><th>person</th><th>reason</th><th>asked</th><th></th></tr></thead>`,70+ `<tr><td><nostr-name pubkey="` + askerPending + `" title="` + askerPending + `">` + shortID(askerPending) + `</nostr-name></td><td>Met you at the meetup, would like to follow the build.</td><td><time datetime="`,71+ `<rpc-form method="approvejoin" refresh><input type="hidden" name="param" value="&quot;` + askerPending + `&quot;"><button>Approve</button></rpc-form><rpc-form method="denyjoin" refresh><input type="hidden" name="param" value="&quot;` + askerPending + `&quot;"><button>Deny</button></rpc-form></td></tr>`,72+ `<td><small>no reason given</small></td>`,73+ `<rpc-form method="approvejoin" refresh><input type="hidden" name="param" value="&quot;` + askerQuiet + `&quot;"><button>Approve</button></rpc-form>`,74+ `<ul id="decided"><li data-state="denied"><nostr-name pubkey="` + askerDenied + `"`,75+ `<small>denied <time datetime="`,76+ ` by <nostr-name pubkey="` + owner + `"`,77+ `<h4>People</h4><table><tr><th>requests</th><td>2 pending</td></tr></table>`,78+ `<h3>Invites</h3>`,79+ } {80+ if !strings.Contains(body, want) {81+ t.Errorf("people page missing %q", want)82+ }83+ }84+ if strings.Index(body, `<h3>Access requests</h3>`) > strings.Index(body, `<h3>Invites</h3>`) {85+ t.Fatal("access requests are not above invites")86+ }87+ if strings.Contains(body, `value="&quot;`+askerDenied+`&quot;"`) {88+ t.Fatal("a decided request offers buttons")89+ }90+ if strings.Contains(body, "No pending requests.") {91+ t.Fatal("empty state shown with pending requests")92+ }93+94+ // With nothing pending the section says so and the panel counts zero.95+ backend.requests = []any{backend.requests[2]}96+ recorder = httptest.NewRecorder()97+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/people", nil))98+ body = recorder.Body.String()99+ for _, want := range []string{`<h3>Access requests</h3>`, `<p>No pending requests.</p>`, `<ul id="decided">`, `<td>0 pending</td>`} {100+ if !strings.Contains(body, want) {101+ t.Errorf("empty people page missing %q", want)102+ }103+ }104+ if strings.Contains(body, `<table id="requests">`) {105+ t.Fatal("empty people page renders the requests table")106+ }107+108+ // A member who may not review sees neither the section nor the count.109+ memberApp, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return strings.Repeat("c", 64), nil }})110+ if err != nil {111+ t.Fatal(err)112+ }113+ recorder = httptest.NewRecorder()114+ memberApp.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/people", nil))115+ body = recorder.Body.String()116+ if strings.Contains(body, "Access requests") || strings.Contains(body, "pending</td>") {117+ t.Fatal("a member sees the access requests section")118+ }119+ if !strings.Contains(body, `<h3>Invites</h3>`) {120+ t.Fatal("a member lost the rest of the page")121+ }122+}
M internal/webui/style.css +9 -0
@@ -141,6 +141,15 @@ agent-card th{text-align:left;font-weight:normal;color:var(--muted);width:9em;pa
141141 agent-card td code{color:var(--muted)}142142 agent-card p{margin:8px 0 0}agent-card rpc-form{display:inline-block;border:0;padding:0;margin:0 8px 0 0;vertical-align:top}agent-card rpc-form output:empty{display:none}143143 agent-card p>a{display:inline-block;font:12px var(--mono);line-height:29px;vertical-align:top}144+/* #requests: access requests waiting for a decision, with their controls. #decided lists the last answered ones. */145+#requests{border-collapse:collapse;width:100%;max-width:720px;font:13px var(--mono)}146+#requests th{text-align:left;font:12px var(--mono);color:var(--muted);font-weight:normal;padding:0 6px;height:28px;border-bottom:1px solid var(--line)}147+#requests td{padding:8px 6px;border-bottom:1px solid var(--line);vertical-align:top;overflow-wrap:anywhere;line-height:1.35}148+#requests td:nth-child(2){font-family:var(--sans)}#requests td:nth-child(3){white-space:nowrap;color:var(--muted);font-size:12px}#requests td:last-child{white-space:nowrap;width:150px}149+#requests rpc-form{display:inline-block;border:0;padding:0;margin:0 6px 0 0;vertical-align:middle}#requests rpc-form output:empty{display:none}#requests button{font-size:12px;padding:1px 6px}150+#decided{list-style:none;margin:8px 0 0;padding:0;max-width:720px}#decided li{padding:3px 0;font:13px var(--mono)}#decided li small{color:var(--muted)}151+#decided li[data-state=approved] small{color:var(--accent)}#decided li[data-state=denied] small{color:var(--alert)}152+144153 /* agent-callback: one registered callback on an agent card, with its controls. */145154 agent-callback{display:block;margin:0 0 4px}agent-callback code{color:inherit}146155 agent-callback rpc-form{display:inline-block;border:0;padding:0;margin:0 0 0 6px;vertical-align:middle}agent-callback rpc-form output:empty{display:none}agent-callback button{font-size:12px;padding:1px 6px}
M internal/webui/templates.go +2 -0
M internal/webui/webui.go +49 -1
@@ -94,6 +94,10 @@ type PageData struct {
9494 Rooms []any9595 // Callbacks lists the event callbacks shown beside each agent.9696 Callbacks []any97+ // Requests lists the access requests on the People page, pending first;98+ // Review is true when the viewer may decide them.99+ Requests []any100+ Review bool97101 }98102 99103 func New(backend Backend, options Options) (*App, error) {
@@ -918,6 +922,9 @@ func (a *App) page(writer http.ResponseWriter, request *http.Request) {
918922 if tab == "agents" && actor != "" {919923 a.agentsPage(request, actor, &data)920924 }925+ if tab == "people" && actor != "" {926+ a.peoplePage(request, actor, &data)927+ }921928 if tab != "" {922929 data.Title = tab + " | " + a.backend.Slug()923930 }
@@ -963,6 +970,47 @@ func (a *App) agentsPage(request *http.Request, actor string, data *PageData) {
963970 data.Event = detail964971 }965972 973+// peoplePage loads the access requests for the owner and moderators. A974+// member's request is refused by the backend, and the section stays out.975+func (a *App) peoplePage(request *http.Request, actor string, data *PageData) {976+ result, err := a.backend.Query(request.Context(), "listjoinrequests", nil, actor)977+ if err != nil {978+ return979+ }980+ data.Review = true981+ for _, row := range browseRows(result) {982+ if plainString(valueMap(row)["pubkey"]) != "" {983+ data.Requests = append(data.Requests, row)984+ }985+ }986+}987+988+// pendingRequests keeps the access requests that wait for a decision.989+func pendingRequests(rows []any) []any {990+ var pending []any991+ for _, row := range rows {992+ if plainString(valueMap(row)["status"]) == "pending" {993+ pending = append(pending, row)994+ }995+ }996+ return pending997+}998+999+// decidedRequests keeps the last decided access requests, newest first as1000+// the backend lists them.1001+func decidedRequests(rows []any) []any {1002+ var decided []any1003+ for _, row := range rows {1004+ if status := plainString(valueMap(row)["status"]); status != "" && status != "pending" {1005+ decided = append(decided, row)1006+ }1007+ if len(decided) == 10 {1008+ break1009+ }1010+ }1011+ return decided1012+}1013+9661014 // agentState folds a grant's marks into one word for data-state: revoked9671015 // covers expired grants too, since neither lets the agent publish again9681016 // without a new grant.
@@ -1157,7 +1205,7 @@ func dateOf(value any) string {
11571205 }11581206 11591207 var supportedMethods = []string{1160- "supportedmethods", "stats", "getpolicy", "setpolicy", "listaudit", "listviews", "listmembers", "listpeople", "setmember", "allowpubkey", "unrulepubkey", "removemember", "createinvite", "listinvites", "revokeinvite", "listclaims", "createclaim", "deleteclaim", "removesubtree", "listbannedpubkeys", "listallowedpubkeys", "banpubkey", "listreports", "resolvereport", "banevent", "allowevent", "listeventsneedingmoderation", "blockip", "unblockip", "listblockedips", "exportconfig", "importconfig", "planconfig", "listblobs", "deleteblob", "listbannedevents", "deleteevent", "listrecentevents", "searchevents", "pinevent", "unpinevent", "listpins", "allowkind", "disallowkind", "unrulekind", "storagestats", "gitstorage", "setretention", "listretention", "purgekind", "listallowedkinds", "listblockedkinds", "notifytest", "resetrules", "listpresets", "listconnectiontemplates", "listconnections", "setconnections", "applypreset", "forkrelay", "pullfrom", "pullstatus", "listjobs", "deliverystatus", "addjob", "removejob", "runjob", "backfill", "transferowner", "listdumps", "deletedump", "dumpnow", "backupnow", "listbackups", "deletebackup", "changerelayname", "changerelaydescription", "changerelayicon", "adddomain", "setdomainsite", "checkdomain", "removedomain", "listdomains", "listcallbacks", "addcallback", "removecallback", "pausecallback", "resumecallback",1208+ "supportedmethods", "stats", "getpolicy", "setpolicy", "listaudit", "listviews", "listmembers", "listpeople", "listjoinrequests", "approvejoin", "denyjoin", "setmember", "allowpubkey", "unrulepubkey", "removemember", "createinvite", "listinvites", "revokeinvite", "listclaims", "createclaim", "deleteclaim", "removesubtree", "listbannedpubkeys", "listallowedpubkeys", "banpubkey", "listreports", "resolvereport", "banevent", "allowevent", "listeventsneedingmoderation", "blockip", "unblockip", "listblockedips", "exportconfig", "importconfig", "planconfig", "listblobs", "deleteblob", "listbannedevents", "deleteevent", "listrecentevents", "searchevents", "pinevent", "unpinevent", "listpins", "allowkind", "disallowkind", "unrulekind", "storagestats", "gitstorage", "setretention", "listretention", "purgekind", "listallowedkinds", "listblockedkinds", "notifytest", "resetrules", "listpresets", "listconnectiontemplates", "listconnections", "setconnections", "applypreset", "forkrelay", "pullfrom", "pullstatus", "listjobs", "deliverystatus", "addjob", "removejob", "runjob", "backfill", "transferowner", "listdumps", "deletedump", "dumpnow", "backupnow", "listbackups", "deletebackup", "changerelayname", "changerelaydescription", "changerelayicon", "adddomain", "setdomainsite", "checkdomain", "removedomain", "listdomains", "listcallbacks", "addcallback", "removecallback", "pausecallback", "resumecallback",11611209 }11621210 11631211 func (a *App) card(writer http.ResponseWriter) {