feat: wire up account settings endpoints (email, device, guardians)

Server (src/encryptid/server.ts):
- POST /api/account/email/start — send 6-digit verification code via SMTP
- POST /api/account/email/verify — verify code and set email on account
- POST /api/account/device/start — WebAuthn creation options for same-device
  passkey registration (authenticated, reuses existing userId)
- POST /api/account/device/complete — store additional credential under
  existing account

DB (src/encryptid/db.ts):
- Add 'device_registration' to StoredChallenge.type union
- Add 'email_verification' to StoredRecoveryToken.type union

Client (shared/components/rstack-identity.ts):
- Rewrite social recovery modal to use existing guardian API:
  GET /api/guardians, POST /api/guardians, DELETE /api/guardians/:id
- Loads existing guardians on open, adds/removes in real-time
- Shows guardian status (accepted/pending), invite emails sent on add
- Two name+email inputs (max 3 guardians, server-enforced)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-25 14:57:32 -08:00
parent cd33f7c050
commit 1ff0f69218
3 changed files with 267 additions and 62 deletions

View File

@ -586,29 +586,26 @@ export class RStackIdentity extends HTMLElement {
if (document.querySelector(".rstack-auth-overlay")) return;
const overlay = document.createElement("div");
overlay.className = "rstack-auth-overlay";
const contacts: string[] = [];
let guardians: { id: string; name: string; email?: string; status: string }[] = [];
let threshold = 2;
let loading = true;
const render = () => {
const contactsHTML = contacts.length > 0
? `<div class="contact-list">${contacts.map((c, i) => `
const guardiansHTML = guardians.length > 0
? `<div class="contact-list">${guardians.map(g => `
<div class="contact-item">
<span>${c.replace(/</g, "&lt;")}</span>
<button class="contact-remove" data-remove="${i}">&times;</button>
<div style="display:flex;flex-direction:column;gap:2px;min-width:0;flex:1">
<span>${g.name.replace(/</g, "&lt;")}${g.email ? ` <span style="opacity:0.5;font-size:0.8rem">${g.email.replace(/</g, "&lt;")}</span>` : ""}</span>
<span style="font-size:0.7rem;color:${g.status === "accepted" ? "#34d399" : "#fbbf24"}">${g.status === "accepted" ? "Accepted" : "Pending invite"}</span>
</div>
<button class="contact-remove" data-remove-id="${g.id}">&times;</button>
</div>
`).join("")}</div>`
: "";
const thresholdHTML = contacts.length >= 2
? `<div class="threshold-row">
<label>Recovery threshold:</label>
<select id="s-threshold">${
Array.from({ length: contacts.length - 1 }, (_, i) => i + 2)
.map(n => `<option value="${n}"${n === Math.ceil(contacts.length * 0.6) ? " selected" : ""}>${n} of ${contacts.length}</option>`)
.join("")
}</select>
<span class="threshold-hint">contacts needed to recover</span>
</div>`
: "";
const infoHTML = guardians.length < 2
? `<div class="info-text">Add at least 2 trusted guardians to enable social recovery. Threshold: ${threshold} of ${Math.max(guardians.length, 2)} needed to recover.</div>`
: `<div class="info-text" style="color:#34d399">Social recovery is active. ${threshold} of ${guardians.length} guardians needed to recover your account.</div>`;
overlay.innerHTML = `
<style>${MODAL_STYLES}${SETTINGS_STYLES}</style>
@ -616,17 +613,16 @@ export class RStackIdentity extends HTMLElement {
<button class="close-btn" data-action="close">&times;</button>
<h2>Social Recovery</h2>
<p>Choose trusted contacts who can help recover your account.</p>
<div class="input-row">
<input class="input input--inline" id="s-contact" type="text" placeholder="Enter username" />
<button class="btn btn--small btn--primary" data-action="add-contact">Add</button>
</div>
${contactsHTML}
${thresholdHTML}
<div class="actions" style="margin-top:1rem">
${contacts.length >= 2 ? '<button class="btn btn--primary" data-action="save-recovery">Save Recovery Settings</button>' : ""}
</div>
${loading ? '<div style="text-align:center;padding:1rem;color:#94a3b8"><span class="spinner"></span> Loading guardians...</div>' : `
${guardians.length < 3 ? `<div class="input-row">
<input class="input input--inline" id="s-name" type="text" placeholder="Guardian name" />
<input class="input input--inline" id="s-email" type="email" placeholder="Email (optional)" />
<button class="btn btn--small btn--primary" data-action="add-guardian">Add</button>
</div>` : ""}
${guardiansHTML}
${infoHTML}
`}
<div class="error" id="s-error"></div>
${contacts.length < 2 ? '<div class="info-text">Add at least 2 trusted contacts to enable social recovery.</div>' : ""}
</div>
`;
attach();
@ -634,57 +630,81 @@ export class RStackIdentity extends HTMLElement {
const close = () => overlay.remove();
const loadGuardians = async () => {
try {
const res = await fetch(`${ENCRYPTID_URL}/api/guardians`, {
headers: { Authorization: `Bearer ${getAccessToken()}` },
});
if (res.ok) {
const data = await res.json();
guardians = data.guardians || [];
threshold = data.threshold || 2;
}
} catch { /* offline */ }
loading = false;
render();
};
const attach = () => {
overlay.querySelector('[data-action="close"]')?.addEventListener("click", close);
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
overlay.querySelector('[data-action="add-contact"]')?.addEventListener("click", () => {
const input = overlay.querySelector("#s-contact") as HTMLInputElement;
overlay.querySelector('[data-action="add-guardian"]')?.addEventListener("click", async () => {
const nameInput = overlay.querySelector("#s-name") as HTMLInputElement;
const emailInput = overlay.querySelector("#s-email") as HTMLInputElement;
const err = overlay.querySelector("#s-error") as HTMLElement;
const name = input.value.trim();
if (!name) { err.textContent = "Enter a username."; input.focus(); return; }
if (contacts.includes(name)) { err.textContent = "Already added."; return; }
contacts.push(name);
render();
setTimeout(() => (overlay.querySelector("#s-contact") as HTMLInputElement)?.focus(), 50);
});
overlay.querySelector("#s-contact")?.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") (overlay.querySelector('[data-action="add-contact"]') as HTMLElement)?.click();
});
overlay.querySelectorAll("[data-remove]").forEach(el => {
el.addEventListener("click", () => {
contacts.splice(parseInt((el as HTMLElement).dataset.remove!, 10), 1);
render();
});
});
overlay.querySelector('[data-action="save-recovery"]')?.addEventListener("click", async () => {
const err = overlay.querySelector("#s-error") as HTMLElement;
const btn = overlay.querySelector('[data-action="save-recovery"]') as HTMLButtonElement;
const threshold = parseInt((overlay.querySelector("#s-threshold") as HTMLSelectElement)?.value || "2", 10);
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Saving...';
const btn = overlay.querySelector('[data-action="add-guardian"]') as HTMLButtonElement;
const name = nameInput.value.trim();
const email = emailInput.value.trim();
if (!name) { err.textContent = "Enter a guardian name."; nameInput.focus(); return; }
err.textContent = "";
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span>';
try {
const res = await fetch(`${ENCRYPTID_URL}/api/account/recovery/setup`, {
const res = await fetch(`${ENCRYPTID_URL}/api/guardians`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAccessToken()}` },
body: JSON.stringify({ contacts, threshold }),
body: JSON.stringify({ name, email: email || undefined }),
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "Failed to save recovery settings");
btn.innerHTML = "Saved";
btn.className = "btn btn--success";
this.dispatchEvent(new CustomEvent("identity-action", { bubbles: true, composed: true, detail: { action: "recovery-configured", contacts, threshold } }));
setTimeout(close, 1500);
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 });
render();
setTimeout(() => (overlay.querySelector("#s-name") as HTMLInputElement)?.focus(), 50);
} catch (e: any) {
btn.disabled = false; btn.innerHTML = "Save Recovery Settings";
btn.disabled = false; btn.innerHTML = "Add";
err.textContent = e.message;
}
});
overlay.querySelector("#s-name")?.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") (overlay.querySelector('[data-action="add-guardian"]') as HTMLElement)?.click();
});
overlay.querySelector("#s-email")?.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") (overlay.querySelector('[data-action="add-guardian"]') as HTMLElement)?.click();
});
overlay.querySelectorAll("[data-remove-id]").forEach(el => {
el.addEventListener("click", async () => {
const id = (el as HTMLElement).dataset.removeId!;
const err = overlay.querySelector("#s-error") as HTMLElement;
try {
const res = await fetch(`${ENCRYPTID_URL}/api/guardians/${id}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${getAccessToken()}` },
});
if (!res.ok) throw new Error("Failed to remove guardian");
guardians = guardians.filter(g => g.id !== id);
render();
} catch (e: any) {
err.textContent = e.message;
}
});
});
};
document.body.appendChild(overlay);
render();
loadGuardians();
}
static define(tag = "rstack-identity") {

View File

@ -43,7 +43,7 @@ export interface StoredCredential {
export interface StoredChallenge {
challenge: string;
userId?: string;
type: 'registration' | 'authentication';
type: 'registration' | 'authentication' | 'device_registration';
createdAt: number;
expiresAt: number;
}
@ -219,7 +219,7 @@ export async function getUserById(userId: string) {
export interface StoredRecoveryToken {
token: string;
userId: string;
type: 'email_verify' | 'account_recovery';
type: 'email_verify' | 'account_recovery' | 'email_verification';
createdAt: number;
expiresAt: number;
used: boolean;

View File

@ -637,6 +637,191 @@ app.get('/api/user/credentials', async (c) => {
}
});
// ============================================================================
// ACCOUNT SETTINGS ENDPOINTS
// ============================================================================
/**
* POST /api/account/email/start send verification code to email
* Body: { email }
* Auth required
*/
app.post('/api/account/email/start', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
const { email } = await c.req.json();
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return c.json({ error: 'Valid email required' }, 400);
}
const code = String(Math.floor(100000 + Math.random() * 900000));
const tokenKey = `emailverify_${claims.sub}_${code}`;
await storeRecoveryToken({
token: tokenKey,
userId: claims.sub as string,
type: 'email_verification',
createdAt: Date.now(),
expiresAt: Date.now() + 10 * 60 * 1000, // 10 minutes
used: false,
});
if (smtpTransport) {
try {
await smtpTransport.sendMail({
from: CONFIG.smtp.from,
to: email,
subject: `${code} — rStack Email Verification`,
text: `Your verification code is: ${code}\n\nThis code expires in 10 minutes.\n\n— rStack Identity`,
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;">&#9993;</div>
<h1 style="margin:0;font-size:24px;background:linear-gradient(90deg,#00d4ff,#7c3aed);-webkit-background-clip:text;-webkit-text-fill-color:transparent;">Email Verification</h1>
</td></tr>
<tr><td style="padding:0 32px 24px;color:#e2e8f0;font-size:15px;line-height:1.6;text-align:center;">
<p>Your verification code is:</p>
<div style="font-size:36px;font-weight:800;letter-spacing:8px;color:#06b6d4;padding:16px 0;">${code}</div>
<p style="color:#94a3b8;font-size:13px;">This code expires in 10 minutes.</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>`,
});
} catch (err) {
console.error('EncryptID: Failed to send verification email:', err);
return c.json({ error: 'Failed to send verification email' }, 500);
}
} else {
console.log(`EncryptID: [NO SMTP] Email verification code for ${email}: ${code}`);
}
return c.json({ success: true });
});
/**
* POST /api/account/email/verify verify code and set email
* Body: { email, code }
* Auth required
*/
app.post('/api/account/email/verify', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
const { email, code } = await c.req.json();
if (!email || !code) return c.json({ error: 'Email and code required' }, 400);
const tokenKey = `emailverify_${claims.sub}_${code}`;
const rt = await getRecoveryToken(tokenKey);
if (!rt || rt.used || Date.now() > rt.expiresAt || rt.userId !== (claims.sub as string)) {
return c.json({ error: 'Invalid or expired verification code' }, 400);
}
await markRecoveryTokenUsed(tokenKey);
await setUserEmail(claims.sub as string, email);
return c.json({ success: true, email });
});
/**
* POST /api/account/device/start get WebAuthn options for registering another passkey
* Auth required
*/
app.post('/api/account/device/start', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
const user = await getUserById(claims.sub as string);
if (!user) return c.json({ error: 'User not found' }, 404);
const challenge = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url');
await storeChallenge({
challenge,
userId: claims.sub as string,
type: 'device_registration',
createdAt: Date.now(),
expiresAt: Date.now() + 5 * 60 * 1000,
});
const rpId = resolveRpId(c);
const options = {
challenge,
rp: { id: rpId, name: CONFIG.rpName },
user: {
id: claims.sub as string,
name: user.username,
displayName: user.username,
},
pubKeyCredParams: [
{ alg: -7, type: 'public-key' },
{ alg: -257, type: 'public-key' },
],
authenticatorSelection: {
residentKey: 'required',
requireResidentKey: true,
userVerification: 'required',
},
timeout: 60000,
attestation: 'none',
};
return c.json({ options, userId: claims.sub });
});
/**
* POST /api/account/device/complete register additional passkey for existing account
* Body: { challenge, credential: { credentialId, publicKey, transports } }
* Auth required
*/
app.post('/api/account/device/complete', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
const { challenge, credential } = await c.req.json();
if (!challenge || !credential?.credentialId) {
return c.json({ error: 'Challenge and credential required' }, 400);
}
const challengeRecord = await getChallenge(challenge);
if (!challengeRecord || challengeRecord.type !== 'device_registration') {
return c.json({ error: 'Invalid challenge' }, 400);
}
if (challengeRecord.userId !== (claims.sub as string)) {
return c.json({ error: 'Challenge mismatch' }, 400);
}
if (Date.now() > challengeRecord.expiresAt) {
await deleteChallenge(challenge);
return c.json({ error: 'Challenge expired' }, 400);
}
await deleteChallenge(challenge);
const user = await getUserById(claims.sub as string);
if (!user) return c.json({ error: 'User not found' }, 404);
const rpId = resolveRpId(c);
await storeCredential({
credentialId: credential.credentialId,
publicKey: credential.publicKey || '',
userId: claims.sub as string,
username: user.username,
counter: 0,
createdAt: Date.now(),
transports: credential.transports || [],
rpId,
});
console.log('EncryptID: Additional device registered for', user.username);
return c.json({ success: true });
});
// ============================================================================
// RECOVERY ENDPOINTS
// ============================================================================