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 <noreply@anthropic.com>
This commit is contained in:
parent
98d3ce4d2f
commit
cdb62e2ee8
|
|
@ -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<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 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 {
|
|||
|
||||
<div class="error" id="acct-error"></div>
|
||||
</div>
|
||||
${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) =>
|
||||
`<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 isOpen = openSection === "email";
|
||||
const done = acctStatus ? acctStatus.email : null;
|
||||
|
|
@ -1484,6 +1565,39 @@ export class RStackIdentity extends HTMLElement {
|
|||
</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 isOpen = openSection === "recovery";
|
||||
let body = "";
|
||||
|
|
@ -1491,6 +1605,19 @@ export class RStackIdentity extends HTMLElement {
|
|||
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>`;
|
||||
} 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
|
||||
? `<div class="contact-list">${guardians.map(g => `
|
||||
<div class="contact-item">
|
||||
|
|
@ -1505,13 +1632,63 @@ export class RStackIdentity extends HTMLElement {
|
|||
</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" 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 = `
|
||||
<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>
|
||||
${activeBanner}
|
||||
${renderGuardianPiecesSVG(slots, recoveryActive)}
|
||||
${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-email" type="email" placeholder="Email (optional)" />
|
||||
|
|
@ -1519,15 +1696,18 @@ export class RStackIdentity extends HTMLElement {
|
|||
</div>` : ""}
|
||||
${guardiansHTML}
|
||||
${infoHTML}
|
||||
${walkthroughLink}
|
||||
${drillHTML}
|
||||
<div class="error" id="recovery-error"></div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
const done = acctStatus ? acctStatus.socialRecovery : null;
|
||||
const urgentClass = done === false ? " recovery-urgent" : "";
|
||||
return `
|
||||
<div class="account-section${isOpen ? " open" : ""}${done === false ? " section--warning" : ""}">
|
||||
<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>
|
||||
</div>
|
||||
${body}
|
||||
|
|
@ -1975,7 +2155,9 @@ 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; }
|
||||
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();
|
||||
setTimeout(() => (overlay.querySelector("#acct-guardian-name") as HTMLInputElement)?.focus(), 50);
|
||||
} catch (e: any) {
|
||||
|
|
@ -2003,7 +2185,9 @@ 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; }
|
||||
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();
|
||||
} catch (e: any) {
|
||||
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
|
||||
overlay.querySelector('[data-action="save-address"]')?.addEventListener("click", async () => {
|
||||
const street = (overlay.querySelector("#acct-street") as HTMLInputElement)?.value.trim() || "";
|
||||
|
|
@ -2559,6 +2809,17 @@ const STYLES = `
|
|||
display: flex; align-items: center; justify-content: center;
|
||||
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 */
|
||||
.dropdown-label {
|
||||
|
|
@ -2871,6 +3132,79 @@ const ACCOUNT_MODAL_STYLES = `
|
|||
margin-right: 12px; margin-bottom: 2px; cursor: pointer;
|
||||
}
|
||||
.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 = `
|
||||
|
|
|
|||
|
|
@ -565,6 +565,7 @@ export interface StoredRecoveryRequest {
|
|||
initiatedAt: number;
|
||||
expiresAt: number;
|
||||
completedAt: number | null;
|
||||
isDrill: boolean;
|
||||
}
|
||||
|
||||
function rowToRecoveryRequest(row: any): StoredRecoveryRequest {
|
||||
|
|
@ -577,6 +578,7 @@ function rowToRecoveryRequest(row: any): StoredRecoveryRequest {
|
|||
initiatedAt: new Date(row.initiated_at).getTime(),
|
||||
expiresAt: new Date(row.expires_at).getTime(),
|
||||
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 }>> {
|
||||
const rows = await sql`
|
||||
SELECT guardian_id, approved_at FROM recovery_approvals WHERE request_id = ${requestId}
|
||||
|
|
|
|||
|
|
@ -581,6 +581,13 @@ CREATE TABLE IF NOT EXISTS space_email_forwarding (
|
|||
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)
|
||||
CREATE TABLE IF NOT EXISTS agent_mailboxes (
|
||||
space_slug TEXT PRIMARY KEY,
|
||||
|
|
|
|||
|
|
@ -148,6 +148,9 @@ import {
|
|||
getProfileEmailsByDids,
|
||||
getAgentMailbox,
|
||||
listAllAgentMailboxes,
|
||||
createDrillRequest,
|
||||
setUserLastDrillAt,
|
||||
getUserLastDrillAt,
|
||||
} from './db.js';
|
||||
import {
|
||||
isMailcowConfigured,
|
||||
|
|
@ -1582,17 +1585,21 @@ app.get('/api/account/status', async (c) => {
|
|||
const creds = await getUserCredentials(userId);
|
||||
const hasMultiDevice = creds.length > 1;
|
||||
|
||||
// Check guardians
|
||||
// Check guardians (count accepted ones for recovery readiness)
|
||||
let guardianCount = 0;
|
||||
let acceptedGuardianCount = 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');
|
||||
const rows = await sql`SELECT status, COUNT(*) as count FROM guardians WHERE user_id = ${userId} AND status != 'revoked' GROUP BY status`;
|
||||
for (const r of rows) {
|
||||
const cnt = parseInt(r.count || '0');
|
||||
guardianCount += cnt;
|
||||
if (r.status === 'accepted') acceptedGuardianCount = cnt;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
const hasRecovery = guardianCount >= 2;
|
||||
const hasRecovery = acceptedGuardianCount >= 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
|
||||
// Last drill timestamp
|
||||
const lastDrillAt = await getUserLastDrillAt(userId);
|
||||
|
||||
return c.json({
|
||||
email: hasEmail,
|
||||
|
|
@ -1601,6 +1608,8 @@ app.get('/api/account/status', async (c) => {
|
|||
socialRecovery: hasRecovery,
|
||||
credentialCount: creds.length,
|
||||
guardianCount,
|
||||
acceptedGuardianCount,
|
||||
lastDrillAt,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -3260,7 +3269,10 @@ app.get('/approve', (c) => {
|
|||
.status.success { background: rgba(34,197,94,0.1); color: #22c55e; }
|
||||
.status.error { background: rgba(239,68,68,0.1); color: #ef4444; }
|
||||
.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.drill { background: linear-gradient(90deg, #fbbf24, #f59e0b); color: #1a1a2e; }
|
||||
.btn:hover { transform: translateY(-2px); }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
|
||||
.hidden { display: none; }
|
||||
|
|
@ -3269,7 +3281,8 @@ app.get('/approve', (c) => {
|
|||
<body>
|
||||
<div class="card">
|
||||
<div class="icon">🔐</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>
|
||||
<button id="approve-btn" class="btn hidden" onclick="doApprove()">Confirm Approval</button>
|
||||
</div>
|
||||
|
|
@ -3279,7 +3292,10 @@ app.get('/approve', (c) => {
|
|||
const approvalToken = params.get('token');
|
||||
const statusEl = document.getElementById('status');
|
||||
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() {
|
||||
if (!approvalToken) {
|
||||
statusEl.className = 'status error';
|
||||
|
|
@ -3308,9 +3324,18 @@ app.get('/approve', (c) => {
|
|||
btn.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
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.');
|
||||
// Show drill badge if this was a drill
|
||||
if (data.isDrill) {
|
||||
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');
|
||||
} catch {
|
||||
statusEl.className = 'status error';
|
||||
|
|
@ -3345,15 +3370,26 @@ app.post('/api/recovery/social/approve', async (c) => {
|
|||
if (request.approvalCount >= request.threshold && request.status === 'pending') {
|
||||
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({
|
||||
userDid: request.userId,
|
||||
category: 'system',
|
||||
eventType: 'recovery_approved',
|
||||
title: 'Account recovery approved',
|
||||
body: `${request.approvalCount}/${request.threshold} guardians approved. You can now recover your account.`,
|
||||
metadata: { recoveryRequestId: request.id },
|
||||
actionUrl: `https://auth.rspace.online/recover/social?id=${request.id}`,
|
||||
eventType,
|
||||
title,
|
||||
body,
|
||||
metadata: { recoveryRequestId: request.id, isDrill: request.isDrill },
|
||||
actionUrl: request.isDrill ? undefined : `https://auth.rspace.online/recover/social?id=${request.id}`,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
|
|
@ -3362,6 +3398,7 @@ app.post('/api/recovery/social/approve', async (c) => {
|
|||
approvalCount: request.approvalCount,
|
||||
threshold: request.threshold,
|
||||
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;">🔐</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
|
||||
// ============================================================================
|
||||
|
|
|
|||
Loading…
Reference in New Issue