diff --git a/shared/components/rstack-identity.ts b/shared/components/rstack-identity.ts index 5d06979..4fd5e6e 100644 --- a/shared/components/rstack-identity.ts +++ b/shared/components/rstack-identity.ts @@ -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 - ? `
${contacts.map((c, i) => ` + const guardiansHTML = guardians.length > 0 + ? `
${guardians.map(g => `
- ${c.replace(/ - +
+ ${g.name.replace(/${g.email.replace(/` : ""} + ${g.status === "accepted" ? "Accepted" : "Pending invite"} +
+
`).join("")}
` : ""; - const thresholdHTML = contacts.length >= 2 - ? `
- - - contacts needed to recover -
` - : ""; + const infoHTML = guardians.length < 2 + ? `
Add at least 2 trusted guardians to enable social recovery. Threshold: ${threshold} of ${Math.max(guardians.length, 2)} needed to recover.
` + : `
Social recovery is active. ${threshold} of ${guardians.length} guardians needed to recover your account.
`; overlay.innerHTML = ` @@ -616,17 +613,16 @@ export class RStackIdentity extends HTMLElement {

Social Recovery

Choose trusted contacts who can help recover your account.

-
- - -
- ${contactsHTML} - ${thresholdHTML} -
- ${contacts.length >= 2 ? '' : ""} -
+ ${loading ? '
Loading guardians...
' : ` + ${guardians.length < 3 ? `
+ + + +
` : ""} + ${guardiansHTML} + ${infoHTML} + `}
- ${contacts.length < 2 ? '
Add at least 2 trusted contacts to enable social recovery.
' : ""}
`; 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 = ' 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 = ''; 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") { diff --git a/src/encryptid/db.ts b/src/encryptid/db.ts index 60f2bc8..1d64e0c 100644 --- a/src/encryptid/db.ts +++ b/src/encryptid/db.ts @@ -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; diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index 4aa9970..9170fd8 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -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: ` + + + + +
+ + + +
+
+

Email Verification

+
+

Your verification code is:

+
${code}
+

This code expires in 10 minutes.

+
+
+`, + }); + } 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 // ============================================================================