Merge branch 'dev'
CI/CD / deploy (push) Successful in 2m58s
Details
CI/CD / deploy (push) Successful in 2m58s
Details
This commit is contained in:
commit
bc2b6ba23c
|
|
@ -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 = `
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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">🔐</div>
|
<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>
|
<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;
|
||||||
}
|
}
|
||||||
|
// 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.className = 'status success';
|
||||||
statusEl.innerHTML = 'Recovery approved! <strong>' + data.approvalCount + ' of ' + data.threshold + '</strong> approvals received.' +
|
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.');
|
(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;">🔐</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
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue