Merge branch 'dev'
CI/CD / deploy (push) Successful in 2m58s Details

This commit is contained in:
Jeff Emmett 2026-04-11 10:49:10 -04:00
commit bc2b6ba23c
4 changed files with 589 additions and 24 deletions

View File

@ -421,6 +421,9 @@ export class RStackIdentity extends HTMLElement {
// Nudge users without a second device to link one // Nudge users without a second device to link one
this.#checkDeviceNudge(); this.#checkDeviceNudge();
// Show recovery alert dot on avatar if not set up
this.#checkRecoveryBadge();
// Propagate login/logout across tabs via storage events // Propagate login/logout across tabs via storage events
window.addEventListener("storage", this.#onStorageChange); window.addEventListener("storage", this.#onStorageChange);
} }
@ -458,6 +461,44 @@ export class RStackIdentity extends HTMLElement {
} catch { /* network error — let token expire naturally */ } } catch { /* network error — let token expire naturally */ }
} }
async #checkRecoveryBadge() {
const session = getSession();
if (!session?.accessToken) return;
// Cache: only re-check every 30 minutes
const CACHE_KEY = "eid_recovery_status";
const cached = localStorage.getItem(CACHE_KEY);
if (cached) {
try {
const { ts, ok } = JSON.parse(cached);
if (Date.now() - ts < 30 * 60 * 1000) {
if (!ok) this.#showRecoveryDot();
return;
}
} catch { /* stale cache */ }
}
try {
const res = await fetch(`${ENCRYPTID_URL}/api/account/status`, {
headers: { Authorization: `Bearer ${getAccessToken()}` },
});
if (!res.ok) return;
const status = await res.json();
const recoveryOk = status.socialRecovery === true;
localStorage.setItem(CACHE_KEY, JSON.stringify({ ts: Date.now(), ok: recoveryOk }));
if (!recoveryOk) this.#showRecoveryDot();
} catch { /* offline */ }
}
#showRecoveryDot() {
const wrap = this.#shadow.querySelector(".avatar-wrap");
if (!wrap || wrap.querySelector(".recovery-alert-dot")) return;
const dot = document.createElement("span");
dot.className = "recovery-alert-dot";
dot.title = "Social recovery not set up";
wrap.appendChild(dot);
}
async #checkDeviceNudge() { async #checkDeviceNudge() {
const session = getSession(); const session = getSession();
if (!session?.accessToken) return; if (!session?.accessToken) return;
@ -1300,7 +1341,7 @@ export class RStackIdentity extends HTMLElement {
let openSection: string | null = options?.openSection ?? null; let openSection: string | null = options?.openSection ?? null;
// Account completion status // Account completion status
let acctStatus: { email: boolean; emailAddress?: string | null; multiDevice: boolean; socialRecovery: boolean; guardianCount: number; credentialCount: number } | null = null; let acctStatus: { email: boolean; emailAddress?: string | null; multiDevice: boolean; socialRecovery: boolean; guardianCount: number; acceptedGuardianCount?: number; credentialCount: number; lastDrillAt?: number | null } | null = null;
// Lazy-loaded data // Lazy-loaded data
let guardians: { id: string; name: string; email?: string; status: string }[] = []; let guardians: { id: string; name: string; email?: string; status: string }[] = [];
@ -1308,6 +1349,13 @@ export class RStackIdentity extends HTMLElement {
let guardiansLoaded = false; let guardiansLoaded = false;
let guardiansLoading = false; let guardiansLoading = false;
// Drill state
let activeDrillId: string | null = null;
let drillPollingTimer: ReturnType<typeof setInterval> | null = null;
let drillStatus: { approvals: Array<{ guardianId: string; approved: boolean }>; approvalCount: number; threshold: number; status: string } | null = null;
let showWalkthrough = false;
let walkthroughStep = 0;
let devices: { credentialId: string; label: string | null; createdAt: number; lastUsed?: number; transports?: string[] }[] = []; let devices: { credentialId: string; label: string | null; createdAt: number; lastUsed?: number; transports?: string[] }[] = [];
let devicesLoaded = false; let devicesLoaded = false;
let devicesLoading = false; let devicesLoading = false;
@ -1326,7 +1374,10 @@ export class RStackIdentity extends HTMLElement {
let emailStep: "input" | "verify" = "input"; let emailStep: "input" | "verify" = "input";
let emailAddr = ""; let emailAddr = "";
const close = () => overlay.remove(); const close = () => {
if (drillPollingTimer) { clearInterval(drillPollingTimer); drillPollingTimer = null; }
overlay.remove();
};
// Load account completion status // Load account completion status
const loadStatus = async () => { const loadStatus = async () => {
@ -1388,10 +1439,40 @@ export class RStackIdentity extends HTMLElement {
<div class="error" id="acct-error"></div> <div class="error" id="acct-error"></div>
</div> </div>
${showWalkthrough ? renderWalkthrough() : ""}
`; `;
attachListeners(); attachListeners();
}; };
const WALKTHROUGH_STEPS = [
{ icon: "🔒", iconClass: "red", title: "Account Locked", body: "Imagine you lost access to all your devices. Your passkeys are gone, and you can't sign in." },
{ icon: "📡", iconClass: "amber", title: "Guardians Notified", body: "You request account recovery. Your trusted guardians each receive a notification asking them to verify your identity." },
{ icon: "✅", iconClass: "amber", title: "Identity Verified", body: "Each guardian confirms it's really you — through a phone call, video chat, or meeting in person. They click 'Approve' on their end." },
{ icon: "🔑", iconClass: "green", title: "Key Assembled", body: "When 2 of your 3 guardians approve, their trust combines to unlock your recovery. No single guardian can do this alone." },
{ icon: "🛡️", iconClass: "green", title: "Account Recovered", body: "You register a new passkey on your new device and regain full access. Your data and identity are restored." },
];
const renderWalkthrough = () => {
const step = WALKTHROUGH_STEPS[walkthroughStep];
const dots = WALKTHROUGH_STEPS.map((_, i) =>
`<div class="walkthrough-dot${i < walkthroughStep ? " done" : i === walkthroughStep ? " active" : ""}"></div>`
).join("");
const isLast = walkthroughStep >= WALKTHROUGH_STEPS.length - 1;
return `
<div class="walkthrough-overlay">
<div class="walkthrough-card">
<div class="walkthrough-icon ${step.iconClass}">${step.icon}</div>
<div class="walkthrough-title">${step.title}</div>
<div class="walkthrough-body">${step.body}</div>
<div class="walkthrough-progress">${dots}</div>
<div class="walkthrough-nav">
<button class="walkthrough-btn walkthrough-btn--skip" data-action="walkthrough-skip">${isLast ? "Close" : "Skip"}</button>
${!isLast ? `<button class="walkthrough-btn walkthrough-btn--next" data-action="walkthrough-next">Next</button>` : `<button class="walkthrough-btn walkthrough-btn--next" data-action="walkthrough-skip">Got It</button>`}
</div>
</div>
</div>`;
};
const renderEmailSection = () => { const renderEmailSection = () => {
const isOpen = openSection === "email"; const isOpen = openSection === "email";
const done = acctStatus ? acctStatus.email : null; const done = acctStatus ? acctStatus.email : null;
@ -1484,6 +1565,39 @@ export class RStackIdentity extends HTMLElement {
</div>`; </div>`;
}; };
const renderGuardianPiecesSVG = (slots: Array<{ status: "empty" | "pending" | "accepted" }>, assembled: boolean) => {
const pieceClass = (s: string) => s === "accepted" ? "piece-accepted glow" : s === "pending" ? "piece-pending" : "piece-empty";
return `<div class="guardian-viz">
<svg width="200" height="60" viewBox="0 0 200 60">
<defs>
<linearGradient id="keyGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#34d399" />
<stop offset="100%" style="stop-color:#06b6d4" />
</linearGradient>
</defs>
${/* Piece 1 (left) */""}
<g transform="translate(15,10)">
<path class="${pieceClass(slots[0]?.status || "empty")}" d="M0,0 h25 c0,5 8,5 8,0 h7 v40 h-40 z" />
<text x="12" y="25" fill="white" font-size="10" text-anchor="middle" font-weight="600">1</text>
</g>
${/* Piece 2 (center) */""}
<g transform="translate(70,10)">
<path class="${pieceClass(slots[1]?.status || "empty")}" d="M0,0 h25 c0,5 8,5 8,0 h7 v40 h-7 c0,-5 -8,-5 -8,0 h-25 v-40 z" />
<text x="16" y="25" fill="white" font-size="10" text-anchor="middle" font-weight="600">2</text>
</g>
${/* Piece 3 (right) */""}
<g transform="translate(125,10)">
<path class="${pieceClass(slots[2]?.status || "empty")}" d="M0,0 h40 v40 h-40 v0 c0,-5 -8,-5 -8,0 v0 z" />
<text x="16" y="25" fill="white" font-size="10" text-anchor="middle" font-weight="600">3</text>
</g>
${/* Key icon (right side) */""}
<g transform="translate(175,15)">
<path class="${assembled ? "key-assembled" : "key-outline"}" d="M0,15 a10,10 0 1 1 12,0 l0,3 h8 v5 h-3 v3 h3 v5 h-8 l0,0 h-12 z" />
</g>
</svg>
</div>`;
};
const renderRecoverySection = () => { const renderRecoverySection = () => {
const isOpen = openSection === "recovery"; const isOpen = openSection === "recovery";
let body = ""; let body = "";
@ -1491,6 +1605,19 @@ export class RStackIdentity extends HTMLElement {
if (guardiansLoading) { if (guardiansLoading) {
body = `<div class="account-section-body"><div style="text-align:center;padding:1rem;color:var(--rs-text-secondary)"><span class="spinner"></span> Loading guardians...</div></div>`; body = `<div class="account-section-body"><div style="text-align:center;padding:1rem;color:var(--rs-text-secondary)"><span class="spinner"></span> Loading guardians...</div></div>`;
} else { } else {
const acceptedCount = guardians.filter(g => g.status === "accepted").length;
const recoveryActive = acceptedCount >= 2;
// Build puzzle piece slots
const slots: Array<{ status: "empty" | "pending" | "accepted" }> = [];
for (let i = 0; i < 3; i++) {
if (i < guardians.length) {
slots.push({ status: guardians[i].status === "accepted" ? "accepted" : "pending" });
} else {
slots.push({ status: "empty" });
}
}
const guardiansHTML = guardians.length > 0 const guardiansHTML = guardians.length > 0
? `<div class="contact-list">${guardians.map(g => ` ? `<div class="contact-list">${guardians.map(g => `
<div class="contact-item"> <div class="contact-item">
@ -1505,13 +1632,63 @@ export class RStackIdentity extends HTMLElement {
</div> </div>
`).join("")}</div>` : ""; `).join("")}</div>` : "";
const infoHTML = guardians.length < 2 // Recovery active banner
const activeBanner = recoveryActive ? `
<div class="recovery-active-banner">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 1l2.5 5 5.5.8-4 3.9.9 5.3L8 13.3l-4.9 2.7.9-5.3-4-3.9 5.5-.8z" fill="#34d399"/></svg>
Recovery active ${guardiansThreshold} of ${guardians.length} guardians can recover your account
</div>` : "";
// Walkthrough link (show after 2+ guardians)
const walkthroughLink = guardians.length >= 2 ? `
<div style="margin-top:8px;text-align:center">
<a style="color:#06b6d4;font-size:0.78rem;cursor:pointer;text-decoration:none" data-action="preview-recovery">Preview how recovery works</a>
</div>` : "";
// Drill section (only when recovery active)
let drillHTML = "";
if (recoveryActive) {
const lastDrill = acctStatus?.lastDrillAt;
const lastDrillStr = lastDrill ? new Date(lastDrill).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }) : null;
if (activeDrillId && drillStatus) {
// Live drill view
const drillSlots: Array<{ status: "empty" | "pending" | "accepted" }> = [];
for (const g of guardians) {
if (g.status !== "accepted") continue;
const approval = drillStatus.approvals.find(a => a.guardianId === g.id);
drillSlots.push({ status: approval?.approved ? "accepted" : "pending" });
}
while (drillSlots.length < 3) drillSlots.push({ status: "empty" });
const drillComplete = drillStatus.status === "approved" || drillStatus.status === "completed";
drillHTML = `<div class="drill-section">
<div class="drill-live">
<div class="drill-live-title">${drillComplete ? "Drill Complete!" : "Recovery Drill in Progress..."}</div>
${renderGuardianPiecesSVG(drillSlots, drillComplete)}
<div style="text-align:center;font-size:0.78rem;color:var(--rs-text-secondary)">
${drillStatus.approvalCount}/${drillStatus.threshold} guardians approved
</div>
${drillComplete ? `<div class="drill-success">Your recovery setup is working!</div>` : `<div style="text-align:center;font-size:0.72rem;color:var(--rs-text-muted);margin-top:4px">Checking every 5 seconds...</div>`}
</div>
</div>`;
} else {
drillHTML = `<div class="drill-section">
<button class="drill-btn" data-action="start-drill">🧪 Run Recovery Drill</button>
${lastDrillStr ? `<div class="drill-timestamp">Last drill: ${lastDrillStr}</div>` : `<div class="drill-timestamp">No drill run yet — test your setup!</div>`}
</div>`;
}
}
const infoHTML = !recoveryActive
? `<div class="info-text">Add at least 2 trusted guardians to enable social recovery. Threshold: ${guardiansThreshold} of ${Math.max(guardians.length, 2)} needed to recover.</div>` ? `<div class="info-text">Add at least 2 trusted guardians to enable social recovery. Threshold: ${guardiansThreshold} of ${Math.max(guardians.length, 2)} needed to recover.</div>`
: `<div class="info-text" style="color:#34d399">Social recovery is active. ${guardiansThreshold} of ${guardians.length} guardians needed to recover your account.</div>`; : `<div class="info-text" style="color:var(--rs-text-muted)">Recovery guardians can also help override account freezes and flow restrictions in emergencies.</div>`;
body = ` body = `
<div class="account-section-body"> <div class="account-section-body">
<p style="color:var(--rs-text-secondary);font-size:0.85rem;margin:0 0 12px">Choose trusted contacts who can help recover your account.</p> <p style="color:var(--rs-text-secondary);font-size:0.85rem;margin:0 0 12px">Choose trusted contacts who can help recover your account.</p>
${activeBanner}
${renderGuardianPiecesSVG(slots, recoveryActive)}
${guardians.length < 3 ? `<div class="input-row"> ${guardians.length < 3 ? `<div class="input-row">
<input class="input input--inline" id="acct-guardian-name" type="text" placeholder="Guardian name" /> <input class="input input--inline" id="acct-guardian-name" type="text" placeholder="Guardian name" />
<input class="input input--inline" id="acct-guardian-email" type="email" placeholder="Email (optional)" /> <input class="input input--inline" id="acct-guardian-email" type="email" placeholder="Email (optional)" />
@ -1519,15 +1696,18 @@ export class RStackIdentity extends HTMLElement {
</div>` : ""} </div>` : ""}
${guardiansHTML} ${guardiansHTML}
${infoHTML} ${infoHTML}
${walkthroughLink}
${drillHTML}
<div class="error" id="recovery-error"></div> <div class="error" id="recovery-error"></div>
</div>`; </div>`;
} }
} }
const done = acctStatus ? acctStatus.socialRecovery : null; const done = acctStatus ? acctStatus.socialRecovery : null;
const urgentClass = done === false ? " recovery-urgent" : "";
return ` return `
<div class="account-section${isOpen ? " open" : ""}${done === false ? " section--warning" : ""}"> <div class="account-section${isOpen ? " open" : ""}${done === false ? " section--warning" : ""}">
<div class="account-section-header" data-section="recovery"> <div class="account-section-header" data-section="recovery">
<span>${statusDot(done)} 🛡 Social Recovery</span> <span>${done === null ? "" : done ? '<span class="status-dot done" title="Complete"></span>' : `<span class="status-dot pending${urgentClass}" title="Not yet set up"></span>`} 🛡 Social Recovery</span>
<span class="section-arrow">${isOpen ? "▾" : "▸"}</span> <span class="section-arrow">${isOpen ? "▾" : "▸"}</span>
</div> </div>
${body} ${body}
@ -1975,7 +2155,9 @@ export class RStackIdentity extends HTMLElement {
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to add guardian"); 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 }); 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; } const accepted = guardians.filter(g => g.status === "accepted").length;
if (acctStatus) { acctStatus.guardianCount = guardians.length; acctStatus.acceptedGuardianCount = accepted; acctStatus.socialRecovery = accepted >= 2; }
localStorage.removeItem("eid_recovery_status");
render(); render();
setTimeout(() => (overlay.querySelector("#acct-guardian-name") as HTMLInputElement)?.focus(), 50); setTimeout(() => (overlay.querySelector("#acct-guardian-name") as HTMLInputElement)?.focus(), 50);
} catch (e: any) { } catch (e: any) {
@ -2003,7 +2185,9 @@ export class RStackIdentity extends HTMLElement {
}); });
if (!res.ok) throw new Error("Failed to remove guardian"); if (!res.ok) throw new Error("Failed to remove guardian");
guardians = guardians.filter(g => g.id !== id); guardians = guardians.filter(g => g.id !== id);
if (acctStatus) { acctStatus.guardianCount = guardians.length; acctStatus.socialRecovery = guardians.length >= 2; } const acceptedAfter = guardians.filter(g => g.status === "accepted").length;
if (acctStatus) { acctStatus.guardianCount = guardians.length; acctStatus.acceptedGuardianCount = acceptedAfter; acctStatus.socialRecovery = acceptedAfter >= 2; }
localStorage.removeItem("eid_recovery_status");
render(); render();
} catch (e: any) { } catch (e: any) {
if (err) err.textContent = e.message; if (err) err.textContent = e.message;
@ -2011,6 +2195,72 @@ export class RStackIdentity extends HTMLElement {
}); });
}); });
// Recovery: start drill
overlay.querySelector('[data-action="start-drill"]')?.addEventListener("click", async () => {
const btn = overlay.querySelector('[data-action="start-drill"]') as HTMLButtonElement;
const err = overlay.querySelector("#recovery-error") as HTMLElement;
if (!confirm("This will send a test notification to your guardians. They'll need to verify your identity and approve. Continue?")) return;
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Starting drill...';
try {
const res = await fetch(`${ENCRYPTID_URL}/api/recovery/drill/initiate`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAccessToken()}` },
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to start drill");
activeDrillId = data.drillId;
drillStatus = { approvals: data.guardians.map((g: any) => ({ guardianId: g.id, approved: false })), approvalCount: 0, threshold: 2, status: "pending" };
// Start polling
drillPollingTimer = setInterval(async () => {
if (!activeDrillId) return;
try {
const pollRes = await fetch(`${ENCRYPTID_URL}/api/recovery/drill/${activeDrillId}/status`, {
headers: { Authorization: `Bearer ${getAccessToken()}` },
});
if (pollRes.ok) {
const pollData = await pollRes.json();
drillStatus = pollData;
render();
if (pollData.status === "approved" || pollData.status === "completed") {
if (drillPollingTimer) clearInterval(drillPollingTimer);
drillPollingTimer = null;
// Complete the drill
await fetch(`${ENCRYPTID_URL}/api/recovery/drill/${activeDrillId}/complete`, {
method: "POST",
headers: { Authorization: `Bearer ${getAccessToken()}` },
});
if (acctStatus) acctStatus.lastDrillAt = Date.now();
// Clear recovery status cache so badge refreshes
localStorage.removeItem("eid_recovery_status");
render();
}
}
} catch { /* offline */ }
}, 5000);
render();
} catch (e: any) {
btn.disabled = false; btn.innerHTML = "🧪 Run Recovery Drill";
if (err) err.textContent = e.message;
}
});
// Recovery: preview walkthrough
overlay.querySelector('[data-action="preview-recovery"]')?.addEventListener("click", () => {
showWalkthrough = true;
walkthroughStep = 0;
render();
});
// Walkthrough navigation
overlay.querySelector('[data-action="walkthrough-next"]')?.addEventListener("click", () => {
walkthroughStep++;
if (walkthroughStep >= 5) { showWalkthrough = false; walkthroughStep = 0; }
render();
});
overlay.querySelector('[data-action="walkthrough-skip"]')?.addEventListener("click", () => {
showWalkthrough = false; walkthroughStep = 0; render();
});
// Address: save // Address: save
overlay.querySelector('[data-action="save-address"]')?.addEventListener("click", async () => { overlay.querySelector('[data-action="save-address"]')?.addEventListener("click", async () => {
const street = (overlay.querySelector("#acct-street") as HTMLInputElement)?.value.trim() || ""; const street = (overlay.querySelector("#acct-street") as HTMLInputElement)?.value.trim() || "";
@ -2559,6 +2809,17 @@ const STYLES = `
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
padding: 0 4px; border: 2px solid var(--rs-bg-surface); line-height: 1; padding: 0 4px; border: 2px solid var(--rs-bg-surface); line-height: 1;
} }
/* Recovery alert dot on avatar */
.recovery-alert-dot {
position: absolute; bottom: -1px; right: -1px;
width: 10px; height: 10px; border-radius: 50%;
background: #f87171; border: 2px solid var(--rs-bg-surface, #1e1e2e);
animation: recovery-pulse 2s ease-in-out infinite;
}
@keyframes recovery-pulse {
0%, 100% { box-shadow: 0 0 4px rgba(248,113,113,0.4); }
50% { box-shadow: 0 0 10px rgba(248,113,113,0.8); }
}
/* Persona switcher in dropdown */ /* Persona switcher in dropdown */
.dropdown-label { .dropdown-label {
@ -2871,6 +3132,79 @@ const ACCOUNT_MODAL_STYLES = `
margin-right: 12px; margin-bottom: 2px; cursor: pointer; margin-right: 12px; margin-bottom: 2px; cursor: pointer;
} }
.conn-share-opt input[type="checkbox"] { margin: 0; accent-color: #06b6d4; } .conn-share-opt input[type="checkbox"] { margin: 0; accent-color: #06b6d4; }
/* Guardian puzzle pieces SVG visualization */
.guardian-viz { display: flex; align-items: center; justify-content: center; gap: 8px; margin: 12px 0; }
.guardian-viz svg { transition: all 0.5s ease; }
.piece-empty { fill: #334155; opacity: 0.4; }
.piece-pending { fill: #fbbf24; opacity: 0.7; }
.piece-accepted { fill: #34d399; }
.piece-accepted.glow { filter: drop-shadow(0 0 6px rgba(52,211,153,0.6)); }
.key-outline { fill: none; stroke: #334155; stroke-width: 2; stroke-dasharray: 4 2; opacity: 0.4; }
.key-assembled { fill: url(#keyGrad); stroke: #34d399; stroke-width: 2; stroke-dasharray: none; opacity: 1; filter: drop-shadow(0 0 8px rgba(52,211,153,0.5)); }
@keyframes piece-slide { from { transform: translateX(0); } to { transform: translateX(var(--slide-x, 0px)) translateY(var(--slide-y, 0px)); } }
@keyframes key-glow { 0% { filter: drop-shadow(0 0 4px rgba(52,211,153,0.3)); } 100% { filter: drop-shadow(0 0 12px rgba(52,211,153,0.7)); } }
/* Recovery active banner */
.recovery-active-banner {
display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-radius: 8px;
background: rgba(52,211,153,0.08); border: 1px solid rgba(52,211,153,0.2);
color: #34d399; font-size: 0.82rem; font-weight: 500; margin-bottom: 12px;
}
.recovery-active-banner svg { flex-shrink: 0; }
/* Drill section */
.drill-section { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--rs-border); }
.drill-btn {
padding: 8px 16px; border-radius: 8px; border: 1px solid rgba(251,191,36,0.3);
background: rgba(251,191,36,0.08); color: #fbbf24; font-size: 0.82rem; font-weight: 600;
cursor: pointer; transition: all 0.2s; width: 100%;
}
.drill-btn:hover { background: rgba(251,191,36,0.15); border-color: rgba(251,191,36,0.5); }
.drill-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.drill-timestamp { font-size: 0.72rem; color: var(--rs-text-muted); margin-top: 6px; }
.drill-live { padding: 12px; border-radius: 8px; background: var(--rs-bg-hover); border: 1px solid var(--rs-border); margin-top: 8px; }
.drill-live-title { font-size: 0.82rem; font-weight: 600; color: #fbbf24; margin-bottom: 8px; }
.drill-success { color: #34d399; font-weight: 600; font-size: 0.85rem; text-align: center; margin: 12px 0; }
/* Solo walkthrough overlay */
.walkthrough-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.7);
-webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
display: flex; align-items: center; justify-content: center; z-index: 10001;
animation: fadeIn 0.3s;
}
.walkthrough-card {
background: var(--rs-bg-surface, #1e1e2e); border: 1px solid var(--rs-border, #334155);
border-radius: 16px; padding: 2rem; max-width: 400px; width: 90%;
text-align: center; animation: slideUp 0.3s;
}
.walkthrough-icon { font-size: 3rem; margin-bottom: 0.5rem; transition: all 0.5s ease; }
.walkthrough-icon.red { filter: drop-shadow(0 0 12px rgba(239,68,68,0.6)); }
.walkthrough-icon.green { filter: drop-shadow(0 0 12px rgba(52,211,153,0.6)); }
.walkthrough-icon.amber { filter: drop-shadow(0 0 12px rgba(251,191,36,0.6)); }
.walkthrough-title {
font-size: 1.1rem; font-weight: 600; margin-bottom: 0.5rem;
background: linear-gradient(135deg, #06b6d4, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
.walkthrough-body { color: var(--rs-text-secondary, #94a3b8); font-size: 0.85rem; line-height: 1.6; margin-bottom: 1.5rem; }
.walkthrough-progress { display: flex; gap: 6px; justify-content: center; margin-bottom: 1rem; }
.walkthrough-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--rs-border, #334155); transition: background 0.3s; }
.walkthrough-dot.active { background: #06b6d4; }
.walkthrough-dot.done { background: #34d399; }
.walkthrough-nav { display: flex; gap: 8px; justify-content: center; }
.walkthrough-btn {
padding: 8px 20px; border-radius: 8px; border: none; font-size: 0.85rem; font-weight: 600;
cursor: pointer; transition: all 0.2s;
}
.walkthrough-btn--next { background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white; }
.walkthrough-btn--next:hover { transform: translateY(-1px); }
.walkthrough-btn--skip { background: transparent; color: var(--rs-text-muted, #64748b); border: 1px solid var(--rs-border, #334155); }
.walkthrough-btn--skip:hover { color: var(--rs-text-primary); }
/* Recovery section urgent pulsing */
.status-dot.pending.recovery-urgent { animation: recovery-pulse 2s ease-in-out infinite; }
@keyframes recovery-pulse { 0%,100% { box-shadow: 0 0 4px rgba(248,113,113,0.4); } 50% { box-shadow: 0 0 10px rgba(248,113,113,0.8); } }
`; `;
const ONBOARDING_STYLES = ` const ONBOARDING_STYLES = `

View File

@ -565,6 +565,7 @@ export interface StoredRecoveryRequest {
initiatedAt: number; initiatedAt: number;
expiresAt: number; expiresAt: number;
completedAt: number | null; completedAt: number | null;
isDrill: boolean;
} }
function rowToRecoveryRequest(row: any): StoredRecoveryRequest { function rowToRecoveryRequest(row: any): StoredRecoveryRequest {
@ -577,6 +578,7 @@ function rowToRecoveryRequest(row: any): StoredRecoveryRequest {
initiatedAt: new Date(row.initiated_at).getTime(), initiatedAt: new Date(row.initiated_at).getTime(),
expiresAt: new Date(row.expires_at).getTime(), expiresAt: new Date(row.expires_at).getTime(),
completedAt: row.completed_at ? new Date(row.completed_at).getTime() : null, completedAt: row.completed_at ? new Date(row.completed_at).getTime() : null,
isDrill: row.is_drill ?? false,
}; };
} }
@ -654,6 +656,30 @@ export async function updateRecoveryRequestStatus(requestId: string, status: str
`; `;
} }
export async function createDrillRequest(
id: string,
userId: string,
threshold: number,
expiresAt: number,
): Promise<StoredRecoveryRequest> {
const rows = await sql`
INSERT INTO recovery_requests (id, user_id, threshold, expires_at, is_drill)
VALUES (${id}, ${userId}, ${threshold}, ${new Date(expiresAt)}, TRUE)
RETURNING *
`;
return rowToRecoveryRequest(rows[0]);
}
export async function setUserLastDrillAt(userId: string): Promise<void> {
await sql`UPDATE users SET last_drill_at = NOW() WHERE id = ${userId}`;
}
export async function getUserLastDrillAt(userId: string): Promise<number | null> {
const rows = await sql`SELECT last_drill_at FROM users WHERE id = ${userId}`;
if (!rows.length || !rows[0].last_drill_at) return null;
return new Date(rows[0].last_drill_at).getTime();
}
export async function getRecoveryApprovals(requestId: string): Promise<Array<{ guardianId: string; approvedAt: number | null }>> { export async function getRecoveryApprovals(requestId: string): Promise<Array<{ guardianId: string; approvedAt: number | null }>> {
const rows = await sql` const rows = await sql`
SELECT guardian_id, approved_at FROM recovery_approvals WHERE request_id = ${requestId} SELECT guardian_id, approved_at FROM recovery_approvals WHERE request_id = ${requestId}

View File

@ -581,6 +581,13 @@ CREATE TABLE IF NOT EXISTS space_email_forwarding (
PRIMARY KEY (space_slug, user_did) PRIMARY KEY (space_slug, user_did)
); );
-- ============================================================================
-- SOCIAL RECOVERY DRILL (test recovery flow without actual recovery)
-- ============================================================================
ALTER TABLE recovery_requests ADD COLUMN IF NOT EXISTS is_drill BOOLEAN DEFAULT FALSE;
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_drill_at TIMESTAMPTZ;
-- Per-space agent mailbox credentials ({space}-agent@rspace.online) -- Per-space agent mailbox credentials ({space}-agent@rspace.online)
CREATE TABLE IF NOT EXISTS agent_mailboxes ( CREATE TABLE IF NOT EXISTS agent_mailboxes (
space_slug TEXT PRIMARY KEY, space_slug TEXT PRIMARY KEY,

View File

@ -148,6 +148,9 @@ import {
getProfileEmailsByDids, getProfileEmailsByDids,
getAgentMailbox, getAgentMailbox,
listAllAgentMailboxes, listAllAgentMailboxes,
createDrillRequest,
setUserLastDrillAt,
getUserLastDrillAt,
} from './db.js'; } from './db.js';
import { import {
isMailcowConfigured, isMailcowConfigured,
@ -1582,17 +1585,21 @@ app.get('/api/account/status', async (c) => {
const creds = await getUserCredentials(userId); const creds = await getUserCredentials(userId);
const hasMultiDevice = creds.length > 1; const hasMultiDevice = creds.length > 1;
// Check guardians // Check guardians (count accepted ones for recovery readiness)
let guardianCount = 0; let guardianCount = 0;
let acceptedGuardianCount = 0;
try { try {
const rows = await sql`SELECT COUNT(*) as count FROM guardians WHERE user_id = ${userId} AND status != 'removed'`; const rows = await sql`SELECT status, COUNT(*) as count FROM guardians WHERE user_id = ${userId} AND status != 'revoked' GROUP BY status`;
guardianCount = parseInt(rows[0]?.count || '0'); for (const r of rows) {
const cnt = parseInt(r.count || '0');
guardianCount += cnt;
if (r.status === 'accepted') acceptedGuardianCount = cnt;
}
} catch { /* ignore */ } } catch { /* ignore */ }
const hasRecovery = guardianCount >= 2; const hasRecovery = acceptedGuardianCount >= 2;
// Check encrypted backup // Last drill timestamp
// (This is client-side localStorage, but we can infer from whether they have any synced docs) const lastDrillAt = await getUserLastDrillAt(userId);
// For now, we just return the server-side info and let the client check localStorage
return c.json({ return c.json({
email: hasEmail, email: hasEmail,
@ -1601,6 +1608,8 @@ app.get('/api/account/status', async (c) => {
socialRecovery: hasRecovery, socialRecovery: hasRecovery,
credentialCount: creds.length, credentialCount: creds.length,
guardianCount, guardianCount,
acceptedGuardianCount,
lastDrillAt,
}); });
}); });
@ -3260,7 +3269,10 @@ app.get('/approve', (c) => {
.status.success { background: rgba(34,197,94,0.1); color: #22c55e; } .status.success { background: rgba(34,197,94,0.1); color: #22c55e; }
.status.error { background: rgba(239,68,68,0.1); color: #ef4444; } .status.error { background: rgba(239,68,68,0.1); color: #ef4444; }
.status.warning { background: rgba(234,179,8,0.1); color: #eab308; } .status.warning { background: rgba(234,179,8,0.1); color: #eab308; }
.status.drill { background: rgba(251,191,36,0.1); color: #fbbf24; }
.drill-badge { display: inline-block; padding: 3px 10px; border-radius: 6px; background: rgba(251,191,36,0.15); color: #fbbf24; font-size: 0.7rem; font-weight: 700; letter-spacing: 0.05em; margin-bottom: 0.75rem; }
.btn { display: inline-block; padding: 0.75rem 2rem; background: linear-gradient(90deg, #22c55e, #16a34a); color: #fff; border: none; border-radius: 8px; font-weight: 600; font-size: 1rem; cursor: pointer; transition: transform 0.2s; margin-top: 1rem; } .btn { display: inline-block; padding: 0.75rem 2rem; background: linear-gradient(90deg, #22c55e, #16a34a); color: #fff; border: none; border-radius: 8px; font-weight: 600; font-size: 1rem; cursor: pointer; transition: transform 0.2s; margin-top: 1rem; }
.btn.drill { background: linear-gradient(90deg, #fbbf24, #f59e0b); color: #1a1a2e; }
.btn:hover { transform: translateY(-2px); } .btn:hover { transform: translateY(-2px); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; } .btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.hidden { display: none; } .hidden { display: none; }
@ -3269,7 +3281,8 @@ app.get('/approve', (c) => {
<body> <body>
<div class="card"> <div class="card">
<div class="icon">&#128272;</div> <div class="icon">&#128272;</div>
<h1>Recovery Approval</h1> <h1 id="title">Recovery Approval</h1>
<div id="drill-badge" class="drill-badge hidden">DRILL TEST ONLY</div>
<div id="status" class="status loading">Verifying approval link...</div> <div id="status" class="status loading">Verifying approval link...</div>
<button id="approve-btn" class="btn hidden" onclick="doApprove()">Confirm Approval</button> <button id="approve-btn" class="btn hidden" onclick="doApprove()">Confirm Approval</button>
</div> </div>
@ -3279,7 +3292,10 @@ app.get('/approve', (c) => {
const approvalToken = params.get('token'); const approvalToken = params.get('token');
const statusEl = document.getElementById('status'); const statusEl = document.getElementById('status');
const btn = document.getElementById('approve-btn'); const btn = document.getElementById('approve-btn');
const titleEl = document.getElementById('title');
const drillBadge = document.getElementById('drill-badge');
// Pre-check if this is a drill by looking at query params or checking the token info
async function init() { async function init() {
if (!approvalToken) { if (!approvalToken) {
statusEl.className = 'status error'; statusEl.className = 'status error';
@ -3308,9 +3324,18 @@ app.get('/approve', (c) => {
btn.classList.add('hidden'); btn.classList.add('hidden');
return; return;
} }
statusEl.className = 'status success'; // Show drill badge if this was a drill
statusEl.innerHTML = 'Recovery approved! <strong>' + data.approvalCount + ' of ' + data.threshold + '</strong> approvals received.' + if (data.isDrill) {
(data.approvalCount >= data.threshold ? '<br><br>The account owner can now recover their account.' : '<br><br>Waiting for more guardians to approve.'); drillBadge.classList.remove('hidden');
titleEl.textContent = 'Recovery Drill';
statusEl.className = 'status success';
statusEl.innerHTML = 'Drill approved! <strong>' + data.approvalCount + ' of ' + data.threshold + '</strong> approvals received.' +
(data.approvalCount >= data.threshold ? '<br><br>The drill is complete. The account owner\\'s recovery setup is working!' : '<br><br>Waiting for more guardians to approve the drill.');
} else {
statusEl.className = 'status success';
statusEl.innerHTML = 'Recovery approved! <strong>' + data.approvalCount + ' of ' + data.threshold + '</strong> approvals received.' +
(data.approvalCount >= data.threshold ? '<br><br>The account owner can now recover their account.' : '<br><br>Waiting for more guardians to approve.');
}
btn.classList.add('hidden'); btn.classList.add('hidden');
} catch { } catch {
statusEl.className = 'status error'; statusEl.className = 'status error';
@ -3345,15 +3370,26 @@ app.post('/api/recovery/social/approve', async (c) => {
if (request.approvalCount >= request.threshold && request.status === 'pending') { if (request.approvalCount >= request.threshold && request.status === 'pending') {
await updateRecoveryRequestStatus(request.id, 'approved'); await updateRecoveryRequestStatus(request.id, 'approved');
// Notify account owner that recovery is approved if (request.isDrill) {
// Auto-complete drills when threshold met + record timestamp
await updateRecoveryRequestStatus(request.id, 'completed');
await setUserLastDrillAt(request.userId);
}
// Notify account owner
const eventType = request.isDrill ? 'recovery_drill_complete' : 'recovery_approved';
const title = request.isDrill ? 'Recovery drill complete!' : 'Account recovery approved';
const body = request.isDrill
? `${request.approvalCount}/${request.threshold} guardians approved your drill. Your recovery setup is working!`
: `${request.approvalCount}/${request.threshold} guardians approved. You can now recover your account.`;
notify({ notify({
userDid: request.userId, userDid: request.userId,
category: 'system', category: 'system',
eventType: 'recovery_approved', eventType,
title: 'Account recovery approved', title,
body: `${request.approvalCount}/${request.threshold} guardians approved. You can now recover your account.`, body,
metadata: { recoveryRequestId: request.id }, metadata: { recoveryRequestId: request.id, isDrill: request.isDrill },
actionUrl: `https://auth.rspace.online/recover/social?id=${request.id}`, actionUrl: request.isDrill ? undefined : `https://auth.rspace.online/recover/social?id=${request.id}`,
}).catch(() => {}); }).catch(() => {});
} }
@ -3362,6 +3398,7 @@ app.post('/api/recovery/social/approve', async (c) => {
approvalCount: request.approvalCount, approvalCount: request.approvalCount,
threshold: request.threshold, threshold: request.threshold,
status: request.approvalCount >= request.threshold ? 'approved' : 'pending', status: request.approvalCount >= request.threshold ? 'approved' : 'pending',
isDrill: request.isDrill,
}); });
}); });
@ -3418,6 +3455,167 @@ app.post('/api/recovery/social/:id/complete', async (c) => {
}); });
}); });
// ============================================================================
// RECOVERY DRILL (test recovery flow)
// ============================================================================
/**
* POST /api/recovery/drill/initiate start a recovery drill (auth required)
* Sends drill-flagged notifications to guardians
*/
app.post('/api/recovery/drill/initiate', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
const userId = claims.sub as string;
const user = await getUserById(userId);
if (!user) return c.json({ error: 'User not found' }, 404);
const guardians = await getGuardians(userId);
const accepted = guardians.filter(g => g.status === 'accepted');
if (accepted.length < 2) {
return c.json({ error: 'Need at least 2 accepted guardians to run a drill' }, 400);
}
// Check no active non-drill recovery request
const existing = await getActiveRecoveryRequest(userId);
if (existing && !existing.isDrill) {
return c.json({ error: 'An active recovery request already exists. Cannot start a drill.' }, 400);
}
// Create drill request (24h expiry, same 2-of-3 threshold)
const drillId = generateToken();
const expiresAt = Date.now() + 24 * 60 * 60 * 1000;
await createDrillRequest(drillId, userId, 2, expiresAt);
// Create approval tokens and notify guardians
const drillGuardians: Array<{ id: string; name: string; status: string }> = [];
for (const guardian of accepted) {
const approvalToken = generateToken();
await createRecoveryApproval(drillId, guardian.id, approvalToken);
const approveUrl = `https://auth.rspace.online/approve?token=${approvalToken}`;
drillGuardians.push({ id: guardian.id, name: guardian.name, status: 'pending' });
// In-app notification
if (guardian.guardianUserId) {
notify({
userDid: guardian.guardianUserId,
category: 'system',
eventType: 'recovery_drill',
title: `Recovery drill from ${user.username}`,
body: `This is a test! ${user.username} is testing their recovery setup. Please verify and approve.`,
actionUrl: approveUrl,
actorUsername: user.username,
metadata: { recoveryRequestId: drillId, guardianId: guardian.id, isDrill: true },
}).catch(() => {});
}
// Email notification
if (guardian.email && smtpTransport) {
try {
await smtpTransport.sendMail({
from: CONFIG.smtp.from,
to: guardian.email,
subject: `Recovery DRILL from ${user.username} — rStack`,
text: [
`Hi ${guardian.name},`,
'',
`This is a TEST! ${user.username} is running a recovery drill to test their guardian setup.`,
'',
'Please verify their identity and approve:',
approveUrl,
'',
'This is NOT a real recovery — just a test. The drill expires in 24 hours.',
'',
'— rStack Identity',
].join('\n'),
html: `
<!DOCTYPE html><html><head><meta charset="utf-8"></head>
<body style="margin:0;padding:0;background:#1a1a2e;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#1a1a2e;padding:40px 20px;">
<tr><td align="center">
<table width="480" cellpadding="0" cellspacing="0" style="background:#16213e;border-radius:12px;border:1px solid rgba(255,255,255,0.1);">
<tr><td style="padding:32px 32px 24px;text-align:center;">
<div style="font-size:36px;margin-bottom:8px;">&#128272;</div>
<h1 style="margin:0;font-size:22px;background:linear-gradient(90deg,#fbbf24,#f59e0b);-webkit-background-clip:text;-webkit-text-fill-color:transparent;">Recovery DRILL</h1>
<div style="display:inline-block;margin-top:8px;padding:4px 12px;border-radius:6px;background:rgba(251,191,36,0.15);color:#fbbf24;font-size:0.75rem;font-weight:700;letter-spacing:0.05em;">TEST ONLY</div>
</td></tr>
<tr><td style="padding:0 32px 24px;color:#e2e8f0;font-size:15px;line-height:1.6;">
<p>Hi <strong>${guardian.name}</strong>,</p>
<p><strong>${user.username}</strong> is running a <strong>recovery drill</strong> to test their guardian setup.</p>
<p style="color:#94a3b8;font-size:13px;">This is NOT a real recovery. Please verify their identity and approve the drill.</p>
</td></tr>
<tr><td style="padding:0 32px 32px;text-align:center;">
<a href="${approveUrl}" style="display:inline-block;padding:12px 32px;background:linear-gradient(90deg,#fbbf24,#f59e0b);color:#1a1a2e;text-decoration:none;border-radius:8px;font-weight:600;font-size:15px;">Approve Drill</a>
</td></tr>
<tr><td style="padding:0 32px 32px;color:#94a3b8;font-size:13px;line-height:1.5;">
<p>This drill expires in <strong>24 hours</strong>.</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>`,
});
} catch (err) {
console.error(`EncryptID: Failed to send drill email to ${guardian.email}:`, err);
}
}
}
return c.json({
success: true,
drillId,
guardians: drillGuardians,
});
});
/**
* GET /api/recovery/drill/:id/status check drill status (auth required)
*/
app.get('/api/recovery/drill/:id/status', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
const { id } = c.req.param();
const request = await getRecoveryRequest(id);
if (!request) return c.json({ error: 'Not found' }, 404);
if (!request.isDrill) return c.json({ error: 'Not a drill' }, 400);
if (request.userId !== claims.sub) return c.json({ error: 'Not your drill' }, 403);
const approvals = await getRecoveryApprovals(request.id);
return c.json({
id: request.id,
status: request.status,
threshold: request.threshold,
approvalCount: request.approvalCount,
approvals: approvals.map(a => ({
guardianId: a.guardianId,
approved: !!a.approvedAt,
})),
expiresAt: request.expiresAt,
});
});
/**
* POST /api/recovery/drill/:id/complete mark drill as completed (no recovery token issued)
*/
app.post('/api/recovery/drill/:id/complete', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
const { id } = c.req.param();
const request = await getRecoveryRequest(id);
if (!request) return c.json({ error: 'Not found' }, 404);
if (!request.isDrill) return c.json({ error: 'Not a drill' }, 400);
if (request.userId !== claims.sub) return c.json({ error: 'Not your drill' }, 403);
await updateRecoveryRequestStatus(id, 'completed');
await setUserLastDrillAt(claims.sub as string);
return c.json({ success: true, message: 'Drill completed successfully!' });
});
// ============================================================================ // ============================================================================
// DEVICE LINKING ROUTES // DEVICE LINKING ROUTES
// ============================================================================ // ============================================================================