Merge branch 'dev' into main

Resolve login-button.ts conflicts — take dev's cleaner formatting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-25 12:23:21 -07:00
commit 037182efdf
9 changed files with 179 additions and 85 deletions

View File

@ -182,7 +182,10 @@ class FolkInboxClient extends HTMLElement {
private async loadMailboxes() {
try {
const base = window.location.pathname.replace(/\/$/, "");
const resp = await fetch(`${base}/api/mailboxes`);
const token = getAccessToken();
const resp = await fetch(`${base}/api/mailboxes`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (resp.ok) {
const data = await resp.json();
this.mailboxes = data.mailboxes || [];

View File

@ -157,7 +157,7 @@
.cw-btn:hover {
border-color: var(--rs-accent, #14b8a6);
background: var(--rs-surface-hover, #2a2a40);
background: var(--rs-bg-hover);
}
.cw-btn:disabled {

View File

@ -75,26 +75,26 @@ folk-campaign-workflow {
.cw-btn--back {
text-decoration: none;
color: #93c5fd;
border-color: #3b82f644;
color: var(--rs-primary-hover, #6366f1);
border-color: var(--rs-border, rgba(59,130,246,0.27));
display: inline-flex;
align-items: center;
gap: 4px;
}
.cw-btn--back:hover {
background: #3b82f622;
border-color: #3b82f6;
background: var(--rs-bg-active, rgba(59,130,246,0.13));
border-color: var(--rs-primary, #4f46e5);
}
.cw-btn--run {
background: #3b82f622;
border-color: #3b82f655;
color: #60a5fa;
background: var(--rs-bg-active, rgba(59,130,246,0.13));
border-color: var(--rs-border, rgba(59,130,246,0.33));
color: var(--rs-primary-hover, #6366f1);
}
.cw-btn--run:hover {
background: #3b82f633;
border-color: #3b82f6;
background: var(--rs-bg-active, rgba(59,130,246,0.2));
border-color: var(--rs-primary, #4f46e5);
}
.cw-toggle {

View File

@ -926,7 +926,13 @@ export function renderShell(opts: ShellOptions): string {
const sp = document.querySelector('rstack-space-settings');
if (sp) sp.setAttribute('module-id', moduleId);
if (tabCache) {
const switchId = moduleId; // capture for staleness check
tabCache.switchTo(moduleId).then(ok => {
// If user already clicked a different tab, don't navigate for this one
if (currentModuleId !== switchId) {
console.log('[shell] switchTo result:', ok, 'for', switchId, '(stale, user switched to', currentModuleId + ')');
return;
}
console.log('[shell] switchTo result:', ok, 'for', moduleId);
if (ok) {
tabBar.setAttribute('active', layerId);
@ -936,6 +942,7 @@ export function renderShell(opts: ShellOptions): string {
window.location.href = url;
}
}).catch((err) => {
if (currentModuleId !== switchId) return; // stale
console.error('[shell] switchTo error:', err);
window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId);
});
@ -1849,7 +1856,7 @@ const SUBNAV_CSS = `
background: var(--rs-bg-surface);
scrollbar-width: none;
position: sticky;
top: 92px;
top: 93px;
z-index: 100;
}
.rapp-subnav::-webkit-scrollbar { display: none; }
@ -1949,7 +1956,7 @@ const TABBAR_CSS = `
z-index: 100;
}
.rapp-tabbar::-webkit-scrollbar { display: none; }
.rapp-subnav + .rapp-tabbar { top: 129px; }
.rapp-subnav + .rapp-tabbar { top: 130px; }
@media (max-width: 640px) {
.rapp-tabbar {
position: relative;

View File

@ -394,18 +394,28 @@ export class RStackIdentity extends HTMLElement {
async #validateSessionWithServer() {
const session = getSession();
if (!session?.accessToken) return;
// Throttle: skip if validated within the last 5 minutes
const VALIDATE_KEY = "eid_last_validated";
const VALIDATE_INTERVAL = 5 * 60 * 1000;
const lastValidated = parseInt(localStorage.getItem(VALIDATE_KEY) || "0", 10);
if (Date.now() - lastValidated < VALIDATE_INTERVAL) return;
try {
const res = await fetch(`${ENCRYPTID_URL}/api/session/verify`, {
headers: { Authorization: `Bearer ${session.accessToken}` },
});
if (!res.ok) {
// Session revoked — clear locally and re-render
localStorage.removeItem(VALIDATE_KEY);
localStorage.removeItem(SESSION_KEY);
localStorage.removeItem("rspace-username");
_removeSessionCookie();
resetDocBridge();
this.#render();
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true }));
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true, detail: { reason: "revoked" } }));
} else {
localStorage.setItem(VALIDATE_KEY, String(Date.now()));
}
} catch { /* network error — let token expire naturally */ }
}
@ -470,7 +480,7 @@ export class RStackIdentity extends HTMLElement {
.eid-nudge-link-row { display: flex; gap: 6px; margin-bottom: 0.4rem; }
.eid-nudge-link-input {
flex: 1; font-size: 0.72rem; padding: 5px 8px; border-radius: 6px;
border: 1px solid var(--rs-border, #334155); background: var(--rs-bg-inset, #0f172a);
border: 1px solid var(--rs-border, #334155); background: var(--rs-input-bg, #0f172a);
color: var(--rs-text-secondary, #94a3b8); min-width: 0; outline: none;
}
.eid-nudge-btn--copy {
@ -548,7 +558,7 @@ export class RStackIdentity extends HTMLElement {
if (e.key === "encryptid_session" || e.key === PERSONAS_KEY) {
this.#render();
if (e.key === "encryptid_session") {
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true }));
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true, detail: { reason: e.newValue ? "signin" : "signout" } }));
// Session cleared from another tab — reload to show logged-out state
if (!e.newValue) window.location.reload();
}
@ -595,7 +605,7 @@ export class RStackIdentity extends HTMLElement {
const payload = parseJWT(newToken);
storeSession(newToken, (payload.username as string) || refreshData.username || username, (payload.did as string) || did);
this.#render();
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true }));
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true, detail: { reason: "refresh" } }));
}
} catch { /* offline — keep whatever we have */ }
}
@ -684,9 +694,7 @@ export class RStackIdentity extends HTMLElement {
if (action === "signout") {
clearSession();
resetDocBridge();
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true }));
// Reload so the server re-renders the current rApp in logged-out mode
window.location.reload();
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true, detail: { reason: "signout" } }));
return;
} else if (action === "my-account") {
this.showAccountModal();
@ -715,7 +723,7 @@ export class RStackIdentity extends HTMLElement {
clearSession();
resetDocBridge();
this.#render();
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true }));
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true, detail: { reason: "persona-switch" } }));
this.showAuthModal();
} else if (action === "remove-persona") {
const targetDid = (el as HTMLElement).dataset.did || "";
@ -879,7 +887,7 @@ export class RStackIdentity extends HTMLElement {
storeSession(data.token, data.username || "", data.did || "");
close();
this.#render();
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true }));
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true, detail: { reason: "signin" } }));
callbacks?.onSuccess?.();
// Auto-redirect to personal space
autoResolveSpace(data.token, data.username || "");
@ -956,7 +964,7 @@ export class RStackIdentity extends HTMLElement {
storeSession(data.token, username, data.did || "");
close();
this.#render();
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true }));
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true, detail: { reason: "signin" } }));
callbacks?.onSuccess?.();
// Show post-signup prompt recommending second device before redirecting
this.#showPostSignupPrompt(data.token, username);

View File

@ -17,6 +17,7 @@ export class TabCache {
private currentModuleId: string;
private spaceSlug: string;
private panes = new Map<string, HTMLElement>();
private fetchController: AbortController | null = null;
constructor(spaceSlug: string, moduleId: string) {
this.spaceSlug = spaceSlug;
@ -100,6 +101,12 @@ export class TabCache {
/** Switch to a module tab within the current space. Returns true if handled client-side. */
async switchTo(moduleId: string): Promise<boolean> {
// Abort any in-flight fetch from a previous switchTo call
if (this.fetchController) {
this.fetchController.abort();
this.fetchController = null;
}
const key = this.paneKey(this.spaceSlug, moduleId);
if (moduleId === this.currentModuleId && this.panes.has(key)) {
console.log("[TabCache] switchTo", moduleId, "→ already current + cached");
@ -199,18 +206,25 @@ export class TabCache {
this.hideAllPanes();
app.appendChild(loadingPane);
// Create an abort controller that also times out after 15s
const controller = new AbortController();
this.fetchController = controller;
const timeoutId = setTimeout(() => controller.abort(), 15_000);
try {
const resp = await fetch(fetchUrl, {
headers: { "Accept": "text/html" },
signal: AbortSignal.timeout(10_000),
signal: controller.signal,
});
if (!resp.ok) {
clearTimeout(timeoutId);
console.warn("[TabCache] fetchAndInject: HTTP", resp.status, "for", fetchUrl);
loadingPane.remove();
return false;
}
const html = await resp.text();
clearTimeout(timeoutId);
console.log("[TabCache] fetchAndInject: got", html.length, "bytes for", moduleId);
const content = this.extractContent(html);
if (!content) {
@ -247,8 +261,16 @@ export class TabCache {
console.log("[TabCache] fetchAndInject: SUCCESS for", moduleId);
return true;
} catch (err) {
console.error("[TabCache] fetchAndInject: CATCH for", moduleId, err);
clearTimeout(timeoutId);
loadingPane.remove();
// If aborted because the user switched to a different tab, return
// "true" so the shell doesn't trigger a fallback full-page navigation
// for a tab the user no longer wants.
if (controller.signal.aborted && this.fetchController !== controller) {
console.log("[TabCache] fetchAndInject: aborted (superseded) for", moduleId);
return true;
}
console.error("[TabCache] fetchAndInject: CATCH for", moduleId, err);
return false;
}
}

View File

@ -461,7 +461,7 @@ app.use('*', cors({
}
return undefined;
},
allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
credentials: true,
}));
@ -7141,21 +7141,8 @@ app.get('/', (c) => {
<div id="error-msg" class="error"></div>
<div id="success-msg" class="success"></div>
<!-- Sign-in mode (default) passkey-first -->
<div id="signin-fields">
<button class="btn-primary" id="auth-btn" onclick="handleAuth()" style="font-size:1.1rem;padding:0.9rem">&#128273; Sign in with Passkey</button>
<div style="text-align:center;margin-top:0.75rem">
<a href="#" id="show-email-fallback" onclick="toggleEmailFallback(); return false;" style="color:#64748b;font-size:0.8rem;text-decoration:none">No passkey on this device? Sign in with email</a>
</div>
<div id="email-fallback-section" style="display:none;margin-top:0.75rem;border-top:1px solid rgba(255,255,255,0.08);padding-top:0.75rem">
<div class="form-group">
<label for="signin-email">Email address</label>
<input id="signin-email" type="email" placeholder="you@example.com" autocomplete="email" />
</div>
<button class="btn-secondary" id="magic-link-btn" onclick="sendMagicLink()">Send magic link</button>
<div id="magic-link-msg" style="font-size:0.8rem;margin-top:0.5rem;display:none"></div>
</div>
</div>
<!-- Sign-in mode (default) dynamically rendered by renderSigninAccounts() -->
<div id="signin-fields"></div>
<!-- Registration stepper (hidden until register tab) -->
<div id="register-stepper" style="display:none">
@ -7422,6 +7409,85 @@ app.get('/', (c) => {
} from '/dist/index.js';
const TOKEN_KEY = 'encryptid_token';
const KNOWN_ACCOUNTS_KEY = 'encryptid-known-accounts';
function getKnownAccounts() {
try { return JSON.parse(localStorage.getItem(KNOWN_ACCOUNTS_KEY) || '[]'); }
catch { return []; }
}
function addKnownAccount(username, displayName) {
const accounts = getKnownAccounts().filter(a => a.username !== username);
accounts.unshift({ username, displayName: displayName || username });
localStorage.setItem(KNOWN_ACCOUNTS_KEY, JSON.stringify(accounts));
}
// Render known accounts into sign-in fields on load
function renderSigninAccounts() {
const container = document.getElementById('signin-fields');
const accounts = getKnownAccounts();
if (accounts.length === 0) {
// No known accounts — passkey-first button
container.innerHTML = \`
<button class="btn-primary" id="auth-btn" onclick="handleAuth()" style="font-size:1.1rem;padding:0.9rem">&#128273; Sign in with Passkey</button>
<div style="text-align:center;margin-top:0.75rem">
<a href="#" id="show-email-fallback" onclick="toggleEmailFallback(); return false;" style="color:#64748b;font-size:0.8rem;text-decoration:none">No passkey on this device? Sign in with email</a>
</div>
<div id="email-fallback-section" style="display:none;margin-top:0.75rem;border-top:1px solid rgba(255,255,255,0.08);padding-top:0.75rem">
<div class="form-group">
<label for="signin-email">Email address</label>
<input id="signin-email" type="email" placeholder="you@example.com" autocomplete="email" />
</div>
<button class="btn-secondary" id="magic-link-btn" onclick="sendMagicLink()">Send magic link</button>
<div id="magic-link-msg" style="font-size:0.8rem;margin-top:0.5rem;display:none"></div>
</div>\`;
} else if (accounts.length === 1) {
const esc = s => s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
const name = esc(accounts[0].displayName || accounts[0].username);
container.innerHTML = \`
<button class="btn-primary" id="auth-btn" onclick="handleAuth('\${esc(accounts[0].username)}')" style="font-size:1.1rem;padding:0.9rem">&#128273; Sign in as \${name}</button>
<div style="text-align:center;margin-top:0.5rem">
<a href="#" onclick="handleAuth(); return false;" style="color:#64748b;font-size:0.8rem;text-decoration:none">Use a different account</a>
</div>
<div style="text-align:center;margin-top:0.25rem">
<a href="#" id="show-email-fallback" onclick="toggleEmailFallback(); return false;" style="color:#64748b;font-size:0.8rem;text-decoration:none">Sign in with email instead</a>
</div>
<div id="email-fallback-section" style="display:none;margin-top:0.75rem;border-top:1px solid rgba(255,255,255,0.08);padding-top:0.75rem">
<div class="form-group">
<label for="signin-email">Email address</label>
<input id="signin-email" type="email" placeholder="you@example.com" autocomplete="email" />
</div>
<button class="btn-secondary" id="magic-link-btn" onclick="sendMagicLink()">Send magic link</button>
<div id="magic-link-msg" style="font-size:0.8rem;margin-top:0.5rem;display:none"></div>
</div>\`;
} else {
const esc = s => s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
const items = accounts.map(a => {
const name = esc(a.displayName || a.username);
const initial = esc((a.displayName || a.username).slice(0, 2).toUpperCase());
return \`<button class="btn-secondary" onclick="handleAuth('\${esc(a.username)}')" style="display:flex;align-items:center;gap:0.75rem;padding:0.75rem">
<span style="width:28px;height:28px;border-radius:50%;background:#7c3aed;display:flex;align-items:center;justify-content:center;font-weight:600;font-size:0.75rem;color:#fff;flex-shrink:0">\${initial}</span>
<span>\${name}</span>
</button>\`;
}).join('');
container.innerHTML = \`
<div style="display:flex;flex-direction:column;gap:0.5rem">\${items}</div>
<div style="text-align:center;margin-top:0.5rem">
<a href="#" onclick="handleAuth(); return false;" style="color:#64748b;font-size:0.8rem;text-decoration:none">Use a different account</a>
</div>
<div style="text-align:center;margin-top:0.25rem">
<a href="#" id="show-email-fallback" onclick="toggleEmailFallback(); return false;" style="color:#64748b;font-size:0.8rem;text-decoration:none">Sign in with email instead</a>
</div>
<div id="email-fallback-section" style="display:none;margin-top:0.75rem;border-top:1px solid rgba(255,255,255,0.08);padding-top:0.75rem">
<div class="form-group">
<label for="signin-email">Email address</label>
<input id="signin-email" type="email" placeholder="you@example.com" autocomplete="email" />
</div>
<button class="btn-secondary" id="magic-link-btn" onclick="sendMagicLink()">Send magic link</button>
<div id="magic-link-msg" style="font-size:0.8rem;margin-top:0.5rem;display:none"></div>
</div>\`;
}
}
let currentMode = 'signin';
// Registration stepper state
@ -7692,19 +7758,19 @@ app.get('/', (c) => {
}
};
// handleAuth — always unscoped passkey picker (browser shows all stored passkeys)
window.handleAuth = async () => {
// handleAuth — optionally scoped to a known username, unscoped if omitted
window.handleAuth = async (username) => {
const btn = document.getElementById('auth-btn');
btn.disabled = true;
btn.textContent = 'Waiting for passkey...';
if (btn) { btn.disabled = true; btn.textContent = 'Waiting for passkey...'; }
hideMessages();
try {
// Unscoped auth — let the browser show all available passkeys
// If username provided, scope credentials to that user; otherwise unscoped
const authBody = username ? { username } : {};
const startRes = await fetch('/api/auth/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
body: JSON.stringify(authBody),
});
if (!startRes.ok) throw new Error('Failed to start authentication');
const { options: serverOptions, prfSalt } = await startRes.json();
@ -7782,18 +7848,22 @@ app.get('/', (c) => {
}
}
// Remember this account for next login
if (data.username) addKnownAccount(data.username, data.username);
showProfile(data.token, data.username, data.did);
} catch (err) {
if (err.name === 'NotAllowedError') {
showError('No passkey found on this device. Use email to sign in.');
// Auto-show email fallback
document.getElementById('email-fallback-section').style.display = 'block';
document.getElementById('show-email-fallback').style.display = 'none';
const fb = document.getElementById('email-fallback-section');
const link = document.getElementById('show-email-fallback');
if (fb) fb.style.display = 'block';
if (link) link.style.display = 'none';
} else {
showError(err.message || 'Authentication failed');
}
btn.innerHTML = '&#128273; Sign in with Passkey';
btn.disabled = false;
if (btn) { btn.innerHTML = '&#128273; Sign in with Passkey'; btn.disabled = false; }
}
};
@ -8334,8 +8404,10 @@ app.get('/', (c) => {
}
}
// On page load: handle query params and check for existing token
// On page load: render known accounts + handle query params + check token
(async () => {
renderSigninAccounts();
const params = new URLSearchParams(location.search);
if (params.get('tab') === 'register') switchTab('register');

View File

@ -361,25 +361,6 @@ const styles = `
opacity: 0.7;
}
.email-fallback-link {
display: block;
margin-top: 10px;
padding: 0;
background: none;
border: none;
color: var(--eid-text-secondary);
font-size: 0.8rem;
cursor: pointer;
text-align: center;
width: 100%;
font-family: inherit;
}
.email-fallback-link:hover {
color: var(--eid-primary);
text-decoration: underline;
}
.email-fallback-section {
display: flex;
flex-direction: column;
@ -536,7 +517,7 @@ export class EncryptIDLoginButton extends HTMLElement {
${PASSKEY_ICON}
<span>${this.label}</span>
</button>
${this.showEmailFallback ? '' : `<button class="email-fallback-link" data-action="show-email">No passkey? Sign in with email</button>`}
${this.showEmailFallback ? '' : `<button class="alt-action" data-action="show-email">No passkey? Sign in with email</button>`}
${emailSection}
</div>
`;
@ -641,22 +622,18 @@ export class EncryptIDLoginButton extends HTMLElement {
this.render();
});
// Send magic link button
// Send magic link button + enter key
this.shadow.querySelector('[data-action="send-magic-link"]')?.addEventListener('click', () => {
this.sendMagicLink();
});
// Email input enter key
const emailInput = this.shadow.querySelector('[data-email-input]') as HTMLInputElement;
if (emailInput) {
emailInput.addEventListener('keydown', (e) => {
if ((e as KeyboardEvent).key === 'Enter') this.sendMagicLink();
});
}
emailInput?.addEventListener('keydown', (e) => {
if ((e as KeyboardEvent).key === 'Enter') this.sendMagicLink();
});
// Login button with scoped username (1 known account)
const loginBtn = this.shadow.querySelector('.login-btn:not([data-action])');
if (loginBtn && !usernameInput) {
if (loginBtn) {
loginBtn.addEventListener('click', () => {
const username = (loginBtn as HTMLElement).dataset.username;
this.handleLogin(username);

View File

@ -117,13 +117,18 @@ if (spaceSlug) {
}
// Reload space list when user signs in/out (to show/hide private spaces)
document.addEventListener("auth-change", () => {
document.addEventListener("auth-change", (e) => {
const reason = (e as CustomEvent).detail?.reason;
// Token refreshes are invisible — no UI side-effects needed
if (reason === "refresh") return;
// Reload space switcher on state-changing events
const spaceSwitcher = document.querySelector("rstack-space-switcher") as any;
spaceSwitcher?.reload?.();
// If signed out, redirect to homepage
const session = localStorage.getItem("encryptid_session");
if (!session) {
// Only redirect to homepage on genuine sign-out or server revocation
if (reason === "signout" || reason === "revoked") {
window.location.href = "/";
}
});