${body}
@@ -867,10 +900,11 @@ export class RStackIdentity extends HTMLElement {
`;
}
}
+ const done = acctStatus ? acctStatus.socialRecovery : null;
return `
-
+
${body}
@@ -1025,7 +1059,8 @@ export class RStackIdentity extends HTMLElement {
body: JSON.stringify({ email: emailAddr, code }),
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "Verification failed");
- close();
+ if (acctStatus) acctStatus.email = true;
+ openSection = null; render();
this.dispatchEvent(new CustomEvent("identity-action", { bubbles: true, composed: true, detail: { action: "email-added", email: emailAddr } }));
} catch (e: any) {
btn.disabled = false; btn.innerHTML = "Verify";
@@ -1093,8 +1128,10 @@ export class RStackIdentity extends HTMLElement {
});
if (!completeRes.ok) throw new Error((await completeRes.json().catch(() => ({}))).error || "Device registration failed");
+ if (acctStatus) { acctStatus.credentialCount++; acctStatus.multiDevice = acctStatus.credentialCount > 1; }
btn.innerHTML = "Device Registered";
btn.className = "btn btn--success";
+ render();
this.dispatchEvent(new CustomEvent("identity-action", { bubbles: true, composed: true, detail: { action: "device-added" } }));
} catch (e: any) {
btn.disabled = false; btn.innerHTML = "🔑 Register Passkey on This Device";
@@ -1122,6 +1159,7 @@ export class RStackIdentity extends HTMLElement {
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 });
+ if (acctStatus) { acctStatus.guardianCount = guardians.length; acctStatus.socialRecovery = guardians.length >= 2; }
render();
setTimeout(() => (overlay.querySelector("#acct-guardian-name") as HTMLInputElement)?.focus(), 50);
} catch (e: any) {
@@ -1149,6 +1187,7 @@ export class RStackIdentity extends HTMLElement {
});
if (!res.ok) throw new Error("Failed to remove guardian");
guardians = guardians.filter(g => g.id !== id);
+ if (acctStatus) { acctStatus.guardianCount = guardians.length; acctStatus.socialRecovery = guardians.length >= 2; }
render();
} catch (e: any) {
if (err) err.textContent = e.message;
@@ -1215,8 +1254,7 @@ export class RStackIdentity extends HTMLElement {
e.stopPropagation();
const enabled = backupToggle.checked;
setEncryptedBackupEnabled(enabled);
- const hint = overlay.querySelector("#backup-hint") as HTMLElement;
- if (hint) hint.textContent = enabled ? "Save to encrypted server" : "Save locally — you manage your own data";
+ render();
this.dispatchEvent(new CustomEvent("backup-toggle", { bubbles: true, composed: true, detail: { enabled } }));
});
}
@@ -1657,6 +1695,14 @@ const ACCOUNT_MODAL_STYLES = `
padding: 0 16px 10px; font-size: 0.75rem; color: #64748b; line-height: 1.4;
}
.guardian-piece { font-size: 1.1rem; flex-shrink: 0; }
+.status-dot {
+ display: inline-block; width: 8px; height: 8px; border-radius: 50%;
+ margin-right: 6px; vertical-align: middle; flex-shrink: 0;
+}
+.status-dot.done { background: #34d399; box-shadow: 0 0 4px rgba(52,211,153,0.4); }
+.status-dot.pending { background: #f87171; box-shadow: 0 0 4px rgba(248,113,113,0.4); }
+.section--warning { border-color: rgba(248,113,113,0.3) !important; }
+.section--warning .account-section-header span:first-child { color: #fca5a5; }
.address-form {
display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px;
}
diff --git a/src/encryptid/db.ts b/src/encryptid/db.ts
index c2e2d1a..67d0191 100644
--- a/src/encryptid/db.ts
+++ b/src/encryptid/db.ts
@@ -219,7 +219,7 @@ export async function getUserById(userId: string) {
export interface StoredRecoveryToken {
token: string;
userId: string;
- type: 'email_verify' | 'account_recovery' | 'email_verification';
+ type: 'email_verify' | 'account_recovery';
createdAt: number;
expiresAt: number;
used: boolean;
diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts
index d692302..001d928 100644
--- a/src/encryptid/server.ts
+++ b/src/encryptid/server.ts
@@ -847,6 +847,45 @@ app.delete('/api/user/addresses/:id', async (c) => {
// ACCOUNT SETTINGS ENDPOINTS
// ============================================================================
+/**
+ * GET /api/account/status — account setup completion status
+ * Returns which security/setup steps are done.
+ */
+app.get('/api/account/status', async (c) => {
+ const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
+ if (!claims) return c.json({ error: 'Unauthorized' }, 401);
+
+ const userId = claims.sub as string;
+
+ // Check email
+ const profile = await getUserProfile(userId);
+ const hasEmail = !!(profile?.profileEmail);
+
+ // Check credentials count (>1 means multi-device)
+ const creds = await getUserCredentials(userId);
+ const hasMultiDevice = creds.length > 1;
+
+ // Check guardians
+ let guardianCount = 0;
+ try {
+ const rows = await sql`SELECT COUNT(*) as count FROM guardians WHERE user_id = ${userId} AND status != 'removed'`;
+ guardianCount = parseInt(rows[0]?.count || '0');
+ } catch { /* ignore */ }
+ const hasRecovery = guardianCount >= 2;
+
+ // Check encrypted backup
+ // (This is client-side localStorage, but we can infer from whether they have any synced docs)
+ // For now, we just return the server-side info and let the client check localStorage
+
+ return c.json({
+ email: hasEmail,
+ multiDevice: hasMultiDevice,
+ socialRecovery: hasRecovery,
+ credentialCount: creds.length,
+ guardianCount,
+ });
+});
+
/**
* POST /api/account/email/start — send verification code to email
* Body: { email }
@@ -867,7 +906,7 @@ app.post('/api/account/email/start', async (c) => {
await storeRecoveryToken({
token: tokenKey,
userId: claims.sub as string,
- type: 'email_verification',
+ type: 'email_verify',
createdAt: Date.now(),
expiresAt: Date.now() + 10 * 60 * 1000, // 10 minutes
used: false,
diff --git a/website/sw.ts b/website/sw.ts
index f62e238..e20893d 100644
--- a/website/sw.ts
+++ b/website/sw.ts
@@ -37,6 +37,9 @@ self.addEventListener("activate", (event) => {
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
+ // Skip non-http(s) schemes (chrome-extension://, etc.) — they can't be cached
+ if (!url.protocol.startsWith("http")) return;
+
// Skip WebSocket and API requests entirely
if (
event.request.url.startsWith("ws://") ||