eleven / ocho Public

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>
Paul Campbell 069d36c4aa73fc8e6786bfd4a6784df67c51395b parent 203ce1c
4 files changed, +66 −10
  • changelog/2026-07-10-admins-always-invite.md +3 −0
  • handlers_test.go +37 −0
  • main.go +11 −6
  • web/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);
}