server+web: admins can always invite — the toggle governs members only (#600)
The "members can invite" setting gated the in-app invite path for everyone, admins included: a fresh space's admin (role=admin, verified set by the setup-invite claim) couldn't invite anyone without flipping the toggle in the /admin console — a path we're sunsetting. The server handler now bypasses the gate for role=admin, and the web UI's four invite affordances gate on inviteAllowed() (setting OR own roster role) instead of the injected setting alone.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+66
−10
changelog/2026-07-10-admins-always-invite.md+3 −0handlers_test.go+37 −0main.go+11 −6web/static/shell.js+15 −4
diff --git a/changelog/2026-07-10-admins-always-invite.md b/changelog/2026-07-10-admins-always-invite.md| new file mode 100644 |
| index 0000000..54ed036 |
| --- /dev/null |
| +++ b/changelog/2026-07-10-admins-always-invite.md |
| @@ -0,0 +1,3 @@ |
| +🤖 Admins can now always invite people, straight from the app. Before, the |
| +"members can invite" switch accidentally applied to admins too — so a brand-new |
| +space's admin couldn't bring anyone in without a trip to the admin console. |
diff --git a/handlers_test.go b/handlers_test.go| index f506847..b791f37 100644 |
| --- a/handlers_test.go |
| +++ b/handlers_test.go |
| @@ -1760,3 +1760,40 @@ func TestNicknamesRoundTrip(t *testing.T) { |
| t.Fatalf("clear: got %q", blob) |
| } |
| } |
| + |
| +func TestAdminCanAlwaysInvite(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + adminTok, adminUID, _ := joinedMember(t, srv, "boss") |
| + if err := srv.db.setUserRole(adminUID, "admin"); err != nil { |
| + t.Fatalf("setUserRole: %v", err) |
| + } |
| + memberTok, _, _ := joinedMember(t, srv, "pal") |
| + if err := srv.db.setUsersCanInvite(false); err != nil { |
| + t.Fatalf("setUsersCanInvite: %v", err) |
| + } |
| + |
| + post := func(token, id string) *http.Response { |
| + body := `{"token":"` + token + `","id":"` + id + `","wrapped":"w","claim_hash":"h","label_blob":"lb","self_wrapped":"sw"}` |
| + r, err := http.Post(ts.URL+"/api/invites", "application/json", strings.NewReader(body)) |
| + if err != nil { |
| + t.Fatalf("post: %v", err) |
| + } |
| + return r |
| + } |
| + |
| + // The toggle governs ordinary members… |
| + if r := post(memberTok, "inv-member-try"); r.StatusCode != http.StatusForbidden { |
| + t.Fatalf("member with invites off: want 403, got %d", r.StatusCode) |
| + } |
| + // …but never the admin. |
| + if r := post(adminTok, "inv-admin-try"); r.StatusCode != http.StatusOK { |
| + t.Fatalf("admin with invites off: want 200, got %d", r.StatusCode) |
| + } |
| + // Flipping the toggle on admits members too (the pre-existing behaviour). |
| + if err := srv.db.setUsersCanInvite(true); err != nil { |
| + t.Fatalf("setUsersCanInvite: %v", err) |
| + } |
| + if r := post(memberTok, "inv-member-ok"); r.StatusCode != http.StatusOK { |
| + t.Fatalf("member with invites on: want 200, got %d", r.StatusCode) |
| + } |
| +} |
diff --git a/main.go b/main.go| index e943c97..3462e8b 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -2164,8 +2164,9 @@ func (s *Server) createDeviceInvite(w http.ResponseWriter, r *http.Request) { |
| writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| } |
| -// postUserInvite lets an ordinary member invite a brand-new person straight into a |
| -// DM — gated by the "users can invite" admin setting (off by default). The browser |
| +// postUserInvite lets a member invite a brand-new person straight into a DM. |
| +// Ordinary members are gated by the "users can invite" admin setting (off by |
| +// default); an admin may always invite. The browser |
| // generates the DM key and hands us only wrapped blobs: `wrapped` is the key |
| // encrypted to the invite secret (so the joiner recovers it), `self_wrapped` is it |
| // wrapped to the inviter's own public key. With no thread_id we create the pending |
| @@ -2189,15 +2190,19 @@ func (s *Server) postUserInvite(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusForbidden, "not a member") |
| return |
| } |
| - if !s.db.usersCanInvite() { |
| - httpError(w, http.StatusForbidden, "inviting is turned off") |
| - return |
| - } |
| uid, ok := s.db.deviceTokenUser(body.Token) |
| if !ok { |
| httpError(w, http.StatusForbidden, "no user for token") |
| return |
| } |
| + // The "members can invite" toggle governs ordinary members; an admin can |
| + // always invite. Without the bypass the person who just set the space up |
| + // couldn't bring anyone in without flipping the toggle in the admin |
| + // console — a path we're sunsetting. |
| + if !s.db.usersCanInvite() && s.db.userRole(uid) != "admin" { |
| + httpError(w, http.StatusForbidden, "inviting is turned off") |
| + return |
| + } |
| if body.ID == "" || len(body.ID) > 200 || body.Wrapped == "" || len(body.Wrapped) > 4096 || |
| body.ClaimHash == "" || len(body.ClaimHash) > 128 { |
| httpError(w, http.StatusBadRequest, "bad request") |
diff --git a/web/static/shell.js b/web/static/shell.js| index 97110f8..c3e90cb 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -1428,6 +1428,17 @@ const APP_VERSION = (document.querySelector('meta[name="app-version"]') || {}).c |
| // the invite UI only; the server enforces it regardless. |
| const USER_INVITES = ((document.querySelector('meta[name="user-invites"]') || {}).content || "") === "1"; |
| +// Whether THIS member may invite: the toggle governs ordinary members, but an |
| +// admin always can — otherwise the person who just set the space up couldn't |
| +// bring anyone in without a trip to the admin console (a path we're |
| +// sunsetting). Live read, so it's correct once the roster (which carries our |
| +// role) has loaded. |
| +function inviteAllowed() { |
| + if (USER_INVITES) return true; |
| + const me = roster.find((e) => e.user_id === myUserId); |
| + return !!(me && me.role === "admin"); |
| +} |
| + |
| // True when a composer holds unsent text — reload paths check this so picking up |
| // an update never eats someone's draft. Covers every mounted chat-app composer |
| // (Split view shows two) plus the legacy #text box. |
| @@ -1562,7 +1573,7 @@ window.lchatPaletteItems = () => { |
| const others = roster.some((e) => e.user_id !== myUserId && e.public_key); |
| const actions = [{ id: "new-chat", label: "New chat" }]; |
| if (others) actions.push({ id: "new-group", label: "New group" }); |
| - if (USER_INVITES) actions.push({ id: "invite", label: "Invite someone" }); |
| + if (inviteAllowed()) actions.push({ id: "invite", label: "Invite someone" }); |
| if (personalApps().length) actions.push({ id: "apps", label: "Open apps" }); |
| actions.push( |
| { id: "add-device", label: "Add a device" }, |
| @@ -6467,7 +6478,7 @@ function showNewChat(startMode) { |
| toggle.onclick = () => render("group"); |
| head.appendChild(toggle); |
| } |
| - if (USER_INVITES) { |
| + if (inviteAllowed()) { |
| const inv = document.createElement("button"); |
| inv.className = "linkish"; |
| inv.textContent = "Invite someone"; |
| @@ -6512,7 +6523,7 @@ function showNewChat(startMode) { |
| if (!others.length) { |
| const p = document.createElement("p"); |
| p.className = "sub"; |
| - if (USER_INVITES) { |
| + if (inviteAllowed()) { |
| p.textContent = "No connections yet — invite someone to start chatting."; |
| card.appendChild(p); |
| const btn = document.createElement("button"); |
| @@ -6641,7 +6652,7 @@ function showNewChat(startMode) { |
| // Honour a requested start view only when it's usable, else fall back. |
| let start = "people"; |
| if (startMode === "group" && others.length) start = "group"; |
| - if (startMode === "invite" && USER_INVITES) start = "invite"; |
| + if (startMode === "invite" && inviteAllowed()) start = "invite"; |
| render(start); |
| } |