eleven / messenger Public

web: dead invite links offer the sign-in door instead of a dead end (#1357)

A returning member whose local session is gone (cleared browser, new
device) often re-opens their old invite link — and got a terminal
"ask for a new one" card. The error card now reveals an
"Already a member? Sign in" button pointing at the home sign-in page,
opt-in per showError call site (the three dead-link errors; transient
network errors keep the plain card). Hidden in the native add-chat
webview (it owns navigation) and on self-hosts with no home origin.


Claude-Session: https://claude.ai/code/session_01VonpkALPw4ryZ6DXGy6tsS
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell bbdf18e256bd939e0a73b831347bae70d32a222d parent 423fbb2
3 files changed, +31 −4
  • changelog/2026-07-26-expired-invite-sign-in.md +4 −0
  • web/pages/join.html +12 −0
  • web/static/join.js +15 −4
diff --git a/changelog/2026-07-26-expired-invite-sign-in.md b/changelog/2026-07-26-expired-invite-sign-in.md
new file mode 100644
index 0000000..6957cbd
--- /dev/null
+++ b/changelog/2026-07-26-expired-invite-sign-in.md
@@ -0,0 +1,4 @@
+🤖 A dead invite link is no longer a dead end. If a link has expired or already
+been used, the error page now offers "Already a member? Sign in" — so if you
+were using an old link to get back to your space, your account can let you back
+in without asking anyone for a new invite.
diff --git a/web/pages/join.html b/web/pages/join.html
index aed5036..406a238 100644
--- a/web/pages/join.html
+++ b/web/pages/join.html
@@ -24,6 +24,12 @@
if (navigator.userAgent.includes("lchatMac"))
document.documentElement.classList.add("native");
</script>
+<!-- The home server (sign-in / passkey backup; server-configured via
+ LCHAT_HOME_ORIGIN — same substitution as index.html). The error card's
+ "Sign in" door reads it; the origin is public, so exposing it is fine. -->
+<script nonce="__CSP_NONCE__">
+ window.__homeOrigin = "__HOME_ORIGIN__";
+</script>
<link rel="stylesheet" href="/static/style.css" />
<!-- Same identity as the chat root, so "Add to Home Screen" from here installs as
__GROUP_NAME__ (not a generic "Join") and launches at the app's start_url ("/"). -->
@@ -99,6 +105,12 @@
<template id="error-tpl">
<h1>This link doesn't work</h1>
<p id="error-msg">This invite link is invalid or has been turned off. Ask for a new one.</p>
+ <!-- Revealed by showError({signin}) for dead-invite errors on the web: a
+ returning member whose session is gone doesn't need a new invite — their
+ account gets them back in via the home sign-in page. Stays hidden in the
+ native add-chat webview (it owns navigation and expects a join) and on
+ self-hosts with no home server (no sign-in to offer). -->
+ <div class="row"><button id="error-signin" class="secondary hidden">Already a member? Sign in</button></div>
</template>
<!-- The welcome card (deferred claim — see docs/onboarding-and-home-server.md).
diff --git a/web/static/join.js b/web/static/join.js
index ffd6494..b07160e 100644
--- a/web/static/join.js
+++ b/web/static/join.js
@@ -71,7 +71,7 @@ function init() {
// Already installed? The link in this URL is likely spent — go to the app,
// which signs in via paste-to-join.
if (isStandalone && !window.__lchatEmbed) return location.replace("/");
- if (!T) return showError("This link is missing its secret.");
+ if (!T) return showError("This link is missing its secret.", { signin: true });
start();
}
@@ -114,7 +114,7 @@ async function start() {
// owns navigation, and its shell expects a join, not a redirect.
const existing = window.__lchatEmbed ? null : await liveSession();
if (existing) return location.replace("/");
- return showError("This invite has been used or is no longer valid. Ask for a new one.");
+ return showError("This invite has been used or is no longer valid. Ask for a new one.", { signin: true });
}
// The space's description rides on the invite lookup (holders of a valid link
@@ -180,7 +180,7 @@ async function start() {
try {
payload = await decrypt(wrapKey, info.wrapped);
} catch {
- return showError("Couldn't unlock this link (wrong or corrupt secret).");
+ return showError("Couldn't unlock this link (wrong or corrupt secret).", { signin: true });
}
}
@@ -597,11 +597,22 @@ async function plugIn() {
await new Promise((r) => setTimeout(r, 300));
}
-function showError(msg) {
+function showError(msg, opts = {}) {
card.classList.remove("onboard", "status");
card.innerHTML = "";
card.appendChild(document.getElementById("error-tpl").content.cloneNode(true));
card.querySelector("#error-msg").textContent = msg;
+ // A dead invite can be a returning member's stale bookmark on a device whose
+ // session is gone — they don't need a new invite, they need the sign-in door
+ // (their account restores their spaces from home). Opt-in per call site so
+ // unrelated errors ("couldn't reach the server") don't grow a misleading CTA.
+ // The startsWith guard also covers a dev page served without substitution.
+ const home = window.__homeOrigin || "";
+ if (opts.signin && !window.__lchatEmbed && home.startsWith("http")) {
+ const btn = card.querySelector("#error-signin");
+ btn.classList.remove("hidden");
+ btn.onclick = () => { location.href = home; };
+ }
showCard();
}