eleven / messenger Public

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>
James Adam 1aade00a7e8a6050b169794d514bf262eba817a2 parent 34c99fb
4 files changed, +97 −2
  • changelog/2026-07-15-user-invites-live.md +3 −0
  • handlers_test.go +77 −0
  • main.go +5 −0
  • web/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.