Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-02 19:28:26 -08:00
commit 13293799b6
4 changed files with 103 additions and 15 deletions

View File

@ -712,6 +712,9 @@ export class RStackIdentity extends HTMLElement {
let openSection: string | null = null;
// Account completion status
let acctStatus: { email: boolean; multiDevice: boolean; socialRecovery: boolean; guardianCount: number; credentialCount: number } | null = null;
// Lazy-loaded data
let guardians: { id: string; name: string; email?: string; status: string }[] = [];
let guardiansThreshold = 2;
@ -727,11 +730,36 @@ export class RStackIdentity extends HTMLElement {
const close = () => overlay.remove();
// Load account completion status
const loadStatus = async () => {
try {
const res = await fetch(`${ENCRYPTID_URL}/api/account/status`, {
headers: { Authorization: `Bearer ${getAccessToken()}` },
});
if (res.ok) {
acctStatus = await res.json();
render();
}
} catch { /* offline */ }
};
loadStatus();
const statusDot = (done: boolean | null) => {
if (done === null) return ''; // still loading
return done
? '<span class="status-dot done" title="Complete"></span>'
: '<span class="status-dot pending" title="Not yet set up"></span>';
};
const render = () => {
const backupEnabled = isEncryptedBackupEnabled();
const currentTheme = localStorage.getItem("canvas-theme") || "dark";
const isDark = currentTheme === "dark";
const emailDone = acctStatus ? acctStatus.email : null;
const deviceDone = acctStatus ? acctStatus.multiDevice : null;
const recoveryDone = acctStatus ? acctStatus.socialRecovery : null;
overlay.innerHTML = `
<style>${MODAL_STYLES}${SETTINGS_STYLES}${ACCOUNT_MODAL_STYLES}</style>
<div class="account-modal">
@ -743,15 +771,18 @@ export class RStackIdentity extends HTMLElement {
${renderRecoverySection()}
${renderAddressSection()}
<div class="account-section account-section--inline">
<div class="account-section account-section--inline${!backupEnabled ? " section--warning" : ""}">
<div class="account-section-header">
<span>🔒 Data Storage</span>
<span>${statusDot(backupEnabled)} 🔒 Data Storage</span>
<label class="toggle-switch">
<input type="checkbox" id="acct-backup-toggle" ${backupEnabled ? "checked" : ""} />
<span class="toggle-slider"></span>
</label>
</div>
<div class="toggle-hint" id="backup-hint">${backupEnabled ? "Save to encrypted server" : "Save locally — you manage your own data"}</div>
<div class="toggle-hint" id="backup-hint">${backupEnabled
? "Save to encrypted server"
: '<span style="color:#f87171">Local only — you are responsible for your own data</span>'
}</div>
</div>
<div class="account-section account-section--inline">
@ -772,6 +803,7 @@ export class RStackIdentity extends HTMLElement {
const renderEmailSection = () => {
const isOpen = openSection === "email";
const done = acctStatus ? acctStatus.email : null;
let body = "";
if (isOpen) {
if (emailStep === "input") {
@ -798,9 +830,9 @@ export class RStackIdentity extends HTMLElement {
}
}
return `
<div class="account-section${isOpen ? " open" : ""}">
<div class="account-section${isOpen ? " open" : ""}${done === false ? " section--warning" : ""}">
<div class="account-section-header" data-section="email">
<span> Email</span>
<span>${statusDot(done)} Email</span>
<span class="section-arrow">${isOpen ? "▾" : "▸"}</span>
</div>
${body}
@ -809,9 +841,10 @@ export class RStackIdentity extends HTMLElement {
const renderDeviceSection = () => {
const isOpen = openSection === "device";
const done = acctStatus ? acctStatus.multiDevice : null;
const body = isOpen ? `
<div class="account-section-body">
<p style="color:#94a3b8;font-size:0.85rem;margin:0 0 12px">Register an additional passkey for backup access.</p>
<p style="color:#94a3b8;font-size:0.85rem;margin:0 0 12px">Register an additional passkey for backup access.${acctStatus ? ` <span style="color:#64748b">(${acctStatus.credentialCount} passkey${acctStatus.credentialCount !== 1 ? "s" : ""} registered)</span>` : ""}</p>
<div class="actions actions--stack">
<button class="btn btn--primary" data-action="register-device">🔑 Register Passkey on This Device</button>
</div>
@ -819,9 +852,9 @@ export class RStackIdentity extends HTMLElement {
<div class="info-text">Each device you register can independently sign in to your account.</div>
</div>` : "";
return `
<div class="account-section${isOpen ? " open" : ""}">
<div class="account-section${isOpen ? " open" : ""}${done === false ? " section--warning" : ""}">
<div class="account-section-header" data-section="device">
<span>📱 Connect Another Device</span>
<span>${statusDot(done)} 📱 Connect Another Device</span>
<span class="section-arrow">${isOpen ? "▾" : "▸"}</span>
</div>
${body}
@ -867,10 +900,11 @@ export class RStackIdentity extends HTMLElement {
</div>`;
}
}
const done = acctStatus ? acctStatus.socialRecovery : null;
return `
<div class="account-section${isOpen ? " open" : ""}">
<div class="account-section${isOpen ? " open" : ""}${done === false ? " section--warning" : ""}">
<div class="account-section-header" data-section="recovery">
<span>🛡 Social Recovery</span>
<span>${statusDot(done)} 🛡 Social Recovery</span>
<span class="section-arrow">${isOpen ? "▾" : "▸"}</span>
</div>
${body}
@ -1025,7 +1059,8 @@ export class RStackIdentity extends HTMLElement {
body: JSON.stringify({ email: emailAddr, code }),
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "Verification failed");
close();
if (acctStatus) acctStatus.email = true;
openSection = null; render();
this.dispatchEvent(new CustomEvent("identity-action", { bubbles: true, composed: true, detail: { action: "email-added", email: emailAddr } }));
} catch (e: any) {
btn.disabled = false; btn.innerHTML = "Verify";
@ -1093,8 +1128,10 @@ export class RStackIdentity extends HTMLElement {
});
if (!completeRes.ok) throw new Error((await completeRes.json().catch(() => ({}))).error || "Device registration failed");
if (acctStatus) { acctStatus.credentialCount++; acctStatus.multiDevice = acctStatus.credentialCount > 1; }
btn.innerHTML = "Device Registered";
btn.className = "btn btn--success";
render();
this.dispatchEvent(new CustomEvent("identity-action", { bubbles: true, composed: true, detail: { action: "device-added" } }));
} catch (e: any) {
btn.disabled = false; btn.innerHTML = "🔑 Register Passkey on This Device";
@ -1122,6 +1159,7 @@ export class RStackIdentity extends HTMLElement {
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to add guardian");
guardians.push({ id: data.guardian.id, name: data.guardian.name, email: data.guardian.email, status: data.guardian.status });
if (acctStatus) { acctStatus.guardianCount = guardians.length; acctStatus.socialRecovery = guardians.length >= 2; }
render();
setTimeout(() => (overlay.querySelector("#acct-guardian-name") as HTMLInputElement)?.focus(), 50);
} catch (e: any) {
@ -1149,6 +1187,7 @@ export class RStackIdentity extends HTMLElement {
});
if (!res.ok) throw new Error("Failed to remove guardian");
guardians = guardians.filter(g => g.id !== id);
if (acctStatus) { acctStatus.guardianCount = guardians.length; acctStatus.socialRecovery = guardians.length >= 2; }
render();
} catch (e: any) {
if (err) err.textContent = e.message;
@ -1215,8 +1254,7 @@ export class RStackIdentity extends HTMLElement {
e.stopPropagation();
const enabled = backupToggle.checked;
setEncryptedBackupEnabled(enabled);
const hint = overlay.querySelector("#backup-hint") as HTMLElement;
if (hint) hint.textContent = enabled ? "Save to encrypted server" : "Save locally — you manage your own data";
render();
this.dispatchEvent(new CustomEvent("backup-toggle", { bubbles: true, composed: true, detail: { enabled } }));
});
}
@ -1657,6 +1695,14 @@ const ACCOUNT_MODAL_STYLES = `
padding: 0 16px 10px; font-size: 0.75rem; color: #64748b; line-height: 1.4;
}
.guardian-piece { font-size: 1.1rem; flex-shrink: 0; }
.status-dot {
display: inline-block; width: 8px; height: 8px; border-radius: 50%;
margin-right: 6px; vertical-align: middle; flex-shrink: 0;
}
.status-dot.done { background: #34d399; box-shadow: 0 0 4px rgba(52,211,153,0.4); }
.status-dot.pending { background: #f87171; box-shadow: 0 0 4px rgba(248,113,113,0.4); }
.section--warning { border-color: rgba(248,113,113,0.3) !important; }
.section--warning .account-section-header span:first-child { color: #fca5a5; }
.address-form {
display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px;
}

View File

@ -219,7 +219,7 @@ export async function getUserById(userId: string) {
export interface StoredRecoveryToken {
token: string;
userId: string;
type: 'email_verify' | 'account_recovery' | 'email_verification';
type: 'email_verify' | 'account_recovery';
createdAt: number;
expiresAt: number;
used: boolean;

View File

@ -847,6 +847,45 @@ app.delete('/api/user/addresses/:id', async (c) => {
// ACCOUNT SETTINGS ENDPOINTS
// ============================================================================
/**
* GET /api/account/status account setup completion status
* Returns which security/setup steps are done.
*/
app.get('/api/account/status', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
const userId = claims.sub as string;
// Check email
const profile = await getUserProfile(userId);
const hasEmail = !!(profile?.profileEmail);
// Check credentials count (>1 means multi-device)
const creds = await getUserCredentials(userId);
const hasMultiDevice = creds.length > 1;
// Check guardians
let guardianCount = 0;
try {
const rows = await sql`SELECT COUNT(*) as count FROM guardians WHERE user_id = ${userId} AND status != 'removed'`;
guardianCount = parseInt(rows[0]?.count || '0');
} catch { /* ignore */ }
const hasRecovery = guardianCount >= 2;
// Check encrypted backup
// (This is client-side localStorage, but we can infer from whether they have any synced docs)
// For now, we just return the server-side info and let the client check localStorage
return c.json({
email: hasEmail,
multiDevice: hasMultiDevice,
socialRecovery: hasRecovery,
credentialCount: creds.length,
guardianCount,
});
});
/**
* POST /api/account/email/start send verification code to email
* Body: { email }
@ -867,7 +906,7 @@ app.post('/api/account/email/start', async (c) => {
await storeRecoveryToken({
token: tokenKey,
userId: claims.sub as string,
type: 'email_verification',
type: 'email_verify',
createdAt: Date.now(),
expiresAt: Date.now() + 10 * 60 * 1000, // 10 minutes
used: false,

View File

@ -37,6 +37,9 @@ self.addEventListener("activate", (event) => {
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
// Skip non-http(s) schemes (chrome-extension://, etc.) — they can't be cached
if (!url.protocol.startsWith("http")) return;
// Skip WebSocket and API requests entirely
if (
event.request.url.startsWith("ws://") ||