- commit
- 6d1dafb4feb3215a1661dee89c8ac814e703fb9e
- parent
- 2151df9512eed751536e29eed3a8c8cfb6ed73ee
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 8 | ++- |
| M | internal/ | 3 | +- |
| A | internal/ | 122 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 9 | ++ |
| M | internal/ | 2 | + |
| M | internal/ | 50 | ++++++++++++- |
@@ -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=""{{$pk}}""><button>Approve</button></rpc-form><rpc-form method="denyjoin" refresh><input type="hidden" name="param" value=""{{$pk}}""><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>@@ -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>@@ -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=""` + askerPending + `""><button>Approve</button></rpc-form><rpc-form method="denyjoin" refresh><input type="hidden" name="param" value=""` + askerPending + `""><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=""` + askerQuiet + `""><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=""`+askerDenied+`""`) {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+}@@ -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}@@ -266,6 +266,8 @@ func parseTemplates() (*template.Template, error) {
266266 "agentScope": agentScope,267267 "agentLabel": agentLabel,268268 "agentCounts": agentCounts,269+ "pendingRequests": pendingRequests,270+ "decidedRequests": decidedRequests,269271 "callbacksFor": callbacksFor,270272 "callbackState": callbackState,271273 "callbackKinds": callbackKinds,@@ -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) {