From cdb62e2ee857ac6214818403deacbef89e6341f2 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 11 Apr 2026 10:48:55 -0400 Subject: [PATCH] feat(encryptid): social recovery guardian UX enhancements - Red pulsing alert dot on avatar when social recovery not configured - SVG puzzle piece visualization for guardian slots (empty/pending/accepted) - Key assembly animation when 2+ guardians accepted - Recovery drill system: test the full guardian approval flow without actual recovery - POST /api/recovery/drill/initiate, GET .../status, POST .../complete - Drill-specific emails with "TEST ONLY" branding - Live polling UI with puzzle pieces filling in as guardians approve - Drill timestamp tracking (last_drill_at on users table) - Solo walkthrough modal: 5-step animated preview of how recovery works - Approval page detects drill flag, shows DRILL badge - Account status now returns acceptedGuardianCount and lastDrillAt - Recovery section shows emergency override messaging Co-Authored-By: Claude Opus 4.6 --- shared/components/rstack-identity.ts | 348 ++++++++++++++++++++++++++- src/encryptid/db.ts | 26 ++ src/encryptid/schema.sql | 7 + src/encryptid/server.ts | 232 ++++++++++++++++-- 4 files changed, 589 insertions(+), 24 deletions(-) diff --git a/shared/components/rstack-identity.ts b/shared/components/rstack-identity.ts index 3dbfbd5f..43c7be98 100644 --- a/shared/components/rstack-identity.ts +++ b/shared/components/rstack-identity.ts @@ -421,6 +421,9 @@ export class RStackIdentity extends HTMLElement { // Nudge users without a second device to link one this.#checkDeviceNudge(); + // Show recovery alert dot on avatar if not set up + this.#checkRecoveryBadge(); + // Propagate login/logout across tabs via storage events window.addEventListener("storage", this.#onStorageChange); } @@ -458,6 +461,44 @@ export class RStackIdentity extends HTMLElement { } 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() { const session = getSession(); if (!session?.accessToken) return; @@ -1300,7 +1341,7 @@ export class RStackIdentity extends HTMLElement { let openSection: string | null = options?.openSection ?? null; // 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 let guardians: { id: string; name: string; email?: string; status: string }[] = []; @@ -1308,6 +1349,13 @@ export class RStackIdentity extends HTMLElement { let guardiansLoaded = false; let guardiansLoading = false; + // Drill state + let activeDrillId: string | null = null; + let drillPollingTimer: ReturnType | 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 devicesLoaded = false; let devicesLoading = false; @@ -1326,7 +1374,10 @@ export class RStackIdentity extends HTMLElement { let emailStep: "input" | "verify" = "input"; let emailAddr = ""; - const close = () => overlay.remove(); + const close = () => { + if (drillPollingTimer) { clearInterval(drillPollingTimer); drillPollingTimer = null; } + overlay.remove(); + }; // Load account completion status const loadStatus = async () => { @@ -1388,10 +1439,40 @@ export class RStackIdentity extends HTMLElement {
+ ${showWalkthrough ? renderWalkthrough() : ""} `; 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) => + `
` + ).join(""); + const isLast = walkthroughStep >= WALKTHROUGH_STEPS.length - 1; + return ` +
+
+
${step.icon}
+
${step.title}
+
${step.body}
+
${dots}
+
+ + ${!isLast ? `` : ``} +
+
+
`; + }; + const renderEmailSection = () => { const isOpen = openSection === "email"; const done = acctStatus ? acctStatus.email : null; @@ -1484,6 +1565,39 @@ export class RStackIdentity extends HTMLElement { `; }; + 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 `
+ + + + + + + + ${/* Piece 1 (left) */""} + + + 1 + + ${/* Piece 2 (center) */""} + + + 2 + + ${/* Piece 3 (right) */""} + + + 3 + + ${/* Key icon (right side) */""} + + + + +
`; + }; + const renderRecoverySection = () => { const isOpen = openSection === "recovery"; let body = ""; @@ -1491,6 +1605,19 @@ export class RStackIdentity extends HTMLElement { if (guardiansLoading) { body = ``; } 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 ? `
${guardians.map(g => `
@@ -1505,13 +1632,63 @@ export class RStackIdentity extends HTMLElement {
`).join("")}
` : ""; - const infoHTML = guardians.length < 2 + // Recovery active banner + const activeBanner = recoveryActive ? ` +
+ + Recovery active โ€” ${guardiansThreshold} of ${guardians.length} guardians can recover your account +
` : ""; + + // Walkthrough link (show after 2+ guardians) + const walkthroughLink = guardians.length >= 2 ? ` +
+ Preview how recovery works +
` : ""; + + // 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 = `
+
+
${drillComplete ? "Drill Complete!" : "Recovery Drill in Progress..."}
+ ${renderGuardianPiecesSVG(drillSlots, drillComplete)} +
+ ${drillStatus.approvalCount}/${drillStatus.threshold} guardians approved +
+ ${drillComplete ? `
Your recovery setup is working!
` : `
Checking every 5 seconds...
`} +
+
`; + } else { + drillHTML = `
+ + ${lastDrillStr ? `
Last drill: ${lastDrillStr}
` : `
No drill run yet โ€” test your setup!
`} +
`; + } + } + + const infoHTML = !recoveryActive ? `
Add at least 2 trusted guardians to enable social recovery. Threshold: ${guardiansThreshold} of ${Math.max(guardians.length, 2)} needed to recover.
` - : `
Social recovery is active. ${guardiansThreshold} of ${guardians.length} guardians needed to recover your account.
`; + : `
Recovery guardians can also help override account freezes and flow restrictions in emergencies.
`; body = ` `; } } const done = acctStatus ? acctStatus.socialRecovery : null; + const urgentClass = done === false ? " recovery-urgent" : ""; return `