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:
parent
cd33f7c050
commit
1ff0f69218
|
|
@ -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, "<")}</span>
|
||||
<button class="contact-remove" data-remove="${i}">×</button>
|
||||
<div style="display:flex;flex-direction:column;gap:2px;min-width:0;flex:1">
|
||||
<span>${g.name.replace(/</g, "<")}${g.email ? ` <span style="opacity:0.5;font-size:0.8rem">${g.email.replace(/</g, "<")}</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}">×</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">×</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") {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;">✉</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
|
||||
// ============================================================================
|
||||
|
|
|
|||
Loading…
Reference in New Issue