eleven / messenger Public

web,admin: allow admins to delegate the admin role to other users (#342)

* web,admin: allow admins to delegate the admin role to other users

Add a "Delegate admin role" section in the admin console that lets the current
admin create a special invite. When claimed, it grants the claimer the admin role
so they can access the console with their device token (no need to share the
bearer token). This provides a recovery path if the admin loses their token and
allows sharing admin duties.

Backed by the existing role-based authorization infrastructure (the backend
already supported admin roles and grants_admin invites; now exposed in the UI).

* web,admin: fix smart quotes in admin delegation form HTML

The HTML attributes had UTF-8 encoded smart/curly quotes (e2 80 9c/9d)
instead of ASCII straight quotes (22), which broke the attribute parsing.
Replaced all curly quotes with ASCII straight quotes.

* web,admin app: add admin delegation UI in the chat

Add a "Delegate admin role" section to the admin.js mini-app (accessible via
/admin command in the chat). Admins can now:
- List members
- Select one to promote to admin
- Create an admin-granting add-device invite

The invite allows existing members to claim admin role on their existing account
(not a new-user invite). Shows the invite link/QR in a modal for easy sharing.

Adds two bridge methods to shell.js:
- listUsers(): fetch admin user list to populate the member dropdown
- createAdminInvite(userId, label): mint an admin-granting add-device invite
  for an existing member

* web,admin app: simplify admin delegation to mint generic invite links

Since the admin.js app has no access to member names or info (all E2E encrypted),
simplify to just mint generic admin-granting invite links. The person receiving
the link can:
- Join as a new member with admin role
- Or claim it on their existing account to get promoted to admin

Removes user listing logic. The bridge method now just creates a generic
admin-granting invite with no specific user_id target.

* tools: lchat-dev-up --onboarding: mint setup invite instead of regular invite

When run with --onboarding, the tool now creates an admin-granting setup invite
(with grants_admin=true) instead of a regular user invite. The first person to
claim it becomes an admin and lands in the Eleven setup conversation, exactly
as if they had claimed the fresh-instance setup link.

This allows testing the onboarding and admin delegation flows with a zero-user
instance (no buddies or chats pre-seeded).

* tools: lchat-dev-up: clarify invite type in output

Display the invite type clearly in the output so it's obvious whether you're
getting a SETUP (admin-granting) invite or a regular user invite. Include:
- The invite ID (so you can verify it's the setup invite if needed)
- The invite type label
- Clear instructions on what claiming each type does

Makes it unmistakable whether you're testing onboarding or the group chat flow.

* web: handle existing sessions when claiming admin invites

When an existing member claims an admin-granting invite, promote them to admin
on their existing account instead of creating a brand new user and running
setup again.

Changes:
- Check for existing session when processing admin invites (grants_admin)
- If already logged in, show "Become admin" welcome card and call /adopt
- If not logged in, proceed with normal setup flow (new user, admin role)

The /adopt endpoint supports admin invites (no wrapped thread key, just the
claim_secret). This makes admin delegation seamless for existing members.

* web: preserve existing sessions when claiming any invite type

Fix the fallback invite case: when an existing member clicks any invite link
(that doesn't have a specific payload), check if they're already logged in and
adopt it as their existing self instead of creating a new user account.

This prevents accidentally overwriting your existing session/access when
clicking an invite link you received. The /adopt endpoint is generic and works
for all invite types (with or without wrapped payloads).

Now all three invite paths check for existing sessions:
- User invites (DM): already had check
- Admin invites: fixed in prior commit
- Generic invites: fixed here

* web: fix admin invite claiming for existing members and already-set-up servers

Two fixes:

1. Use /claim instead of /adopt for admin invites
   - /adopt endpoint expects wrapped payload (designed for DM invites)
   - /claim works for all invite types, including admin invites
   - Update session with new token returned from /claim

2. Don't force setup flow on already-set-up servers
   - Check if setup_complete is true before showing setup welcome card
   - If setup is already done, show "join" card instead
   - This prevents re-triggering setup when someone claims an admin invite
     on an instance where setup has already completed

Now:
- Existing members can claim admin invites and get promoted
- New admins on already-set-up servers don't see setup flow again

* web,server: polish admin invite experience

UX improvements:

1. Clearer welcome card text for adopting invites
   - Instead of "You're invited to X / You're already in this chat"
   - Now shows "You're already in X" on the title line
   - Makes it clear you're adopting an existing relationship

2. Existing members now claim admin invites on their existing account
   - Changed frontend to use /adopt endpoint for admin invites
   - Modified server /adopt to support admin invites (no wrapped payload needed)
   - Added adoptAdminInvite() to promote existing users to admin atomically
   - Fixes issue where accepting admin invite created new account with Eleven onboarding

3. Skip setup flow on already-set-up servers
   - Check setup_complete status before showing setup welcome card
   - Prevents re-triggering setup when admin invite claimed on live instance

Result: Admin invitation flow is now seamless for existing members.

* server: suppress setup flow when existing members claim admin invites

When an existing member claims an admin invite, automatically mark
setup_complete=true so they don't see the Eleven setup conversation.

They're not the fresh first-time setup user — they already have chats and
access. Setting setup_complete prevents the app from auto-opening the setup
flow when they land in the chat.

Fixes issue where accepting admin invite dropped user into setup conversation
despite having existing chats visible in sidebar.

* web: add confirmation and warnings to admin invite flow

Add friction to prevent accidental admin invite sharing:

1. Confirmation modal before creating the invite
   - Explains what admin invites do
   - Lists use cases (delegation, recovery)
   - Warns to keep the link private

2. Extra warning on the invite modal itself
   - Highlights that anyone claiming it gets full admin control
   - Changes the guidance text for admin invites
   - Makes it distinct from regular person invites

This makes it clear that admin invites are powerful and should be
treated like passwords, not shared casually.

* web: remove extra warnings from invite modal

Only show warnings when admin mints the invite (in the confirmation
modal), not when the link is displayed. The invite modal is shared
by all invite types and is shown generically after creation, so
warnings belong only in the creation flow.
James Adam 1adba68ff6ad8bda0ce64fcaefd82bca2ac8dcc6 parent db1c7f9
8 files changed, +375 −25
  • db.go +51 −0
  • main.go +21 −1
  • tools/lchat-dev-up.mjs +28 −15
  • web/pages/admin.html +10 −1
  • web/static/admin.js +64 −1
  • web/static/apps/admin.js +85 −0
  • web/static/join.js +85 −7
  • web/static/shell.js +31 −0
diff --git a/db.go b/db.go
index 1a1f357..630931e 100644
--- a/db.go
+++ b/db.go
@@ -539,6 +539,15 @@ func (db *DB) setConfig(key, value string) error {
return err
}
+// setConfigTx sets a config value within a transaction.
+func setConfigTx(tx *sql.Tx, key, value string) error {
+ _, err := tx.Exec(
+ "INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
+ key, value,
+ )
+ return err
+}
+
// ---- admin token --------------------------------------------------------
//
// The admin token only authorises invite management. It is NOT an encryption
@@ -807,6 +816,42 @@ func (db *DB) setInviteGrantsAdmin(id string) error {
return err
}
+// adoptAdminInvite claims an admin-granting invite on an existing member's
+// account, promoting them to admin without creating a new user. Also marks
+// setup_complete=true since the adopter is not a fresh first-time user.
+// Returns ok=false (no error) if the invite is unknown, used, expired, or has invalid claim secret.
+func (db *DB) adoptAdminInvite(inviteID, existingUserID, claimSecret string) (bool, error) {
+ ki, found, err := db.getKeyInvite(inviteID)
+ if err != nil || !found || ki.Used || ki.Expired || !ki.GrantsAdmin {
+ return false, err
+ }
+ if !validClaimSecret(ki.ClaimHash, claimSecret) {
+ return false, nil
+ }
+ // Mark the invite as used, promote the user to admin, and mark setup complete
+ // (since existing members should not see the setup flow).
+ tx, err := db.sql.Begin()
+ if err != nil {
+ return false, err
+ }
+ defer tx.Rollback()
+ res, err := tx.Exec("UPDATE key_invites SET used_at = ? WHERE id = ? AND used_at IS NULL", time.Now().Unix(), inviteID)
+ if err != nil {
+ return false, err
+ }
+ if n, _ := res.RowsAffected(); n == 0 {
+ return false, nil // already claimed
+ }
+ if err := setUserRoleTx(tx, existingUserID, "admin"); err != nil {
+ return false, err
+ }
+ // Mark setup as complete so existing members don't see the setup flow.
+ if err := setConfigTx(tx, "setup_complete", "1"); err != nil {
+ return false, err
+ }
+ return true, tx.Commit()
+}
+
// claimKeyInvite marks an unused, unexpired invite as used (single-use) and mints
// a device token for its user. Returns the plaintext token (shown once). ok=false
// (no error) if the invite is unknown, used, expired, or lost a claim race.
@@ -882,6 +927,12 @@ func (db *DB) setUserRole(id, role string) error {
return err
}
+// setUserRoleTx sets a user's role within a transaction (for atomic operations).
+func setUserRoleTx(tx *sql.Tx, id, role string) error {
+ _, err := tx.Exec("UPDATE users SET role = ? WHERE id = ?", role, id)
+ return err
+}
+
// deviceCount returns how many devices (tokens) a user currently has signed in — so
// the client can warn before signing out the LAST one (which loses their keys).
func (db *DB) deviceCount(userID string) int {
diff --git a/main.go b/main.go
index dbd036c..9fca73d 100644
--- a/main.go
+++ b/main.go
@@ -2041,11 +2041,31 @@ func (s *Server) adoptKeyInvite(w http.ResponseWriter, r *http.Request) {
httpError(w, http.StatusForbidden, "no user for token")
return
}
+ // Check if this is an admin invite (no wrapped payload needed for admin invites).
+ id := r.PathValue("id")
+ ki, found, err := s.db.getKeyInvite(id)
+ if err != nil || !found {
+ httpError(w, http.StatusForbidden, "invite unavailable")
+ return
+ }
+ if ki.GrantsAdmin {
+ // Admin invite: promote the existing user to admin.
+ if ok, err := s.db.adoptAdminInvite(id, uid, body.ClaimSecret); err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ } else if !ok {
+ httpError(w, http.StatusForbidden, "invite unavailable")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+ return
+ }
+ // DM invite: wrapped key is required.
if body.Wrapped == "" || len(body.Wrapped) > 4096 {
httpError(w, http.StatusBadRequest, "bad request")
return
}
- threadID, ok, err := s.db.adoptUserInvite(r.PathValue("id"), uid, body.Wrapped, body.ClaimSecret)
+ threadID, ok, err := s.db.adoptUserInvite(id, uid, body.Wrapped, body.ClaimSecret)
if err != nil {
httpError(w, http.StatusInternalServerError, "db error")
return
diff --git a/tools/lchat-dev-up.mjs b/tools/lchat-dev-up.mjs
index 1cf66e3..d18ad7f 100755
--- a/tools/lchat-dev-up.mjs
+++ b/tools/lchat-dev-up.mjs
@@ -102,24 +102,28 @@ async function waitForServer(base, server) {
throw new Error("server didn't become ready in time");
}
-// Provisions a brand-new "you" account for onboarding mode (fresh instance,
-// no buddies or chats). Returns the invite link.
+// Provisions an admin-granting setup invite for onboarding mode (fresh instance,
+// no buddies or chats). The first person to claim it becomes admin and lands in
+// the setup conversation with Eleven.
async function seedOnboarding(base, adminToken) {
const admin = { "Content-Type": "application/json", Authorization: `Bearer ${adminToken}` };
- const youKp = await generateUserKeypair();
- const youT = randomSecret();
- const youDerived = await deriveInvite(youT);
- const youWrapped = await encrypt(youDerived.wrapKey, { pub: youKp.publicKey, priv: youKp.privateKey });
+ const T = randomSecret();
+ const derived = await deriveInvite(T);
await postJSON(
`${base}/api/admin/key-invites`,
- { id: youDerived.id, wrapped: youWrapped, claim_hash: youDerived.claimHash },
+ {
+ id: derived.id,
+ wrapped: "",
+ claim_hash: derived.claimHash,
+ grants_admin: true,
+ },
admin,
);
return {
- youInviteId: youDerived.id,
- inviteURL: `${base}/join/join#${buildInviteFragment(youT, "You")}`,
+ inviteId: derived.id,
+ inviteURL: `${base}/join/join#${buildInviteFragment(T, "")}`,
};
}
@@ -238,25 +242,34 @@ async function main() {
await waitForServer(base, server);
let seeded;
+ let inviteType;
if (isOnboarding) {
- log("seeding fresh onboarding instance (no buddies or chats)...");
+ log("seeding fresh onboarding instance (setup/admin invite, no buddies or chats)...");
seeded = await seedOnboarding(base, adminToken);
+ inviteType = "SETUP (admin-granting)";
} else {
log("seeding a second member + group chat...");
seeded = await seed(base, adminToken);
patchInviteThread(dbPath, seeded.youInviteId, seeded.threadId);
+ inviteType = "Regular (user invite)";
}
console.log("");
- console.log(`Server: ${base}`);
- console.log(`Admin: ${base}/admin#token=${adminToken}`);
- console.log(`Invite: ${seeded.inviteURL}`);
+ console.log(`Server: ${base}`);
+ console.log(`Admin: ${base}/admin#token=${adminToken}`);
+ console.log(`Invite ID: ${seeded.inviteId || seeded.youInviteId}`);
+ console.log(`Type: ${inviteType}`);
+ console.log(`Link: ${seeded.inviteURL}`);
console.log("");
if (isOnboarding) {
- console.log("Open the invite link to start onboarding. Ctrl-C to stop and clean up.");
+ console.log("📋 Open the invite link to become admin and start onboarding.");
+ console.log(" The /admin command will be available in chat after you join.");
} else {
- console.log("Open the invite link to land straight in the group chat. Ctrl-C to stop and clean up.");
+ console.log("💬 Open the invite link to land straight in the group chat with Dev Buddy.");
}
+ console.log("");
+ console.log("Ctrl-C to stop and clean up.");
+ console.log("");
await new Promise((resolve) => server.on("exit", resolve));
}
diff --git a/web/pages/admin.html b/web/pages/admin.html
index 83d3c62..f9e59bf 100644
--- a/web/pages/admin.html
+++ b/web/pages/admin.html
@@ -140,6 +140,15 @@
<div class="row"><button class="secondary" id="testPush">Send test notification</button></div>
</section>
+ <section>
+ <h2>Delegate admin role</h2>
+ <p class="sub" style="margin:0 0 10px">Create an invite that makes the claimer an admin. They can then access this console with their device token (no need to share your admin link). Use this to share admin duties or to recover access if you lose your token.</p>
+ <form id="createAdminForm" class="inline-form" autocomplete="off">
+ <input id="adminLabel" placeholder="Their name (e.g. Sophie)" maxlength="60" />
+ <button type="submit">Create admin invite</button>
+ </form>
+ </section>
+
<section>
<h2>People</h2>
<div id="bootstrapInvite">
@@ -149,7 +158,7 @@
<button type="submit">Create invite</button>
</form>
</div>
- <p id="inviteGuidance" class="sub hidden" style="margin:0 0 10px">People are added from inside the chat now — turn on “Let members invite other people” above, then use “Invite someone” when starting a new chat. That way the new person is connected the moment they join.</p>
+ <p id="inviteGuidance" class="sub hidden" style="margin:0 0 10px">People are added from inside the chat now — turn on "Let members invite other people" above, then use "Invite someone" when starting a new chat. That way the new person is connected the moment they join.</p>
<div id="people" style="margin-top:16px"><p class="sub">Loading…</p></div>
</section>
</div>
diff --git a/web/static/admin.js b/web/static/admin.js
index 14ade1c..740d2f3 100644
--- a/web/static/admin.js
+++ b/web/static/admin.js
@@ -41,6 +41,7 @@ async function init() {
wireUserInvites();
wireCustomApps();
wireTestPush();
+ wireCreateAdmin();
wireCreate();
wireCreateAgent();
renderUsers((await res.json()).users || []);
@@ -313,6 +314,67 @@ function wireTestPush() {
// ---- invites & people ---------------------------------------------------
+function wireCreateAdmin() {
+ document.getElementById("createAdminForm").onsubmit = async (e) => {
+ e.preventDefault();
+ const label = document.getElementById("adminLabel").value.trim();
+ // Show confirmation before minting the admin invite
+ showAdminConfirmation(label);
+ };
+}
+
+function showAdminConfirmation(label) {
+ const backdrop = document.createElement("div");
+ backdrop.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:9999";
+ const card = document.createElement("div");
+ card.style.cssText = "background:var(--panel);border-radius:12px;padding:28px;max-width:380px;box-shadow:0 10px 40px rgba(0,0,0,0.3)";
+
+ const title = document.createElement("h3");
+ title.textContent = "Create admin invite";
+ title.style.cssText = "margin:0 0 16px;font-size:18px;font-weight:700";
+ card.appendChild(title);
+
+ const warning = document.createElement("div");
+ warning.style.cssText = "background:color-mix(in srgb,#c0392b 10%,transparent);border-left:4px solid #c0392b;padding:12px;border-radius:4px;margin:0 0 16px;font-size:13px;line-height:1.5;color:var(--ink)";
+ warning.innerHTML = "<strong>⚠️ This is powerful</strong><br/>Anyone who claims this link becomes an admin with full control over this server.";
+ card.appendChild(warning);
+
+ const guidance = document.createElement("p");
+ guidance.style.cssText = "margin:0 0 16px;font-size:13px;color:var(--muted);line-height:1.6";
+ guidance.innerHTML = "Use this to:<br/>• Delegate admin duties to someone you trust<br/>• Recover access if you lose your token<br/><br/>Treat this link like a password — keep it private.";
+ card.appendChild(guidance);
+
+ const row = document.createElement("div");
+ row.style.cssText = "display:flex;gap:8px;margin-top:16px";
+
+ const cancelBtn = document.createElement("button");
+ cancelBtn.className = "secondary";
+ cancelBtn.textContent = "Cancel";
+ cancelBtn.onclick = () => backdrop.remove();
+
+ const confirmBtn = document.createElement("button");
+ confirmBtn.textContent = "Create invite";
+ confirmBtn.onclick = async () => {
+ backdrop.remove();
+ confirmBtn.disabled = true;
+ flash(document.getElementById("delegateStatus"), "Creating invite…");
+ const minted = await mintInvite(label, null, false, null, true);
+ confirmBtn.disabled = false;
+ if (!minted) return;
+ document.getElementById("adminLabel").value = "";
+ showInviteModal(minted.link, label ? `Admin invite for ${label}` : "Admin invite", true);
+ loadUsers();
+ };
+
+ row.append(cancelBtn, confirmBtn);
+ card.appendChild(row);
+ backdrop.appendChild(card);
+ backdrop.addEventListener("click", (e) => {
+ if (e.target === backdrop) backdrop.remove();
+ });
+ document.body.appendChild(backdrop);
+}
+
function wireCreate() {
document.getElementById("createForm").onsubmit = async (e) => {
e.preventDefault();
@@ -357,7 +419,7 @@ function wireCreateAgent() {
// Mint a single-use invite: generate the secret T client-side, wrap the group
// key under it, and store only {id, wrapped} server-side. Pass a userId to
// re-issue for an existing person (add/recover a device). Returns the link.
-async function mintInvite(label, userId, agent, config) {
+async function mintInvite(label, userId, agent, config, grantsAdmin) {
// Auth-only invite (threads model): no instance-wide key to wrap — the joiner
// generates their own user keypair on join. See docs/threads-and-dms.md.
const T = randomSecret();
@@ -372,6 +434,7 @@ async function mintInvite(label, userId, agent, config) {
wrapped: "",
claim_hash: claimHash,
agent: !!agent,
+ grants_admin: !!grantsAdmin,
...(config || {}), // model, system_prompt, docs — agent only
});
if (!res.ok) {
diff --git a/web/static/apps/admin.js b/web/static/apps/admin.js
index 0454045..e4e7492 100644
--- a/web/static/apps/admin.js
+++ b/web/static/apps/admin.js
@@ -317,6 +317,91 @@ export const app = defineApp({
} else flash(photoStatus, "Couldn't remove the photo.");
};
+ // ---- Delegate admin role -------------------------------------------------
+ const delegateSec = section("Delegate admin role");
+ const delegateDesc = document.createElement("p");
+ delegateDesc.style.cssText = "font-size:13px;color:var(--muted);margin:0 0 12px";
+ delegateDesc.textContent = "Create a link someone can use to join as admin, or to claim admin role on their existing account.";
+ delegateSec.appendChild(delegateDesc);
+ const delegateRow = document.createElement("div");
+ delegateRow.className = "admin-row admin-row-end";
+ const delegateBtn = document.createElement("button");
+ delegateBtn.textContent = "Create admin invite";
+ delegateRow.appendChild(delegateBtn);
+ delegateSec.appendChild(delegateRow);
+ const delegateStatus = statusLine(delegateSec);
+
+ delegateBtn.onclick = async () => {
+ delegateBtn.disabled = true;
+ flash(delegateStatus, "Creating invite…");
+ const result = await bridge.createAdminInvite();
+ delegateBtn.disabled = false;
+ if (!result) {
+ flash(delegateStatus, "Couldn't create invite — try again.");
+ return;
+ }
+ flash(delegateStatus, "");
+ showInviteModal(result.link, "Admin invite");
+ };
+
+ // Modal for displaying invite link (QR + link, same as the web admin console)
+ const showInviteModal = (link, title) => {
+ const { qrCanvas } = ctx.imports || {};
+ const backdrop = document.createElement("div");
+ backdrop.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:9999";
+ const card = document.createElement("div");
+ card.style.cssText = "background:var(--panel);border-radius:12px;padding:24px;max-width:320px;box-shadow:0 10px 40px rgba(0,0,0,0.3)";
+ const h = document.createElement("h3");
+ h.textContent = title || "Admin invite";
+ h.style.cssText = "margin:0 0 12px;font-size:18px;font-weight:700";
+ card.appendChild(h);
+ if (qrCanvas) {
+ const qrDiv = document.createElement("div");
+ qrDiv.style.cssText = "text-align:center;margin:16px 0";
+ try {
+ qrDiv.appendChild(qrCanvas(link, { scale: 6, margin: 4 }));
+ } catch {
+ qrDiv.textContent = "Link too long for QR";
+ }
+ card.appendChild(qrDiv);
+ }
+ const linkBox = document.createElement("div");
+ linkBox.style.cssText = "background:color-mix(in srgb,var(--ink) 5%,transparent);border-radius:8px;padding:12px;font-size:11px;font-family:monospace;word-break:break-all;color:var(--muted);margin:12px 0;cursor:pointer";
+ linkBox.textContent = link;
+ linkBox.title = "Tap to select";
+ linkBox.onclick = () => {
+ const range = document.createRange();
+ range.selectNodeContents(linkBox);
+ window.getSelection().removeAllRanges();
+ window.getSelection().addRange(range);
+ };
+ card.appendChild(linkBox);
+ const btnRow = document.createElement("div");
+ btnRow.style.cssText = "display:flex;gap:8px;margin-top:16px";
+ const copyBtn = document.createElement("button");
+ copyBtn.textContent = "Copy";
+ copyBtn.onclick = async () => {
+ try {
+ await navigator.clipboard.writeText(link);
+ copyBtn.textContent = "Copied ✓";
+ setTimeout(() => (copyBtn.textContent = "Copy"), 2000);
+ } catch {
+ copyBtn.textContent = "Select above to copy";
+ }
+ };
+ const closeBtn = document.createElement("button");
+ closeBtn.className = "secondary";
+ closeBtn.textContent = "Done";
+ closeBtn.onclick = () => backdrop.remove();
+ btnRow.append(copyBtn, closeBtn);
+ card.appendChild(btnRow);
+ backdrop.appendChild(card);
+ document.body.appendChild(backdrop);
+ backdrop.addEventListener("click", (e) => {
+ if (e.target === backdrop) backdrop.remove();
+ });
+ };
+
// Deliberately NOT here: inviting people (part of the main chat experience —
// the + menu) and the member list (people show up in New chat). The panel is
// for what ONLY an admin can touch.
diff --git a/web/static/join.js b/web/static/join.js
index ef3ea5b..035935c 100644
--- a/web/static/join.js
+++ b/web/static/join.js
@@ -136,19 +136,45 @@ async function start() {
});
}
- // Setup invite (a fresh instance's first user, who becomes its admin): no name
- // form — the claim lands straight in the chat view, where the Eleven setup
- // conversation asks for a name instead. See docs/conversational-onboarding.md.
+ // Setup invite (admin-granting): if already logged in, promote the existing
+ // account. Otherwise, create a new admin account.
if (info.grants_admin) {
- return showWelcome("setup", async () => {
+ const existing = window.__lchatEmbed ? null : loadSession();
+ if (existing && existing.token && existing.userPub && existing.userPriv) {
+ // Already a member: promote to admin on existing account.
+ return showWelcome("adopt-admin", () => {
+ showStatus("Promoting to admin…");
+ adoptAdminExisting(id, existing);
+ });
+ }
+ // New person becoming admin: check if this instance's setup is already complete.
+ // If so, skip the "setup" flow label and just proceed normally — the app won't
+ // trigger setup conversation if setup_complete is true.
+ let setupNeeded = true;
+ try {
+ const me = await (await fetch("/api/me")).json();
+ setupNeeded = !me.setup_complete;
+ } catch {}
+ const setupLabel = setupNeeded ? "setup" : "join";
+ return showWelcome(setupLabel, async () => {
showStatus("Setting up…");
const kp = await generateUserKeypair();
finish(id, "", kp.publicKey, kp.privateKey);
});
}
- // New person (or unknown payload shape): same chat-first dance — the name is
- // Eleven's first question, not a form (native add-chat keeps its form).
+ // New person (or unknown payload shape): if already logged in, adopt this
+ // invite as your existing self. Otherwise, start a new account.
+ const existing = window.__lchatEmbed ? null : loadSession();
+ if (existing && existing.token && existing.userPub && existing.userPriv) {
+ // Already a member: adopt this invite as your existing self (no wrapped key needed).
+ return showWelcome("adopt", () => {
+ showStatus("Opening your chat…");
+ adoptInviteExisting(id, existing);
+ });
+ }
+ // New person: same chat-first dance — the name is Eleven's first question,
+ // not a form (native add-chat keeps its form).
if (window.__lchatEmbed) {
return showWelcome("join", () =>
showForm(fragName, async (name) => {
@@ -199,10 +225,18 @@ function showWelcome(kind, onContinue) {
card.querySelector("#welcome-caveat").remove();
card.querySelector("#welcome-web").textContent = "Sign in here in the browser";
} else if (kind === "adopt") {
+ const groupName = document.title.replace("You're invited to ", "");
+ card.querySelector("#welcome-title").textContent = `You're already in ${groupName}`;
card.querySelector("#welcome-sub").textContent =
- "You're already in this chat on this device — continue to open the new conversation.";
+ "Continue to open the new conversation on this device.";
card.querySelector("#welcome-caveat").remove();
card.querySelector("#welcome-web").textContent = "Continue";
+ } else if (kind === "adopt-admin") {
+ card.querySelector("#welcome-title").textContent = "Become admin";
+ card.querySelector("#welcome-sub").textContent =
+ "You're already a member on this device. Continue to accept the admin role.";
+ card.querySelector("#welcome-caveat").remove();
+ card.querySelector("#welcome-web").textContent = "Accept";
} else if (kind === "setup") {
card.querySelector("#welcome-title").textContent = "Welcome to your new space";
card.querySelector("#welcome-sub").textContent =
@@ -306,6 +340,50 @@ async function adoptExisting(id, threadKeyB64, existing) {
}
}
+// adoptAdminExisting claims an admin-granting invite on an existing account,
+// promoting the existing member to admin without creating a new user account.
+async function adoptAdminExisting(id, existing) {
+ try {
+ const r = await (
+ await fetch(`/api/key-invite/${encodeURIComponent(id)}/adopt`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: existing.token, wrapped: "", claim_secret: inviteClaimSecret }),
+ })
+ ).json();
+ if (!r.ok) {
+ return showError("This invite has been used or is no longer valid. Ask for a new one.");
+ }
+ // Token stays the same (existing session is preserved). Just redirect back to app.
+ await plugIn();
+ location.href = "/";
+ } catch {
+ showError("Couldn't reach the server. Try again.");
+ }
+}
+
+// adoptInviteExisting claims a generic invite (no wrapped payload) on an existing
+// account, letting them adopt it as their existing self without creating a new user.
+async function adoptInviteExisting(id, existing) {
+ try {
+ const r = await (
+ await fetch(`/api/key-invite/${encodeURIComponent(id)}/adopt`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: existing.token, wrapped: "", claim_secret: inviteClaimSecret }),
+ })
+ ).json();
+ if (!r.ok) {
+ return showError("This invite has been used or is no longer valid. Ask for a new one.");
+ }
+ if (r.thread_id) localStorage.setItem("lchat_open_thread", r.thread_id);
+ await plugIn();
+ location.href = "/";
+ } catch {
+ showError("Couldn't reach the server. Try again.");
+ }
+}
+
function loadSession() {
try {
return JSON.parse(localStorage.getItem(SESSION_KEY) || "null");
diff --git a/web/static/shell.js b/web/static/shell.js
index 83252f0..484d9d6 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -3032,6 +3032,37 @@ setSetupBridge({
return false;
}
},
+ // Create an admin-granting invite. The claimer can either join as a new
+ // member or claim it on their existing account to get promoted to admin.
+ // Returns {link} or null on failure.
+ createAdminInvite: async () => {
+ try {
+ const { randomSecret, deriveInvite } = await import("/static/crypto.js");
+ const { buildInviteFragment } = await import("/static/invite.js");
+ const T = randomSecret();
+ const { id, claimHash } = await deriveInvite(T);
+ const res = await fetch("/api/admin/key-invites", {
+ method: "POST",
+ headers: adminAuth(),
+ body: JSON.stringify({
+ label: "",
+ user_id: "",
+ id,
+ wrapped: "",
+ claim_hash: claimHash,
+ agent: false,
+ grants_admin: true,
+ }),
+ });
+ if (!res.ok) return null;
+ const data = await res.json();
+ if (!data.ok) return null;
+ const frag = buildInviteFragment(T, "");
+ return { link: `${location.origin}/join/join#${frag}` };
+ } catch {
+ return null;
+ }
+ },
});
// First run: the instance's admin lands in a conversation with Eleven that walks