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:
commit
037182efdf
|
|
@ -182,7 +182,10 @@ class FolkInboxClient extends HTMLElement {
|
||||||
private async loadMailboxes() {
|
private async loadMailboxes() {
|
||||||
try {
|
try {
|
||||||
const base = window.location.pathname.replace(/\/$/, "");
|
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) {
|
if (resp.ok) {
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
this.mailboxes = data.mailboxes || [];
|
this.mailboxes = data.mailboxes || [];
|
||||||
|
|
|
||||||
|
|
@ -157,7 +157,7 @@
|
||||||
|
|
||||||
.cw-btn:hover {
|
.cw-btn:hover {
|
||||||
border-color: var(--rs-accent, #14b8a6);
|
border-color: var(--rs-accent, #14b8a6);
|
||||||
background: var(--rs-surface-hover, #2a2a40);
|
background: var(--rs-bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cw-btn:disabled {
|
.cw-btn:disabled {
|
||||||
|
|
|
||||||
|
|
@ -75,26 +75,26 @@ folk-campaign-workflow {
|
||||||
|
|
||||||
.cw-btn--back {
|
.cw-btn--back {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: #93c5fd;
|
color: var(--rs-primary-hover, #6366f1);
|
||||||
border-color: #3b82f644;
|
border-color: var(--rs-border, rgba(59,130,246,0.27));
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
.cw-btn--back:hover {
|
.cw-btn--back:hover {
|
||||||
background: #3b82f622;
|
background: var(--rs-bg-active, rgba(59,130,246,0.13));
|
||||||
border-color: #3b82f6;
|
border-color: var(--rs-primary, #4f46e5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cw-btn--run {
|
.cw-btn--run {
|
||||||
background: #3b82f622;
|
background: var(--rs-bg-active, rgba(59,130,246,0.13));
|
||||||
border-color: #3b82f655;
|
border-color: var(--rs-border, rgba(59,130,246,0.33));
|
||||||
color: #60a5fa;
|
color: var(--rs-primary-hover, #6366f1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cw-btn--run:hover {
|
.cw-btn--run:hover {
|
||||||
background: #3b82f633;
|
background: var(--rs-bg-active, rgba(59,130,246,0.2));
|
||||||
border-color: #3b82f6;
|
border-color: var(--rs-primary, #4f46e5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cw-toggle {
|
.cw-toggle {
|
||||||
|
|
|
||||||
|
|
@ -926,7 +926,13 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
const sp = document.querySelector('rstack-space-settings');
|
const sp = document.querySelector('rstack-space-settings');
|
||||||
if (sp) sp.setAttribute('module-id', moduleId);
|
if (sp) sp.setAttribute('module-id', moduleId);
|
||||||
if (tabCache) {
|
if (tabCache) {
|
||||||
|
const switchId = moduleId; // capture for staleness check
|
||||||
tabCache.switchTo(moduleId).then(ok => {
|
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);
|
console.log('[shell] switchTo result:', ok, 'for', moduleId);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
tabBar.setAttribute('active', layerId);
|
tabBar.setAttribute('active', layerId);
|
||||||
|
|
@ -936,6 +942,7 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
window.location.href = url;
|
window.location.href = url;
|
||||||
}
|
}
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
|
if (currentModuleId !== switchId) return; // stale
|
||||||
console.error('[shell] switchTo error:', err);
|
console.error('[shell] switchTo error:', err);
|
||||||
window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId);
|
window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId);
|
||||||
});
|
});
|
||||||
|
|
@ -1849,7 +1856,7 @@ const SUBNAV_CSS = `
|
||||||
background: var(--rs-bg-surface);
|
background: var(--rs-bg-surface);
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 92px;
|
top: 93px;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
.rapp-subnav::-webkit-scrollbar { display: none; }
|
.rapp-subnav::-webkit-scrollbar { display: none; }
|
||||||
|
|
@ -1949,7 +1956,7 @@ const TABBAR_CSS = `
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
.rapp-tabbar::-webkit-scrollbar { display: none; }
|
.rapp-tabbar::-webkit-scrollbar { display: none; }
|
||||||
.rapp-subnav + .rapp-tabbar { top: 129px; }
|
.rapp-subnav + .rapp-tabbar { top: 130px; }
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.rapp-tabbar {
|
.rapp-tabbar {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
|
||||||
|
|
@ -394,18 +394,28 @@ export class RStackIdentity extends HTMLElement {
|
||||||
async #validateSessionWithServer() {
|
async #validateSessionWithServer() {
|
||||||
const session = getSession();
|
const session = getSession();
|
||||||
if (!session?.accessToken) return;
|
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 {
|
try {
|
||||||
const res = await fetch(`${ENCRYPTID_URL}/api/session/verify`, {
|
const res = await fetch(`${ENCRYPTID_URL}/api/session/verify`, {
|
||||||
headers: { Authorization: `Bearer ${session.accessToken}` },
|
headers: { Authorization: `Bearer ${session.accessToken}` },
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
// Session revoked — clear locally and re-render
|
// Session revoked — clear locally and re-render
|
||||||
|
localStorage.removeItem(VALIDATE_KEY);
|
||||||
localStorage.removeItem(SESSION_KEY);
|
localStorage.removeItem(SESSION_KEY);
|
||||||
localStorage.removeItem("rspace-username");
|
localStorage.removeItem("rspace-username");
|
||||||
_removeSessionCookie();
|
_removeSessionCookie();
|
||||||
resetDocBridge();
|
resetDocBridge();
|
||||||
this.#render();
|
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 */ }
|
} 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-row { display: flex; gap: 6px; margin-bottom: 0.4rem; }
|
||||||
.eid-nudge-link-input {
|
.eid-nudge-link-input {
|
||||||
flex: 1; font-size: 0.72rem; padding: 5px 8px; border-radius: 6px;
|
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;
|
color: var(--rs-text-secondary, #94a3b8); min-width: 0; outline: none;
|
||||||
}
|
}
|
||||||
.eid-nudge-btn--copy {
|
.eid-nudge-btn--copy {
|
||||||
|
|
@ -548,7 +558,7 @@ export class RStackIdentity extends HTMLElement {
|
||||||
if (e.key === "encryptid_session" || e.key === PERSONAS_KEY) {
|
if (e.key === "encryptid_session" || e.key === PERSONAS_KEY) {
|
||||||
this.#render();
|
this.#render();
|
||||||
if (e.key === "encryptid_session") {
|
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
|
// Session cleared from another tab — reload to show logged-out state
|
||||||
if (!e.newValue) window.location.reload();
|
if (!e.newValue) window.location.reload();
|
||||||
}
|
}
|
||||||
|
|
@ -595,7 +605,7 @@ export class RStackIdentity extends HTMLElement {
|
||||||
const payload = parseJWT(newToken);
|
const payload = parseJWT(newToken);
|
||||||
storeSession(newToken, (payload.username as string) || refreshData.username || username, (payload.did as string) || did);
|
storeSession(newToken, (payload.username as string) || refreshData.username || username, (payload.did as string) || did);
|
||||||
this.#render();
|
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 */ }
|
} catch { /* offline — keep whatever we have */ }
|
||||||
}
|
}
|
||||||
|
|
@ -684,9 +694,7 @@ export class RStackIdentity extends HTMLElement {
|
||||||
if (action === "signout") {
|
if (action === "signout") {
|
||||||
clearSession();
|
clearSession();
|
||||||
resetDocBridge();
|
resetDocBridge();
|
||||||
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true }));
|
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true, detail: { reason: "signout" } }));
|
||||||
// Reload so the server re-renders the current rApp in logged-out mode
|
|
||||||
window.location.reload();
|
|
||||||
return;
|
return;
|
||||||
} else if (action === "my-account") {
|
} else if (action === "my-account") {
|
||||||
this.showAccountModal();
|
this.showAccountModal();
|
||||||
|
|
@ -715,7 +723,7 @@ export class RStackIdentity extends HTMLElement {
|
||||||
clearSession();
|
clearSession();
|
||||||
resetDocBridge();
|
resetDocBridge();
|
||||||
this.#render();
|
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();
|
this.showAuthModal();
|
||||||
} else if (action === "remove-persona") {
|
} else if (action === "remove-persona") {
|
||||||
const targetDid = (el as HTMLElement).dataset.did || "";
|
const targetDid = (el as HTMLElement).dataset.did || "";
|
||||||
|
|
@ -879,7 +887,7 @@ export class RStackIdentity extends HTMLElement {
|
||||||
storeSession(data.token, data.username || "", data.did || "");
|
storeSession(data.token, data.username || "", data.did || "");
|
||||||
close();
|
close();
|
||||||
this.#render();
|
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?.();
|
callbacks?.onSuccess?.();
|
||||||
// Auto-redirect to personal space
|
// Auto-redirect to personal space
|
||||||
autoResolveSpace(data.token, data.username || "");
|
autoResolveSpace(data.token, data.username || "");
|
||||||
|
|
@ -956,7 +964,7 @@ export class RStackIdentity extends HTMLElement {
|
||||||
storeSession(data.token, username, data.did || "");
|
storeSession(data.token, username, data.did || "");
|
||||||
close();
|
close();
|
||||||
this.#render();
|
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?.();
|
callbacks?.onSuccess?.();
|
||||||
// Show post-signup prompt recommending second device before redirecting
|
// Show post-signup prompt recommending second device before redirecting
|
||||||
this.#showPostSignupPrompt(data.token, username);
|
this.#showPostSignupPrompt(data.token, username);
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ export class TabCache {
|
||||||
private currentModuleId: string;
|
private currentModuleId: string;
|
||||||
private spaceSlug: string;
|
private spaceSlug: string;
|
||||||
private panes = new Map<string, HTMLElement>();
|
private panes = new Map<string, HTMLElement>();
|
||||||
|
private fetchController: AbortController | null = null;
|
||||||
|
|
||||||
constructor(spaceSlug: string, moduleId: string) {
|
constructor(spaceSlug: string, moduleId: string) {
|
||||||
this.spaceSlug = spaceSlug;
|
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. */
|
/** Switch to a module tab within the current space. Returns true if handled client-side. */
|
||||||
async switchTo(moduleId: string): Promise<boolean> {
|
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);
|
const key = this.paneKey(this.spaceSlug, moduleId);
|
||||||
if (moduleId === this.currentModuleId && this.panes.has(key)) {
|
if (moduleId === this.currentModuleId && this.panes.has(key)) {
|
||||||
console.log("[TabCache] switchTo", moduleId, "→ already current + cached");
|
console.log("[TabCache] switchTo", moduleId, "→ already current + cached");
|
||||||
|
|
@ -199,18 +206,25 @@ export class TabCache {
|
||||||
this.hideAllPanes();
|
this.hideAllPanes();
|
||||||
app.appendChild(loadingPane);
|
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 {
|
try {
|
||||||
const resp = await fetch(fetchUrl, {
|
const resp = await fetch(fetchUrl, {
|
||||||
headers: { "Accept": "text/html" },
|
headers: { "Accept": "text/html" },
|
||||||
signal: AbortSignal.timeout(10_000),
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
console.warn("[TabCache] fetchAndInject: HTTP", resp.status, "for", fetchUrl);
|
console.warn("[TabCache] fetchAndInject: HTTP", resp.status, "for", fetchUrl);
|
||||||
loadingPane.remove();
|
loadingPane.remove();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const html = await resp.text();
|
const html = await resp.text();
|
||||||
|
clearTimeout(timeoutId);
|
||||||
console.log("[TabCache] fetchAndInject: got", html.length, "bytes for", moduleId);
|
console.log("[TabCache] fetchAndInject: got", html.length, "bytes for", moduleId);
|
||||||
const content = this.extractContent(html);
|
const content = this.extractContent(html);
|
||||||
if (!content) {
|
if (!content) {
|
||||||
|
|
@ -247,8 +261,16 @@ export class TabCache {
|
||||||
console.log("[TabCache] fetchAndInject: SUCCESS for", moduleId);
|
console.log("[TabCache] fetchAndInject: SUCCESS for", moduleId);
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[TabCache] fetchAndInject: CATCH for", moduleId, err);
|
clearTimeout(timeoutId);
|
||||||
loadingPane.remove();
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -461,7 +461,7 @@ app.use('*', cors({
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
},
|
},
|
||||||
allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
|
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||||
allowHeaders: ['Content-Type', 'Authorization'],
|
allowHeaders: ['Content-Type', 'Authorization'],
|
||||||
credentials: true,
|
credentials: true,
|
||||||
}));
|
}));
|
||||||
|
|
@ -7141,21 +7141,8 @@ app.get('/', (c) => {
|
||||||
<div id="error-msg" class="error"></div>
|
<div id="error-msg" class="error"></div>
|
||||||
<div id="success-msg" class="success"></div>
|
<div id="success-msg" class="success"></div>
|
||||||
|
|
||||||
<!-- Sign-in mode (default) — passkey-first -->
|
<!-- Sign-in mode (default) — dynamically rendered by renderSigninAccounts() -->
|
||||||
<div id="signin-fields">
|
<div id="signin-fields"></div>
|
||||||
<button class="btn-primary" id="auth-btn" onclick="handleAuth()" style="font-size:1.1rem;padding:0.9rem">🔑 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>
|
|
||||||
|
|
||||||
<!-- Registration stepper (hidden until register tab) -->
|
<!-- Registration stepper (hidden until register tab) -->
|
||||||
<div id="register-stepper" style="display:none">
|
<div id="register-stepper" style="display:none">
|
||||||
|
|
@ -7422,6 +7409,85 @@ app.get('/', (c) => {
|
||||||
} from '/dist/index.js';
|
} from '/dist/index.js';
|
||||||
|
|
||||||
const TOKEN_KEY = 'encryptid_token';
|
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">🔑 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
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">🔑 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
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';
|
let currentMode = 'signin';
|
||||||
|
|
||||||
// Registration stepper state
|
// Registration stepper state
|
||||||
|
|
@ -7692,19 +7758,19 @@ app.get('/', (c) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// handleAuth — always unscoped passkey picker (browser shows all stored passkeys)
|
// handleAuth — optionally scoped to a known username, unscoped if omitted
|
||||||
window.handleAuth = async () => {
|
window.handleAuth = async (username) => {
|
||||||
const btn = document.getElementById('auth-btn');
|
const btn = document.getElementById('auth-btn');
|
||||||
btn.disabled = true;
|
if (btn) { btn.disabled = true; btn.textContent = 'Waiting for passkey...'; }
|
||||||
btn.textContent = 'Waiting for passkey...';
|
|
||||||
hideMessages();
|
hideMessages();
|
||||||
|
|
||||||
try {
|
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', {
|
const startRes = await fetch('/api/auth/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify(authBody),
|
||||||
});
|
});
|
||||||
if (!startRes.ok) throw new Error('Failed to start authentication');
|
if (!startRes.ok) throw new Error('Failed to start authentication');
|
||||||
const { options: serverOptions, prfSalt } = await startRes.json();
|
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);
|
showProfile(data.token, data.username, data.did);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.name === 'NotAllowedError') {
|
if (err.name === 'NotAllowedError') {
|
||||||
showError('No passkey found on this device. Use email to sign in.');
|
showError('No passkey found on this device. Use email to sign in.');
|
||||||
// Auto-show email fallback
|
// Auto-show email fallback
|
||||||
document.getElementById('email-fallback-section').style.display = 'block';
|
const fb = document.getElementById('email-fallback-section');
|
||||||
document.getElementById('show-email-fallback').style.display = 'none';
|
const link = document.getElementById('show-email-fallback');
|
||||||
|
if (fb) fb.style.display = 'block';
|
||||||
|
if (link) link.style.display = 'none';
|
||||||
} else {
|
} else {
|
||||||
showError(err.message || 'Authentication failed');
|
showError(err.message || 'Authentication failed');
|
||||||
}
|
}
|
||||||
btn.innerHTML = '🔑 Sign in with Passkey';
|
if (btn) { btn.innerHTML = '🔑 Sign in with Passkey'; btn.disabled = false; }
|
||||||
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 () => {
|
(async () => {
|
||||||
|
renderSigninAccounts();
|
||||||
|
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
if (params.get('tab') === 'register') switchTab('register');
|
if (params.get('tab') === 'register') switchTab('register');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -361,25 +361,6 @@ const styles = `
|
||||||
opacity: 0.7;
|
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 {
|
.email-fallback-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -536,7 +517,7 @@ export class EncryptIDLoginButton extends HTMLElement {
|
||||||
${PASSKEY_ICON}
|
${PASSKEY_ICON}
|
||||||
<span>${this.label}</span>
|
<span>${this.label}</span>
|
||||||
</button>
|
</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}
|
${emailSection}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -641,22 +622,18 @@ export class EncryptIDLoginButton extends HTMLElement {
|
||||||
this.render();
|
this.render();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send magic link button
|
// Send magic link button + enter key
|
||||||
this.shadow.querySelector('[data-action="send-magic-link"]')?.addEventListener('click', () => {
|
this.shadow.querySelector('[data-action="send-magic-link"]')?.addEventListener('click', () => {
|
||||||
this.sendMagicLink();
|
this.sendMagicLink();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Email input enter key
|
|
||||||
const emailInput = this.shadow.querySelector('[data-email-input]') as HTMLInputElement;
|
const emailInput = this.shadow.querySelector('[data-email-input]') as HTMLInputElement;
|
||||||
if (emailInput) {
|
emailInput?.addEventListener('keydown', (e) => {
|
||||||
emailInput.addEventListener('keydown', (e) => {
|
if ((e as KeyboardEvent).key === 'Enter') this.sendMagicLink();
|
||||||
if ((e as KeyboardEvent).key === 'Enter') this.sendMagicLink();
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Login button with scoped username (1 known account)
|
// Login button with scoped username (1 known account)
|
||||||
const loginBtn = this.shadow.querySelector('.login-btn:not([data-action])');
|
const loginBtn = this.shadow.querySelector('.login-btn:not([data-action])');
|
||||||
if (loginBtn && !usernameInput) {
|
if (loginBtn) {
|
||||||
loginBtn.addEventListener('click', () => {
|
loginBtn.addEventListener('click', () => {
|
||||||
const username = (loginBtn as HTMLElement).dataset.username;
|
const username = (loginBtn as HTMLElement).dataset.username;
|
||||||
this.handleLogin(username);
|
this.handleLogin(username);
|
||||||
|
|
|
||||||
|
|
@ -117,13 +117,18 @@ if (spaceSlug) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reload space list when user signs in/out (to show/hide private spaces)
|
// 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;
|
const spaceSwitcher = document.querySelector("rstack-space-switcher") as any;
|
||||||
spaceSwitcher?.reload?.();
|
spaceSwitcher?.reload?.();
|
||||||
|
|
||||||
// If signed out, redirect to homepage
|
// Only redirect to homepage on genuine sign-out or server revocation
|
||||||
const session = localStorage.getItem("encryptid_session");
|
if (reason === "signout" || reason === "revoked") {
|
||||||
if (!session) {
|
|
||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue