server+web: broadcast user_invites_changed so the toggle is live (#1038)
"Users can invite people" was baked into index.html as a meta tag at page load (USER_INVITES), so flipping it in the admin console only took effect for already-connected members after they reloaded. The admin settings handler now broadcasts user_invites_changed (only on an actual flip, matching the existing audit-log gate) and shell.js updates the module's USER_INVITES live. inviteAllowed() and its callers (the command palette, New chat sheet) already compute fresh on open, so no further re-render is needed. Fixes #346
Co-authored-by: Claude <noreply@anthropic.com>
4 files changed,
+97
−2
changelog/2026-07-15-user-invites-live.md+3 −0handlers_test.go+77 −0main.go+5 −0web/static/shell.js+12 −2
diff --git a/changelog/2026-07-15-user-invites-live.md b/changelog/2026-07-15-user-invites-live.md| new file mode 100644 |
| index 0000000..5ca0e7c |
| --- /dev/null |
| +++ b/changelog/2026-07-15-user-invites-live.md |
| @@ -0,0 +1,3 @@ |
| +🤖 Turning "users can invite people" on or off now takes effect immediately |
| +for everyone connected, instead of only after each person reloads their |
| +browser. |
diff --git a/handlers_test.go b/handlers_test.go| index 191ff8d..eae0193 100644 |
| --- a/handlers_test.go |
| +++ b/handlers_test.go |
| @@ -1723,6 +1723,83 @@ func TestCustomAppsBroadcastsAppsChanged(t *testing.T) { |
| expectAppsChanged("delete") |
| } |
| +// Flipping "users can invite people" broadcasts the new value live, so a |
| +// connected member's invite UI updates without a reload (issue #346). |
| +func TestUserInvitesBroadcastsLive(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + tok, _, tid := joinedMember(t, srv, "Alice") |
| + |
| + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) |
| + defer cancel() |
| + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws?token=" + tok |
| + conn, _, err := websocket.Dial(ctx, wsURL, nil) |
| + if err != nil { |
| + t.Fatalf("ws dial: %v", err) |
| + } |
| + defer conn.CloseNow() |
| + // See TestCustomAppsBroadcastsAppsChanged: a stored round-trip proves our |
| + // registration landed before the admin call below. |
| + if err := conn.Write(ctx, websocket.MessageText, []byte(`{"type":"msg","thread_id":"`+tid+`","blob":"warmup"}`)); err != nil { |
| + t.Fatalf("warmup write: %v", err) |
| + } |
| + if _, _, err := conn.Read(ctx); err != nil { |
| + t.Fatalf("warmup read: %v", err) |
| + } |
| + |
| + expectInvitesChanged := func(stage string, want bool) { |
| + for { |
| + var msg struct { |
| + Type string `json:"type"` |
| + Enabled bool `json:"enabled"` |
| + } |
| + _, data, err := conn.Read(ctx) |
| + if err != nil { |
| + t.Fatalf("%s read: %v (no user_invites_changed)", stage, err) |
| + } |
| + if json.Unmarshal(data, &msg) == nil && msg.Type == "user_invites_changed" { |
| + if msg.Enabled != want { |
| + t.Fatalf("%s: enabled = %v, want %v", stage, msg.Enabled, want) |
| + } |
| + return |
| + } |
| + } |
| + } |
| + |
| + if resp := adminPost(t, ts.URL+"/api/admin/settings", `{"user_invites":true}`); resp.StatusCode != http.StatusOK { |
| + t.Fatalf("enable: want 200, got %d", resp.StatusCode) |
| + } |
| + expectInvitesChanged("enable", true) |
| + |
| + if resp := adminPost(t, ts.URL+"/api/admin/settings", `{"user_invites":false}`); resp.StatusCode != http.StatusOK { |
| + t.Fatalf("disable: want 200, got %d", resp.StatusCode) |
| + } |
| + expectInvitesChanged("disable", false) |
| + |
| + // Re-posting the SAME value is a no-op — no audit entry, no broadcast (only |
| + // a genuine flip is a moderation-relevant event). Confirm no broadcast |
| + // arrives by racing a distinguishable follow-up message. |
| + adminPost(t, ts.URL+"/api/admin/settings", `{"user_invites":false}`) |
| + if err := conn.Write(ctx, websocket.MessageText, []byte(`{"type":"msg","thread_id":"`+tid+`","blob":"canary"}`)); err != nil { |
| + t.Fatalf("canary write: %v", err) |
| + } |
| + for { |
| + var msg struct { |
| + Type string `json:"type"` |
| + Blob string `json:"blob"` |
| + } |
| + _, data, err := conn.Read(ctx) |
| + if err != nil { |
| + t.Fatalf("canary read: %v", err) |
| + } |
| + if json.Unmarshal(data, &msg) == nil && msg.Type == "user_invites_changed" { |
| + t.Fatal("re-posting the unchanged value should not broadcast") |
| + } |
| + if json.Unmarshal(data, &msg) == nil && msg.Type == "msg" && msg.Blob == "canary" { |
| + break // canary arrived first — no stray broadcast was queued ahead of it |
| + } |
| + } |
| +} |
| + |
| func TestNicknamesRoundTrip(t *testing.T) { |
| ts, srv := newTestServer(t) |
| tok, _, _ := joinedMember(t, srv, "viewer") |
diff --git a/main.go b/main.go| index c69f4fd..76b0254 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -4410,6 +4410,11 @@ func (s *Server) setSettings(w http.ResponseWriter, r *http.Request) { |
| act = "invites.paused" |
| } |
| s.db.addAudit(s.adminActor(r), act, "", "", "", "") |
| + // Every connected client baked USER_INVITES into its page at load |
| + // (index.html's meta tag) — without this, the toggle's effect on |
| + // the invite UI only ever showed up after a reload (issue #346). |
| + out, _ := json.Marshal(map[string]any{"type": "user_invites_changed", "enabled": *body.UserInvites}) |
| + s.hub.broadcast <- outbound{data: out} // members:nil → everyone |
| } |
| } |
| if body.AIEnabled != nil { |
diff --git a/web/static/shell.js b/web/static/shell.js| index 2c8b05c..9f58cbe 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -2070,8 +2070,10 @@ function syncThreadKeysToSW() { |
| const APP_VERSION = (document.querySelector('meta[name="app-version"]') || {}).content || ""; |
| // Whether ordinary members may invite people (server-injected admin setting). Gates |
| -// the invite UI only; the server enforces it regardless. |
| -const USER_INVITES = ((document.querySelector('meta[name="user-invites"]') || {}).content || "") === "1"; |
| +// the invite UI only; the server enforces it regardless. Kept live by |
| +// "user_invites_changed" below (issue #346) — an admin flipping the toggle |
| +// used to only take effect for connected members after a reload. |
| +let USER_INVITES = ((document.querySelector('meta[name="user-invites"]') || {}).content || "") === "1"; |
| // Per-instance feature gates (server-injected admin settings). Default ON — a |
| // missing meta (an older server) reads as enabled — and, like USER_INVITES, |
| @@ -6441,6 +6443,14 @@ function connectWS() { |
| } |
| return; |
| } |
| + if (msg.type === "user_invites_changed") { |
| + // The admin flipped "users can invite people" (issue #346). USER_INVITES |
| + // was only ever set once, from the page's initial load — every place that |
| + // reads it via inviteAllowed() computes fresh on open (the palette, the |
| + // New chat sheet), so updating it here is enough; nothing to re-render. |
| + USER_INVITES = !!msg.enabled; |
| + return; |
| + } |
| if (msg.type === "dm_closed" || msg.type === "dm_reopened") { |
| // Either party ended (or revived) this DM. An open pane swaps between |
| // composer and closed banner live; the list updates its hidden state. |