| 1 | diff --git a/internal/webui/components.js b/internal/webui/components.js |
| 2 | index 44fe1b1..1eb60aa 100644 |
| 3 | --- a/internal/webui/components.js |
| 4 | +++ b/internal/webui/components.js |
| 5 | @@ -390,7 +390,29 @@ |
| 6 | } |
| 7 | } |
| 8 | |
| 9 | + // ConnectCard copies a command or address, filling {input:name} from the |
| 10 | + // card's inputs, so a clone command carries the repository name typed. |
| 11 | + class ConnectCard extends HTMLElement { |
| 12 | + connectedCallback() { |
| 13 | + if (this.bound) return; |
| 14 | + this.bound = true; |
| 15 | + this.addEventListener("click", async event => { |
| 16 | + const button = event.target.closest("button[data-copy]"); |
| 17 | + if (!button) return; |
| 18 | + const text = button.dataset.copy.replace(/\{input:([a-z]+)\}/g, (match, name) => this.querySelector(`input[name="${name}"]`)?.value.trim() || "<" + name + ">"); |
| 19 | + const output = this.querySelector("output"); |
| 20 | + try { |
| 21 | + await navigator.clipboard.writeText(text); |
| 22 | + if (output) output.textContent = "copied: " + text; |
| 23 | + } catch { |
| 24 | + if (output) output.textContent = text; |
| 25 | + } |
| 26 | + }); |
| 27 | + } |
| 28 | + } |
| 29 | + |
| 30 | customElements.define("rpc-form", RpcForm); |
| 31 | + customElements.define("connect-card", ConnectCard); |
| 32 | customElements.define("relay-lists", RelayLists); |
| 33 | customElements.define("signed-form", SignedForm); |
| 34 | customElements.define("publish-list", PublishList); |
| 35 | diff --git a/internal/webui/connections.go b/internal/webui/connections.go |
| 36 | new file mode 100644 |
| 37 | index 0000000..6437e35 |
| 38 | --- /dev/null |
| 39 | +++ b/internal/webui/connections.go |
| 40 | @@ -0,0 +1,159 @@ |
| 41 | +package webui |
| 42 | + |
| 43 | +import ( |
| 44 | + "encoding/binary" |
| 45 | + "encoding/hex" |
| 46 | + "net/url" |
| 47 | + "regexp" |
| 48 | + "strings" |
| 49 | +) |
| 50 | + |
| 51 | +// bech32 encodes data with the given human readable part. |
| 52 | +func bech32(hrp string, data []byte) string { |
| 53 | + fiveBits := convertBits(data) |
| 54 | + expanded := make([]byte, 0, len(hrp)*2+1) |
| 55 | + for _, c := range hrp { |
| 56 | + expanded = append(expanded, byte(c)>>5) |
| 57 | + } |
| 58 | + expanded = append(expanded, 0) |
| 59 | + for _, c := range hrp { |
| 60 | + expanded = append(expanded, byte(c)&31) |
| 61 | + } |
| 62 | + values := append(append(append([]byte{}, expanded...), fiveBits...), 0, 0, 0, 0, 0, 0) |
| 63 | + polymod := bech32Polymod(values) ^ 1 |
| 64 | + checksum := make([]byte, 6) |
| 65 | + for i := range checksum { |
| 66 | + checksum[i] = byte(polymod >> uint(5*(5-i)) & 31) |
| 67 | + } |
| 68 | + var out strings.Builder |
| 69 | + out.WriteString(hrp + "1") |
| 70 | + for _, value := range append(fiveBits, checksum...) { |
| 71 | + out.WriteByte(bech32Charset[value]) |
| 72 | + } |
| 73 | + return out.String() |
| 74 | +} |
| 75 | + |
| 76 | +func tlv(kind byte, value []byte) []byte { |
| 77 | + return append([]byte{kind, byte(len(value))}, value...) |
| 78 | +} |
| 79 | + |
| 80 | +// nprofile encodes a NIP-19 profile pointer with one relay hint. |
| 81 | +func nprofile(pubkeyHex, relay string) string { |
| 82 | + pubkey, err := hex.DecodeString(pubkeyHex) |
| 83 | + if err != nil || len(pubkey) != 32 { |
| 84 | + return "" |
| 85 | + } |
| 86 | + data := tlv(0, pubkey) |
| 87 | + if relay != "" { |
| 88 | + data = append(data, tlv(1, []byte(relay))...) |
| 89 | + } |
| 90 | + return bech32("nprofile", data) |
| 91 | +} |
| 92 | + |
| 93 | +// naddr encodes a NIP-19 address pointer for a replaceable event. |
| 94 | +func naddr(kind uint32, pubkeyHex, identifier, relay string) string { |
| 95 | + pubkey, err := hex.DecodeString(pubkeyHex) |
| 96 | + if err != nil || len(pubkey) != 32 { |
| 97 | + return "" |
| 98 | + } |
| 99 | + data := tlv(0, []byte(identifier)) |
| 100 | + if relay != "" { |
| 101 | + data = append(data, tlv(1, []byte(relay))...) |
| 102 | + } |
| 103 | + data = append(data, tlv(2, pubkey)...) |
| 104 | + kindBytes := make([]byte, 4) |
| 105 | + binary.BigEndian.PutUint32(kindBytes, kind) |
| 106 | + data = append(data, tlv(3, kindBytes)...) |
| 107 | + return bech32("naddr", data) |
| 108 | +} |
| 109 | + |
| 110 | +var placeholder = regexp.MustCompile(`\{([a-z]+):([a-z]+)(?:\|(enc))?\}`) |
| 111 | + |
| 112 | +// connectionValues resolves the placeholders connection templates use: |
| 113 | +// relay:url, relay:web, relay:host, relay:domain, relay:naddr, owner:npub, |
| 114 | +// owner:nprofile, user:npub and user:nprofile. The relay's group address is |
| 115 | +// its NIP-29 top-level group, published under the relay identity. |
| 116 | +func connectionValues(data PageData) map[string]string { |
| 117 | + values := map[string]string{} |
| 118 | + web := strings.TrimRight(data.URL, "/") |
| 119 | + values["relay:web"] = web |
| 120 | + values["relay:url"] = wsURL(web) |
| 121 | + if parsed, err := url.Parse(web); err == nil { |
| 122 | + values["relay:host"] = parsed.Host |
| 123 | + values["relay:domain"] = parsed.Hostname() |
| 124 | + } |
| 125 | + if data.Identity != "" { |
| 126 | + values["relay:naddr"] = naddr(39000, data.Identity, "_", values["relay:url"]) |
| 127 | + } |
| 128 | + if data.Policy.Owner != "" { |
| 129 | + values["owner:npub"] = identityNpub(data.Policy.Owner) |
| 130 | + values["owner:nprofile"] = nprofile(data.Policy.Owner, values["relay:url"]) |
| 131 | + } |
| 132 | + if data.Actor != "" { |
| 133 | + values["user:npub"] = identityNpub(data.Actor) |
| 134 | + values["user:nprofile"] = nprofile(data.Actor, values["relay:url"]) |
| 135 | + } |
| 136 | + return values |
| 137 | +} |
| 138 | + |
| 139 | +// fillPlaceholders substitutes known values, leaving input placeholders for |
| 140 | +// the page script. It reports false when a needed value is missing, such as |
| 141 | +// a user value for a guest, so the link can be left out. |
| 142 | +func fillPlaceholders(text string, values map[string]string) (string, bool) { |
| 143 | + complete := true |
| 144 | + out := placeholder.ReplaceAllStringFunc(text, func(match string) string { |
| 145 | + parts := placeholder.FindStringSubmatch(match) |
| 146 | + if parts[1] == "input" { |
| 147 | + return match |
| 148 | + } |
| 149 | + value, ok := values[parts[1]+":"+parts[2]] |
| 150 | + if !ok || value == "" { |
| 151 | + complete = false |
| 152 | + return "" |
| 153 | + } |
| 154 | + if parts[3] == "enc" { |
| 155 | + return url.QueryEscape(value) |
| 156 | + } |
| 157 | + return value |
| 158 | + }) |
| 159 | + return out, complete |
| 160 | +} |
| 161 | + |
| 162 | +// expandConnections turns the daemon's connection rows into ready-to-render |
| 163 | +// maps with placeholders resolved. Links that need a signed-in user are |
| 164 | +// dropped for guests; the card keeps a note that signing in reveals them. |
| 165 | +func expandConnections(rows []any, data PageData) []any { |
| 166 | + values := connectionValues(data) |
| 167 | + out := make([]any, 0, len(rows)) |
| 168 | + for _, row := range rows { |
| 169 | + item := valueMap(row) |
| 170 | + card := map[string]any{"name": plainString(item["template"]), "title": plainString(item["title"]), "about": plainString(item["about"]), "app": plainString(item["app"]), "where": plainString(item["where"]), "visibility": plainString(item["visibility"]), "inputs": item["inputs"]} |
| 171 | + if card["title"] == "" { |
| 172 | + card["title"] = card["name"] |
| 173 | + } |
| 174 | + if qr, ok := fillPlaceholders(plainString(item["qr"]), values); ok && qr != "" { |
| 175 | + card["qr"] = qr |
| 176 | + } |
| 177 | + links := []any{} |
| 178 | + hidden := 0 |
| 179 | + for _, link := range browseRows(item["links"]) { |
| 180 | + l := valueMap(link) |
| 181 | + field := func(name string) string { |
| 182 | + if value := plainString(l[name]); value != "" { |
| 183 | + return value |
| 184 | + } |
| 185 | + return plainString(l[strings.ToUpper(name[:1])+name[1:]]) |
| 186 | + } |
| 187 | + href, okHref := fillPlaceholders(field("href"), values) |
| 188 | + copyText, okCopy := fillPlaceholders(field("copy"), values) |
| 189 | + if !okHref || !okCopy { |
| 190 | + hidden++ |
| 191 | + continue |
| 192 | + } |
| 193 | + links = append(links, map[string]any{"label": field("label"), "href": href, "copy": copyText}) |
| 194 | + } |
| 195 | + card["links"], card["hidden"] = links, hidden |
| 196 | + out = append(out, card) |
| 197 | + } |
| 198 | + return out |
| 199 | +} |
| 200 | diff --git a/internal/webui/connections_test.go b/internal/webui/connections_test.go |
| 201 | new file mode 100644 |
| 202 | index 0000000..3b5fd2e |
| 203 | --- /dev/null |
| 204 | +++ b/internal/webui/connections_test.go |
| 205 | @@ -0,0 +1,63 @@ |
| 206 | +package webui |
| 207 | + |
| 208 | +import ( |
| 209 | + "strings" |
| 210 | + "testing" |
| 211 | + |
| 212 | + "github.com/FelineStateMachine/tinyrelay/internal/policy" |
| 213 | +) |
| 214 | + |
| 215 | +func TestNIP19PointersMatchReferenceEncodings(t *testing.T) { |
| 216 | + key := strings.Repeat("a", 64) |
| 217 | + if got := nprofile(key, "wss://relay.example"); got != "nprofile1qqs242424242424242424242424242424242424242424242424242spzdmhxue69uhhyetvv9ujuetcv9khqmr9nxcavj" { |
| 218 | + t.Fatalf("nprofile = %s", got) |
| 219 | + } |
| 220 | + if got := naddr(39000, key, "_", "wss://relay.example"); got != "naddr1qqq47qgnwaehxw309aex2mrp0yhx27rpd4cxcegzyz424242424242424242424242424242424242424242424242425qcyqqqfskquzv50g" { |
| 221 | + t.Fatalf("naddr = %s", got) |
| 222 | + } |
| 223 | + if got := bech32("npub", mustHex(key)); got != "npub1424242424242424242424242424242424242424242424242424qamrcaj" { |
| 224 | + t.Fatalf("npub = %s", got) |
| 225 | + } |
| 226 | +} |
| 227 | + |
| 228 | +func TestConnectionsExpandPlaceholdersAndHideUserLinksForGuests(t *testing.T) { |
| 229 | + data := PageData{URL: "https://relay.example", Identity: strings.Repeat("a", 64), Policy: policy.Defaults(strings.Repeat("b", 64))} |
| 230 | + rows := []any{map[string]any{"template": "repos", "title": "Repos", "visibility": "public", "links": []any{ |
| 231 | + map[string]any{"label": "Open", "href": "https://gitworkshop.dev/relay/{relay:host|enc}"}, |
| 232 | + map[string]any{"Label": "Copy clone command", "Copy": "git clone '{relay:web}/{user:npub}/{input:repo}.git'"}, |
| 233 | + }}, map[string]any{"template": "group", "title": "Group", "qr": "nostr:{relay:naddr}", "links": []any{map[string]any{"label": "Open", "href": "https://app.flotilla.social/spaces/{relay:host|enc}"}}}} |
| 234 | + cards := expandConnections(rows, data) |
| 235 | + repos := valueMap(cards[0]) |
| 236 | + links := browseRows(repos["links"]) |
| 237 | + if len(links) != 1 || valueMap(links[0])["href"] != "https://gitworkshop.dev/relay/relay.example" || repos["hidden"] != 1 { |
| 238 | + t.Fatalf("guest repos card = %v", repos) |
| 239 | + } |
| 240 | + group := valueMap(cards[1]) |
| 241 | + if !strings.HasPrefix(plainString(group["qr"]), "nostr:naddr1") { |
| 242 | + t.Fatalf("group qr = %v", group["qr"]) |
| 243 | + } |
| 244 | + data.Actor = strings.Repeat("c", 64) |
| 245 | + cards = expandConnections(rows, data) |
| 246 | + links = browseRows(valueMap(cards[0])["links"]) |
| 247 | + if len(links) != 2 || !strings.Contains(plainString(valueMap(links[1])["copy"]), "/npub1enxvenxv") || !strings.Contains(plainString(valueMap(links[1])["copy"]), "{input:repo}") { |
| 248 | + t.Fatalf("member repos links = %v", links) |
| 249 | + } |
| 250 | +} |
| 251 | + |
| 252 | +func mustHex(value string) []byte { |
| 253 | + out := make([]byte, len(value)/2) |
| 254 | + for i := range out { |
| 255 | + out[i] = hexNibble(value[2*i])<<4 | hexNibble(value[2*i+1]) |
| 256 | + } |
| 257 | + return out |
| 258 | +} |
| 259 | + |
| 260 | +func hexNibble(c byte) byte { |
| 261 | + switch { |
| 262 | + case c >= '0' && c <= '9': |
| 263 | + return c - '0' |
| 264 | + case c >= 'a' && c <= 'f': |
| 265 | + return c - 'a' + 10 |
| 266 | + } |
| 267 | + return 0 |
| 268 | +} |
| 269 | diff --git a/internal/webui/public.html b/internal/webui/public.html |
| 270 | index b75d99b..d5e6e89 100644 |
| 271 | --- a/internal/webui/public.html |
| 272 | +++ b/internal/webui/public.html |
| 273 | @@ -11,14 +11,16 @@ |
| 274 | </table> |
| 275 | {{if .Policy.Description}}<h2>About</h2> |
| 276 | {{markdown .Policy.Description}} |
| 277 | -{{end}}{{if .Connections}}<h2>Connections</h2> |
| 278 | -<table id="rows"> |
| 279 | -<thead><tr><th>connection</th><th>relay</th><th>status</th></tr></thead> |
| 280 | -<tbody> |
| 281 | -{{range .Connections}}{{$c := asMap .}}<tr><td>{{or (str (index $c "label")) (str (index $c "title")) (str (index $c "name")) (str (index $c "template"))}}</td><td><code>{{or (str (index $c "url")) (str (index $c "href")) (str (index $c "relay"))}}</code></td><td>{{or (str (index $c "status")) (str (index $c "visibility")) "configured"}}</td></tr> |
| 282 | -{{end}}</tbody> |
| 283 | -</table> |
| 284 | -{{end}}<h2>Recent activity</h2> |
| 285 | +{{end}}{{if .Connections}}<h2>Connect</h2> |
| 286 | +<p>Open this relay in an app. Each card is a way in; sign in to see the ones made for members.</p> |
| 287 | +{{range .Connections}}{{$c := asMap .}}<connect-card> |
| 288 | +<h3>{{str (index $c "title")}} <small>{{str (index $c "app")}}{{with str (index $c "where")}}, {{.}}{{end}}{{if ne (str (index $c "visibility")) "public"}} | {{str (index $c "visibility")}}{{end}}</small></h3> |
| 289 | +<p>{{str (index $c "about")}}</p> |
| 290 | +{{range index $c "inputs"}}{{$i := asMap .}}<label>{{str (index $i "Label")}} <input name="{{str (index $i "Name")}}" placeholder="{{str (index $i "Placeholder")}}" pattern="{{str (index $i "Pattern")}}"></label> |
| 291 | +{{end}}<p>{{range index $c "links"}}{{$l := asMap .}}{{if str (index $l "href")}}<a href="{{str (index $l "href")}}">{{str (index $l "label")}}</a> {{else}}<button type="button" data-copy="{{str (index $l "copy")}}">{{str (index $l "label")}}</button> {{end}}{{end}}{{if index $c "hidden"}}<small>sign in for {{str (index $c "hidden")}} more</small>{{end}}<output role="status"></output></p> |
| 292 | +{{with str (index $c "qr")}}<details><summary>QR code</summary><img src="/qr.svg?text={{urlquery .}}" alt="QR code" width="200" height="200"></details>{{end}} |
| 293 | +</connect-card> |
| 294 | +{{end}}{{end}}<h2>Recent activity</h2> |
| 295 | {{if .Error}}{{template "alert" .Error}}{{else}}{{template "eventList" .}}{{end}} |
| 296 | </section> |
| 297 | {{end}} |
| 298 | diff --git a/internal/webui/shell_test.go b/internal/webui/shell_test.go |
| 299 | index 4d571be..3ac1755 100644 |
| 300 | --- a/internal/webui/shell_test.go |
| 301 | +++ b/internal/webui/shell_test.go |
| 302 | @@ -312,13 +312,13 @@ func TestGuestsSeeSignInInsteadOfManagementPages(t *testing.T) { |
| 303 | type connectionsBackend struct{ fakeBackend } |
| 304 | |
| 305 | func (b *connectionsBackend) Query(_ context.Context, method string, _ []json.RawMessage, _ string) (any, error) { |
| 306 | - if method == "listconnections" { |
| 307 | - return []any{map[string]any{"label": "Upstream", "url": "wss://upstream.example", "status": "syncing"}}, nil |
| 308 | + if method == "connections" { |
| 309 | + return []any{map[string]any{"template": "notes", "title": "Notes", "about": "Everything posted here.", "app": "Jumble", "where": "web", "visibility": "public", "links": []any{map[string]any{"label": "Open", "href": "https://jumble.social/?r={relay:url|enc}"}}}}, nil |
| 310 | } |
| 311 | return nil, nil |
| 312 | } |
| 313 | |
| 314 | -func TestAccountPageOffersRelayListsAndHomeShowsConnections(t *testing.T) { |
| 315 | +func TestAccountPageOffersRelayListsAndHomeShowsConnectCards(t *testing.T) { |
| 316 | backend := &connectionsBackend{fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}} |
| 317 | app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }}) |
| 318 | if err != nil { |
| 319 | @@ -335,16 +335,7 @@ func TestAccountPageOffersRelayListsAndHomeShowsConnections(t *testing.T) { |
| 320 | recorder = httptest.NewRecorder() |
| 321 | app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil)) |
| 322 | body = recorder.Body.String() |
| 323 | - if !strings.Contains(body, "<h2>Connections</h2>") || !strings.Contains(body, "wss://upstream.example") || !strings.Contains(body, "syncing") { |
| 324 | - t.Fatalf("home page did not show configured connections: %s", body[:min(400, len(body))]) |
| 325 | - } |
| 326 | - guest, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return "", nil }}) |
| 327 | - if err != nil { |
| 328 | - t.Fatal(err) |
| 329 | - } |
| 330 | - recorder = httptest.NewRecorder() |
| 331 | - guest.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil)) |
| 332 | - if strings.Contains(recorder.Body.String(), "<h2>Connections</h2>") { |
| 333 | - t.Fatal("guests must not see configured connections") |
| 334 | + if !strings.Contains(body, "<h2>Connect</h2>") || !strings.Contains(body, `href="https://jumble.social/?r=ws%3A%2F%2Frelay.example"`) || !strings.Contains(body, "<connect-card>") { |
| 335 | + t.Fatalf("home page did not show connect cards: %s", body[:min(400, len(body))]) |
| 336 | } |
| 337 | } |
| 338 | diff --git a/internal/webui/style.css b/internal/webui/style.css |
| 339 | index 2031612..4d059e7 100644 |
| 340 | --- a/internal/webui/style.css |
| 341 | +++ b/internal/webui/style.css |
| 342 | @@ -112,6 +112,8 @@ rpc-form output[data-error]{color:var(--alert)} |
| 343 | |
| 344 | relay-lists{display:block}relay-lists #rows td{height:auto;padding:8px 10px;white-space:normal;vertical-align:middle}relay-lists #rows td:last-child{width:230px;text-align:left;color:var(--ink);white-space:nowrap}relay-lists #rows td:first-child{width:170px;color:var(--ink);font-family:var(--sans)}relay-lists output{display:block;font:12px var(--mono);color:var(--muted);white-space:normal}relay-lists button{font-size:12px} |
| 345 | |
| 346 | +connect-card{display:block;border-bottom:1px solid var(--line);padding:10px 0;max-width:720px}connect-card h3{margin:0 0 4px;border:0;font-size:15px}connect-card h3 small{font:12px var(--mono);color:var(--muted)}connect-card p{margin:0 0 6px}connect-card label{display:inline-block;font:12px var(--mono);color:var(--muted);margin:0 10px 6px 0}connect-card label input{display:block;font:13px var(--mono);min-width:200px}connect-card a,connect-card button{margin-right:10px;font-size:13px}connect-card output{display:block;font:12px var(--mono);color:var(--muted);overflow-wrap:anywhere}connect-card details img{display:block;margin:6px 0;background:#fff;padding:6px;border:1px solid var(--line)} |
| 347 | + |
| 348 | /* json-view: structured rendering of any JSON payload. */ |
| 349 | json-view{display:block;font:12px var(--mono)} |
| 350 | json-view>details>summary{cursor:pointer;color:var(--muted)} |
| 351 | diff --git a/internal/webui/webui.go b/internal/webui/webui.go |
| 352 | index 93afdce..dfbf966 100644 |
| 353 | --- a/internal/webui/webui.go |
| 354 | +++ b/internal/webui/webui.go |
| 355 | @@ -79,8 +79,8 @@ type PageData struct { |
| 356 | Path string |
| 357 | Readme template.HTML |
| 358 | Tree []any |
| 359 | - // Connections lists the relay's configured connections for signed-in |
| 360 | - // owners and moderators; it stays nil for everyone else. |
| 361 | + // Connections lists the ways to open this relay in client apps that the |
| 362 | + // viewer may see, with link placeholders already resolved. |
| 363 | Connections []any |
| 364 | } |
| 365 | |
| 366 | @@ -680,21 +680,7 @@ func identityNpub(identity string) string { |
| 367 | |
| 368 | const bech32Charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" |
| 369 | |
| 370 | -func encodeNpub(data []byte) string { |
| 371 | - fiveBits := convertBits(data) |
| 372 | - values := append(append([]byte{}, fiveBits...), 0, 0, 0, 0, 0, 0) |
| 373 | - polymod := bech32Polymod(append([]byte{3, 3, 3, 3, 0, 14, 16, 21, 2}, values...)) ^ 1 |
| 374 | - checksum := make([]byte, 6) |
| 375 | - for i := range checksum { |
| 376 | - checksum[i] = byte(polymod >> uint(5*(5-i)) & 31) |
| 377 | - } |
| 378 | - var out strings.Builder |
| 379 | - out.WriteString("npub1") |
| 380 | - for _, value := range append(fiveBits, checksum...) { |
| 381 | - out.WriteByte(bech32Charset[value]) |
| 382 | - } |
| 383 | - return out.String() |
| 384 | -} |
| 385 | +func encodeNpub(data []byte) string { return bech32("npub", data) } |
| 386 | |
| 387 | func convertBits(data []byte) []byte { |
| 388 | result := make([]byte, 0, 52) |
| 389 | @@ -772,9 +758,9 @@ func (a *App) page(writer http.ResponseWriter, request *http.Request) { |
| 390 | data.Feed = feed |
| 391 | } |
| 392 | } |
| 393 | - if tab == "home" && actor != "" { |
| 394 | - if result, err := a.backend.Query(request.Context(), "listconnections", nil, actor); err == nil { |
| 395 | - data.Connections = browseRows(result) |
| 396 | + if tab == "home" { |
| 397 | + if result, err := a.backend.Query(request.Context(), "connections", nil, actor); err == nil { |
| 398 | + data.Connections = expandConnections(browseRows(result), PageData{URL: a.backend.URL(), Identity: a.backend.Identity(), Policy: a.backend.Policy(), Actor: actor}) |
| 399 | } |
| 400 | } |
| 401 | if tab != "" { |