tinyrelaycommit

Commit 6d1dafb4feb3

browse files at this commit | history

1diff --git a/internal/webui/manage.html b/internal/webui/manage.html
2index 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="&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>
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>
20diff --git a/internal/webui/page.html b/internal/webui/page.html
21index 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>
34diff --git a/internal/webui/people_test.go b/internal/webui/people_test.go
35new file mode 100644
36index 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="&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>`,
111+ `<td><small>no reason given</small></td>`,
112+ `<rpc-form method="approvejoin" refresh><input type="hidden" name="param" value="&quot;` + askerQuiet + `&quot;"><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="&quot;`+askerDenied+`&quot;"`) {
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+}
162diff --git a/internal/webui/style.css b/internal/webui/style.css
163index 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}
182diff --git a/internal/webui/templates.go b/internal/webui/templates.go
183index 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,
195diff --git a/internal/webui/webui.go b/internal/webui/webui.go
196index 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) {

About

ownerc01503e8035f
visibilitypublic
head6d1dafb4feb3

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs