| 1 | diff --git a/internal/webui/manage.html b/internal/webui/manage.html |
| 2 | index 1f46e1a..b95ab75 100644 |
| 3 | --- a/internal/webui/manage.html |
| 4 | +++ b/internal/webui/manage.html |
| 5 | @@ -11,7 +11,13 @@ |
| 6 | </rpc-form> |
| 7 | <rpc-form method="removemember"><label>Pubkey <input name="param" required></label><button>Remove member</button></rpc-form> |
| 8 | <rpc-form method="removesubtree"><label>Member pubkey <input name="param" required></label><button>Remove invite subtree</button></rpc-form> |
| 9 | -<h3>Invites</h3> |
| 10 | +{{if .Review}}<h3>Access requests</h3> |
| 11 | +<p><small>People who asked to join without an invite. Approving adds them as members.</small></p> |
| 12 | +{{with pendingRequests .Requests}}<table id="requests"><thead><tr><th>person</th><th>reason</th><th>asked</th><th></th></tr></thead><tbody> |
| 13 | +{{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> |
| 14 | +{{end}}</tbody></table>{{else}}<p>No pending requests.</p>{{end}} |
| 15 | +{{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}} |
| 16 | +{{end}}<h3>Invites</h3> |
| 17 | <rpc-form method="createinvite"> |
| 18 | <label>TTL seconds <input name="param" type="number" value="259200"></label> |
| 19 | <label>Maximum uses <input name="param" type="number" value="1"></label> |
| 20 | diff --git a/internal/webui/page.html b/internal/webui/page.html |
| 21 | index 61d4133..5875006 100644 |
| 22 | --- a/internal/webui/page.html |
| 23 | +++ b/internal/webui/page.html |
| 24 | @@ -139,7 +139,8 @@ |
| 25 | {{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}} |
| 26 | |
| 27 | {{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> |
| 28 | -<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> |
| 29 | +{{if .Review}}<section><h4>People</h4><table><tr><th>requests</th><td>{{len (pendingRequests .Requests)}} pending</td></tr></table></section> |
| 30 | +{{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> |
| 31 | <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}} |
| 32 | |
| 33 | {{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> |
| 34 | diff --git a/internal/webui/people_test.go b/internal/webui/people_test.go |
| 35 | new file mode 100644 |
| 36 | index 0000000..035f005 |
| 37 | --- /dev/null |
| 38 | +++ b/internal/webui/people_test.go |
| 39 | @@ -0,0 +1,122 @@ |
| 40 | +package webui |
| 41 | + |
| 42 | +import ( |
| 43 | + "context" |
| 44 | + "encoding/json" |
| 45 | + "net/http" |
| 46 | + "net/http/httptest" |
| 47 | + "strings" |
| 48 | + "testing" |
| 49 | + "time" |
| 50 | + |
| 51 | + "github.com/FelineStateMachine/tinyrelay/internal/policy" |
| 52 | +) |
| 53 | + |
| 54 | +// peopleBackend answers listjoinrequests with the rows it holds, pending |
| 55 | +// first as the relay lists them, and refuses callers other than the owner. |
| 56 | +type peopleBackend struct { |
| 57 | + fakeBackend |
| 58 | + requests []any |
| 59 | + calls []string |
| 60 | +} |
| 61 | + |
| 62 | +var ( |
| 63 | + askerPending = strings.Repeat("1", 64) |
| 64 | + askerQuiet = strings.Repeat("2", 64) |
| 65 | + askerDenied = strings.Repeat("3", 64) |
| 66 | +) |
| 67 | + |
| 68 | +func (b *peopleBackend) Query(_ context.Context, method string, params []json.RawMessage, actor string) (any, error) { |
| 69 | + b.calls = append(b.calls, method+":"+actor) |
| 70 | + if method == "listjoinrequests" { |
| 71 | + if actor != b.policy.Owner { |
| 72 | + return nil, context.Canceled |
| 73 | + } |
| 74 | + return b.requests, nil |
| 75 | + } |
| 76 | + return map[string]any{"method": method}, nil |
| 77 | +} |
| 78 | + |
| 79 | +func TestPeoplePageListsAccessRequestsWithDecisions(t *testing.T) { |
| 80 | + now := time.Now().Unix() |
| 81 | + owner := strings.Repeat("a", 64) |
| 82 | + backend := &peopleBackend{fakeBackend: fakeBackend{policy: policy.Defaults(owner)}, requests: []any{ |
| 83 | + 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}, |
| 84 | + map[string]any{"pubkey": askerQuiet, "reason": "", "requested_at": now - 60, "status": "pending", "decided_by": "", "decided_at": 0}, |
| 85 | + map[string]any{"pubkey": askerDenied, "reason": "spam", "requested_at": now - 7200, "status": "denied", "decided_by": owner, "decided_at": now - 3600}, |
| 86 | + }} |
| 87 | + app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return owner, nil }}) |
| 88 | + if err != nil { |
| 89 | + t.Fatal(err) |
| 90 | + } |
| 91 | + recorder := httptest.NewRecorder() |
| 92 | + app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/people", nil)) |
| 93 | + body := recorder.Body.String() |
| 94 | + if recorder.Code != http.StatusOK { |
| 95 | + t.Fatalf("status=%d body=%s", recorder.Code, body) |
| 96 | + } |
| 97 | + if strings.Contains(body, "class=") { |
| 98 | + t.Fatal("people page carries a class attribute") |
| 99 | + } |
| 100 | + if strings.Contains(body, "\u00b7") { |
| 101 | + t.Fatal("people page contains a middle dot") |
| 102 | + } |
| 103 | + if backend.calls[0] != "listjoinrequests:"+owner { |
| 104 | + t.Fatalf("requests were not loaded server-side: %v", backend.calls) |
| 105 | + } |
| 106 | + for _, want := range []string{ |
| 107 | + `<h3>Access requests</h3>`, |
| 108 | + `<table id="requests"><thead><tr><th>person</th><th>reason</th><th>asked</th><th></th></tr></thead>`, |
| 109 | + `<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="`, |
| 110 | + `<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>`, |
| 111 | + `<td><small>no reason given</small></td>`, |
| 112 | + `<rpc-form method="approvejoin" refresh><input type="hidden" name="param" value=""` + askerQuiet + `""><button>Approve</button></rpc-form>`, |
| 113 | + `<ul id="decided"><li data-state="denied"><nostr-name pubkey="` + askerDenied + `"`, |
| 114 | + `<small>denied <time datetime="`, |
| 115 | + ` by <nostr-name pubkey="` + owner + `"`, |
| 116 | + `<h4>People</h4><table><tr><th>requests</th><td>2 pending</td></tr></table>`, |
| 117 | + `<h3>Invites</h3>`, |
| 118 | + } { |
| 119 | + if !strings.Contains(body, want) { |
| 120 | + t.Errorf("people page missing %q", want) |
| 121 | + } |
| 122 | + } |
| 123 | + if strings.Index(body, `<h3>Access requests</h3>`) > strings.Index(body, `<h3>Invites</h3>`) { |
| 124 | + t.Fatal("access requests are not above invites") |
| 125 | + } |
| 126 | + if strings.Contains(body, `value=""`+askerDenied+`""`) { |
| 127 | + t.Fatal("a decided request offers buttons") |
| 128 | + } |
| 129 | + if strings.Contains(body, "No pending requests.") { |
| 130 | + t.Fatal("empty state shown with pending requests") |
| 131 | + } |
| 132 | + |
| 133 | + // With nothing pending the section says so and the panel counts zero. |
| 134 | + backend.requests = []any{backend.requests[2]} |
| 135 | + recorder = httptest.NewRecorder() |
| 136 | + app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/people", nil)) |
| 137 | + body = recorder.Body.String() |
| 138 | + for _, want := range []string{`<h3>Access requests</h3>`, `<p>No pending requests.</p>`, `<ul id="decided">`, `<td>0 pending</td>`} { |
| 139 | + if !strings.Contains(body, want) { |
| 140 | + t.Errorf("empty people page missing %q", want) |
| 141 | + } |
| 142 | + } |
| 143 | + if strings.Contains(body, `<table id="requests">`) { |
| 144 | + t.Fatal("empty people page renders the requests table") |
| 145 | + } |
| 146 | + |
| 147 | + // A member who may not review sees neither the section nor the count. |
| 148 | + memberApp, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return strings.Repeat("c", 64), nil }}) |
| 149 | + if err != nil { |
| 150 | + t.Fatal(err) |
| 151 | + } |
| 152 | + recorder = httptest.NewRecorder() |
| 153 | + memberApp.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/people", nil)) |
| 154 | + body = recorder.Body.String() |
| 155 | + if strings.Contains(body, "Access requests") || strings.Contains(body, "pending</td>") { |
| 156 | + t.Fatal("a member sees the access requests section") |
| 157 | + } |
| 158 | + if !strings.Contains(body, `<h3>Invites</h3>`) { |
| 159 | + t.Fatal("a member lost the rest of the page") |
| 160 | + } |
| 161 | +} |
| 162 | diff --git a/internal/webui/style.css b/internal/webui/style.css |
| 163 | index 5d0c630..7e0a15a 100644 |
| 164 | --- a/internal/webui/style.css |
| 165 | +++ b/internal/webui/style.css |
| 166 | @@ -141,6 +141,15 @@ agent-card th{text-align:left;font-weight:normal;color:var(--muted);width:9em;pa |
| 167 | agent-card td code{color:var(--muted)} |
| 168 | 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} |
| 169 | agent-card p>a{display:inline-block;font:12px var(--mono);line-height:29px;vertical-align:top} |
| 170 | +/* #requests: access requests waiting for a decision, with their controls. #decided lists the last answered ones. */ |
| 171 | +#requests{border-collapse:collapse;width:100%;max-width:720px;font:13px var(--mono)} |
| 172 | +#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)} |
| 173 | +#requests td{padding:8px 6px;border-bottom:1px solid var(--line);vertical-align:top;overflow-wrap:anywhere;line-height:1.35} |
| 174 | +#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} |
| 175 | +#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} |
| 176 | +#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)} |
| 177 | +#decided li[data-state=approved] small{color:var(--accent)}#decided li[data-state=denied] small{color:var(--alert)} |
| 178 | + |
| 179 | /* agent-callback: one registered callback on an agent card, with its controls. */ |
| 180 | agent-callback{display:block;margin:0 0 4px}agent-callback code{color:inherit} |
| 181 | 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} |
| 182 | diff --git a/internal/webui/templates.go b/internal/webui/templates.go |
| 183 | index 12ef017..94bf1cc 100644 |
| 184 | --- a/internal/webui/templates.go |
| 185 | +++ b/internal/webui/templates.go |
| 186 | @@ -266,6 +266,8 @@ func parseTemplates() (*template.Template, error) { |
| 187 | "agentScope": agentScope, |
| 188 | "agentLabel": agentLabel, |
| 189 | "agentCounts": agentCounts, |
| 190 | + "pendingRequests": pendingRequests, |
| 191 | + "decidedRequests": decidedRequests, |
| 192 | "callbacksFor": callbacksFor, |
| 193 | "callbackState": callbackState, |
| 194 | "callbackKinds": callbackKinds, |
| 195 | diff --git a/internal/webui/webui.go b/internal/webui/webui.go |
| 196 | index 66678b4..1ed6241 100644 |
| 197 | --- a/internal/webui/webui.go |
| 198 | +++ b/internal/webui/webui.go |
| 199 | @@ -94,6 +94,10 @@ type PageData struct { |
| 200 | Rooms []any |
| 201 | // Callbacks lists the event callbacks shown beside each agent. |
| 202 | Callbacks []any |
| 203 | + // Requests lists the access requests on the People page, pending first; |
| 204 | + // Review is true when the viewer may decide them. |
| 205 | + Requests []any |
| 206 | + Review bool |
| 207 | } |
| 208 | |
| 209 | func New(backend Backend, options Options) (*App, error) { |
| 210 | @@ -918,6 +922,9 @@ func (a *App) page(writer http.ResponseWriter, request *http.Request) { |
| 211 | if tab == "agents" && actor != "" { |
| 212 | a.agentsPage(request, actor, &data) |
| 213 | } |
| 214 | + if tab == "people" && actor != "" { |
| 215 | + a.peoplePage(request, actor, &data) |
| 216 | + } |
| 217 | if tab != "" { |
| 218 | data.Title = tab + " | " + a.backend.Slug() |
| 219 | } |
| 220 | @@ -963,6 +970,47 @@ func (a *App) agentsPage(request *http.Request, actor string, data *PageData) { |
| 221 | data.Event = detail |
| 222 | } |
| 223 | |
| 224 | +// peoplePage loads the access requests for the owner and moderators. A |
| 225 | +// member's request is refused by the backend, and the section stays out. |
| 226 | +func (a *App) peoplePage(request *http.Request, actor string, data *PageData) { |
| 227 | + result, err := a.backend.Query(request.Context(), "listjoinrequests", nil, actor) |
| 228 | + if err != nil { |
| 229 | + return |
| 230 | + } |
| 231 | + data.Review = true |
| 232 | + for _, row := range browseRows(result) { |
| 233 | + if plainString(valueMap(row)["pubkey"]) != "" { |
| 234 | + data.Requests = append(data.Requests, row) |
| 235 | + } |
| 236 | + } |
| 237 | +} |
| 238 | + |
| 239 | +// pendingRequests keeps the access requests that wait for a decision. |
| 240 | +func pendingRequests(rows []any) []any { |
| 241 | + var pending []any |
| 242 | + for _, row := range rows { |
| 243 | + if plainString(valueMap(row)["status"]) == "pending" { |
| 244 | + pending = append(pending, row) |
| 245 | + } |
| 246 | + } |
| 247 | + return pending |
| 248 | +} |
| 249 | + |
| 250 | +// decidedRequests keeps the last decided access requests, newest first as |
| 251 | +// the backend lists them. |
| 252 | +func decidedRequests(rows []any) []any { |
| 253 | + var decided []any |
| 254 | + for _, row := range rows { |
| 255 | + if status := plainString(valueMap(row)["status"]); status != "" && status != "pending" { |
| 256 | + decided = append(decided, row) |
| 257 | + } |
| 258 | + if len(decided) == 10 { |
| 259 | + break |
| 260 | + } |
| 261 | + } |
| 262 | + return decided |
| 263 | +} |
| 264 | + |
| 265 | // agentState folds a grant's marks into one word for data-state: revoked |
| 266 | // covers expired grants too, since neither lets the agent publish again |
| 267 | // without a new grant. |
| 268 | @@ -1157,7 +1205,7 @@ func dateOf(value any) string { |
| 269 | } |
| 270 | |
| 271 | var supportedMethods = []string{ |
| 272 | - "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", |
| 273 | + "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", |
| 274 | } |
| 275 | |
| 276 | func (a *App) card(writer http.ResponseWriter) { |