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
- ? `
`;
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
// ============================================================================