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>
3 files changed,
+31
−4
changelog/2026-07-26-expired-invite-sign-in.md+4 −0web/pages/join.html+12 −0web/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(); |
| } |