feat(encryptid): complete social recovery end-to-end flow

Add /recover/social page for users to finalize account recovery after
guardian approvals, fix status filter so approved requests remain
findable, return requestId from initiation API with tracking link on
login page, and add actionUrl to recovery notifications.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-23 14:32:38 -07:00
parent cb92a7f6d8
commit a9ff1cf94b
2 changed files with 223 additions and 4 deletions

View File

@ -540,7 +540,7 @@ export async function getRecoveryRequest(requestId: string): Promise<StoredRecov
export async function getActiveRecoveryRequest(userId: string): Promise<StoredRecoveryRequest | null> {
const rows = await sql`
SELECT * FROM recovery_requests
WHERE user_id = ${userId} AND status = 'pending' AND expires_at > NOW()
WHERE user_id = ${userId} AND status IN ('pending', 'approved') AND expires_at > NOW()
ORDER BY initiated_at DESC LIMIT 1
`;
if (rows.length === 0) return null;

View File

@ -2257,6 +2257,220 @@ app.get('/recover', (c) => {
`);
});
// ============================================================================
// SOCIAL RECOVERY COMPLETION PAGE
// ============================================================================
/**
* GET /recover/social page where user finalizes social recovery
* After guardians approve, user opens this to register a new passkey
*/
app.get('/recover/social', (c) => {
return c.html(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Social Recovery rStack Identity</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; color: #fff; padding: 1rem; }
.card { background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 16px; padding: 2.5rem; max-width: 480px; width: 100%; text-align: center; }
.icon { font-size: 3rem; margin-bottom: 0.5rem; }
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; background: linear-gradient(90deg, #00d4ff, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.sub { color: #94a3b8; margin-bottom: 1.5rem; font-size: 0.9rem; line-height: 1.5; }
.status { padding: 1rem; border-radius: 8px; margin-bottom: 1rem; font-size: 0.9rem; line-height: 1.5; }
.status.loading { background: rgba(6,182,212,0.1); color: #06b6d4; }
.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; }
.progress-bar { width: 100%; height: 8px; background: rgba(255,255,255,0.1); border-radius: 4px; margin: 1rem 0; overflow: hidden; }
.progress-fill { height: 100%; background: linear-gradient(90deg, #00d4ff, #7c3aed); border-radius: 4px; transition: width 0.5s; }
.approval-list { text-align: left; margin: 1rem 0; }
.approval-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0; border-bottom: 1px solid rgba(255,255,255,0.05); font-size: 0.85rem; color: #94a3b8; }
.approval-item .dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.dot.approved { background: #22c55e; }
.dot.pending { background: #64748b; }
.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:hover { transform: translateY(-2px); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.hidden { display: none; }
.link { color: #00d4ff; text-decoration: none; }
.link:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="card">
<div class="icon">&#128101;</div>
<h1>Social Recovery</h1>
<p class="sub">Your guardians are helping you recover your account</p>
<div id="status" class="status loading">Checking recovery status...</div>
<div id="progress-section" class="hidden">
<div class="progress-bar"><div id="progress-fill" class="progress-fill" style="width:0%"></div></div>
<div id="approval-list" class="approval-list"></div>
</div>
<button id="complete-btn" class="btn hidden" onclick="completeRecovery()">Complete Recovery</button>
<div id="register-section" class="hidden">
<button id="register-btn" class="btn" onclick="registerNewPasskey()">Add New Passkey</button>
</div>
<div id="success-section" class="hidden">
<p style="color:#94a3b8;margin-top:1rem;">You can now <a class="link" href="/">sign in with your new passkey</a> on any r*.online app.</p>
</div>
</div>
<script>
const params = new URLSearchParams(window.location.search);
const requestId = params.get('id');
const statusEl = document.getElementById('status');
const progressSection = document.getElementById('progress-section');
const progressFill = document.getElementById('progress-fill');
const approvalList = document.getElementById('approval-list');
const completeBtn = document.getElementById('complete-btn');
const registerSection = document.getElementById('register-section');
const successSection = document.getElementById('success-section');
let sessionToken = null;
let userId = null;
let username = null;
let pollTimer = null;
async function checkStatus() {
if (!requestId) {
statusEl.className = 'status error';
statusEl.textContent = 'No recovery request ID. Use the link from your recovery email.';
return;
}
try {
const res = await fetch('/api/recovery/social/' + encodeURIComponent(requestId) + '/status');
if (!res.ok) {
const err = await res.json().catch(() => ({}));
statusEl.className = 'status error';
statusEl.textContent = err.error || 'Recovery request not found.';
return;
}
const data = await res.json();
const pct = Math.round((data.approvalCount / data.threshold) * 100);
progressFill.style.width = Math.min(pct, 100) + '%';
// Show approval dots
approvalList.innerHTML = data.approvals.map(a =>
'<div class="approval-item"><span class="dot ' + (a.approved ? 'approved' : 'pending') + '"></span>' +
'Guardian ' + a.guardianId.slice(0, 8) + '... — ' + (a.approved ? 'Approved' : 'Waiting') + '</div>'
).join('');
progressSection.classList.remove('hidden');
if (data.status === 'approved') {
clearInterval(pollTimer);
statusEl.className = 'status success';
statusEl.textContent = 'Recovery approved! ' + data.approvalCount + '/' + data.threshold + ' guardians confirmed. Complete your recovery below.';
completeBtn.classList.remove('hidden');
} else if (data.status === 'completed') {
clearInterval(pollTimer);
statusEl.className = 'status success';
statusEl.textContent = 'This recovery has already been completed.';
progressSection.classList.add('hidden');
} else if (data.status === 'expired') {
clearInterval(pollTimer);
statusEl.className = 'status error';
statusEl.textContent = 'This recovery request has expired. Please start a new one.';
} else {
// pending
statusEl.className = 'status loading';
statusEl.textContent = 'Waiting for guardian approvals... ' + data.approvalCount + '/' + data.threshold + ' so far. This page auto-refreshes.';
}
} catch (e) {
statusEl.className = 'status error';
statusEl.textContent = 'Failed to check status: ' + e.message;
}
}
async function completeRecovery() {
completeBtn.disabled = true;
completeBtn.textContent = 'Completing...';
try {
const res = await fetch('/api/recovery/social/' + encodeURIComponent(requestId) + '/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
const data = await res.json();
if (!data.success) {
throw new Error(data.error || 'Failed to complete recovery');
}
sessionToken = data.token;
userId = data.userId;
username = data.username;
statusEl.className = 'status success';
statusEl.textContent = 'Recovery verified! Now register a new passkey for your account.';
completeBtn.classList.add('hidden');
registerSection.classList.remove('hidden');
} catch (e) {
statusEl.className = 'status error';
statusEl.textContent = 'Failed: ' + e.message;
completeBtn.disabled = false;
completeBtn.textContent = 'Complete Recovery';
}
}
async function registerNewPasskey() {
const btn = document.getElementById('register-btn');
btn.disabled = true;
btn.textContent = 'Registering...';
try {
const startRes = await fetch('/api/register/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + sessionToken },
body: JSON.stringify({ username, displayName: username }),
});
const { options } = await startRes.json();
options.challenge = Uint8Array.from(atob(options.challenge.replace(/-/g,'+').replace(/_/g,'/')), c => c.charCodeAt(0));
options.user.id = Uint8Array.from(atob(options.user.id.replace(/-/g,'+').replace(/_/g,'/')), c => c.charCodeAt(0));
const credential = await navigator.credentials.create({ publicKey: options });
const credentialData = {
credentialId: btoa(String.fromCharCode(...new Uint8Array(credential.rawId))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,''),
publicKey: btoa(String.fromCharCode(...new Uint8Array(credential.response.getPublicKey?.() || new ArrayBuffer(0)))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,''),
transports: credential.response.getTransports?.() || [],
};
const completeRes = await fetch('/api/register/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ challenge: options.challenge, credential: credentialData, userId, username }),
});
const result = await completeRes.json();
if (result.success) {
statusEl.className = 'status success';
statusEl.textContent = 'New passkey registered successfully!';
registerSection.classList.add('hidden');
successSection.classList.remove('hidden');
progressSection.classList.add('hidden');
} else {
throw new Error(result.error || 'Registration failed');
}
} catch (e) {
statusEl.className = 'status error';
statusEl.textContent = 'Failed to register passkey: ' + e.message;
btn.disabled = false;
btn.textContent = 'Add New Passkey';
}
}
// Initial check + poll every 10s while pending
checkStatus();
pollTimer = setInterval(checkStatus, 10000);
</script>
</body>
</html>
`);
});
// ============================================================================
// GUARDIAN MANAGEMENT ROUTES
// ============================================================================
@ -2757,7 +2971,7 @@ app.post('/api/recovery/social/initiate', async (c) => {
// Check for existing active request
const existing = await getActiveRecoveryRequest(user.id);
if (existing) {
return c.json({ success: true, message: 'A recovery request is already active. Recovery emails have been re-sent.' });
return c.json({ success: true, requestId: existing.id, message: 'A recovery request is already active. Recovery emails have been re-sent.' });
}
// Create recovery request (7 day expiry, 2-of-3 threshold)
@ -2773,6 +2987,7 @@ app.post('/api/recovery/social/initiate', async (c) => {
title: 'Account recovery initiated',
body: `A recovery request was initiated for your account. ${accepted.length} guardians have been contacted.`,
metadata: { recoveryRequestId: requestId, threshold: 2, totalGuardians: accepted.length },
actionUrl: `/recover/social?id=${requestId}`,
}).catch(() => {});
// Create approval tokens and notify guardians
@ -2854,7 +3069,7 @@ app.post('/api/recovery/social/initiate', async (c) => {
}
}
return c.json({ success: true, message: 'If the account exists and has guardians, recovery emails have been sent.' });
return c.json({ success: true, requestId, message: 'If the account exists and has guardians, recovery emails have been sent.' });
});
/**
@ -2973,6 +3188,7 @@ app.post('/api/recovery/social/approve', async (c) => {
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}`,
}).catch(() => {});
}
@ -7394,7 +7610,10 @@ app.get('/', (c) => {
body: JSON.stringify(body),
});
const data = await res.json();
msgEl.textContent = data.message || 'Recovery request sent. Check with your guardians.';
msgEl.innerHTML = data.message || 'Recovery request sent. Check with your guardians.';
if (data.requestId) {
msgEl.innerHTML += '<br><br><a href="/recover/social?id=' + encodeURIComponent(data.requestId) + '" style="color:#00d4ff;">Track your recovery progress &rarr;</a>';
}
msgEl.style.color = '#86efac';
msgEl.style.display = 'block';
// Also try email recovery