Merge branch 'dev'
This commit is contained in:
commit
13293799b6
|
|
@ -712,6 +712,9 @@ export class RStackIdentity extends HTMLElement {
|
|||
|
||||
let openSection: string | null = null;
|
||||
|
||||
// Account completion status
|
||||
let acctStatus: { email: boolean; multiDevice: boolean; socialRecovery: boolean; guardianCount: number; credentialCount: number } | null = null;
|
||||
|
||||
// Lazy-loaded data
|
||||
let guardians: { id: string; name: string; email?: string; status: string }[] = [];
|
||||
let guardiansThreshold = 2;
|
||||
|
|
@ -727,11 +730,36 @@ export class RStackIdentity extends HTMLElement {
|
|||
|
||||
const close = () => overlay.remove();
|
||||
|
||||
// Load account completion status
|
||||
const loadStatus = async () => {
|
||||
try {
|
||||
const res = await fetch(`${ENCRYPTID_URL}/api/account/status`, {
|
||||
headers: { Authorization: `Bearer ${getAccessToken()}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
acctStatus = await res.json();
|
||||
render();
|
||||
}
|
||||
} catch { /* offline */ }
|
||||
};
|
||||
loadStatus();
|
||||
|
||||
const statusDot = (done: boolean | null) => {
|
||||
if (done === null) return ''; // still loading
|
||||
return done
|
||||
? '<span class="status-dot done" title="Complete"></span>'
|
||||
: '<span class="status-dot pending" title="Not yet set up"></span>';
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
const backupEnabled = isEncryptedBackupEnabled();
|
||||
const currentTheme = localStorage.getItem("canvas-theme") || "dark";
|
||||
const isDark = currentTheme === "dark";
|
||||
|
||||
const emailDone = acctStatus ? acctStatus.email : null;
|
||||
const deviceDone = acctStatus ? acctStatus.multiDevice : null;
|
||||
const recoveryDone = acctStatus ? acctStatus.socialRecovery : null;
|
||||
|
||||
overlay.innerHTML = `
|
||||
<style>${MODAL_STYLES}${SETTINGS_STYLES}${ACCOUNT_MODAL_STYLES}</style>
|
||||
<div class="account-modal">
|
||||
|
|
@ -743,15 +771,18 @@ export class RStackIdentity extends HTMLElement {
|
|||
${renderRecoverySection()}
|
||||
${renderAddressSection()}
|
||||
|
||||
<div class="account-section account-section--inline">
|
||||
<div class="account-section account-section--inline${!backupEnabled ? " section--warning" : ""}">
|
||||
<div class="account-section-header">
|
||||
<span>🔒 Data Storage</span>
|
||||
<span>${statusDot(backupEnabled)} 🔒 Data Storage</span>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="acct-backup-toggle" ${backupEnabled ? "checked" : ""} />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="toggle-hint" id="backup-hint">${backupEnabled ? "Save to encrypted server" : "Save locally — you manage your own data"}</div>
|
||||
<div class="toggle-hint" id="backup-hint">${backupEnabled
|
||||
? "Save to encrypted server"
|
||||
: '<span style="color:#f87171">Local only — you are responsible for your own data</span>'
|
||||
}</div>
|
||||
</div>
|
||||
|
||||
<div class="account-section account-section--inline">
|
||||
|
|
@ -772,6 +803,7 @@ export class RStackIdentity extends HTMLElement {
|
|||
|
||||
const renderEmailSection = () => {
|
||||
const isOpen = openSection === "email";
|
||||
const done = acctStatus ? acctStatus.email : null;
|
||||
let body = "";
|
||||
if (isOpen) {
|
||||
if (emailStep === "input") {
|
||||
|
|
@ -798,9 +830,9 @@ export class RStackIdentity extends HTMLElement {
|
|||
}
|
||||
}
|
||||
return `
|
||||
<div class="account-section${isOpen ? " open" : ""}">
|
||||
<div class="account-section${isOpen ? " open" : ""}${done === false ? " section--warning" : ""}">
|
||||
<div class="account-section-header" data-section="email">
|
||||
<span>✉️ Email</span>
|
||||
<span>${statusDot(done)} ✉️ Email</span>
|
||||
<span class="section-arrow">${isOpen ? "▾" : "▸"}</span>
|
||||
</div>
|
||||
${body}
|
||||
|
|
@ -809,9 +841,10 @@ export class RStackIdentity extends HTMLElement {
|
|||
|
||||
const renderDeviceSection = () => {
|
||||
const isOpen = openSection === "device";
|
||||
const done = acctStatus ? acctStatus.multiDevice : null;
|
||||
const body = isOpen ? `
|
||||
<div class="account-section-body">
|
||||
<p style="color:#94a3b8;font-size:0.85rem;margin:0 0 12px">Register an additional passkey for backup access.</p>
|
||||
<p style="color:#94a3b8;font-size:0.85rem;margin:0 0 12px">Register an additional passkey for backup access.${acctStatus ? ` <span style="color:#64748b">(${acctStatus.credentialCount} passkey${acctStatus.credentialCount !== 1 ? "s" : ""} registered)</span>` : ""}</p>
|
||||
<div class="actions actions--stack">
|
||||
<button class="btn btn--primary" data-action="register-device">🔑 Register Passkey on This Device</button>
|
||||
</div>
|
||||
|
|
@ -819,9 +852,9 @@ export class RStackIdentity extends HTMLElement {
|
|||
<div class="info-text">Each device you register can independently sign in to your account.</div>
|
||||
</div>` : "";
|
||||
return `
|
||||
<div class="account-section${isOpen ? " open" : ""}">
|
||||
<div class="account-section${isOpen ? " open" : ""}${done === false ? " section--warning" : ""}">
|
||||
<div class="account-section-header" data-section="device">
|
||||
<span>📱 Connect Another Device</span>
|
||||
<span>${statusDot(done)} 📱 Connect Another Device</span>
|
||||
<span class="section-arrow">${isOpen ? "▾" : "▸"}</span>
|
||||
</div>
|
||||
${body}
|
||||
|
|
@ -867,10 +900,11 @@ export class RStackIdentity extends HTMLElement {
|
|||
</div>`;
|
||||
}
|
||||
}
|
||||
const done = acctStatus ? acctStatus.socialRecovery : null;
|
||||
return `
|
||||
<div class="account-section${isOpen ? " open" : ""}">
|
||||
<div class="account-section${isOpen ? " open" : ""}${done === false ? " section--warning" : ""}">
|
||||
<div class="account-section-header" data-section="recovery">
|
||||
<span>🛡️ Social Recovery</span>
|
||||
<span>${statusDot(done)} 🛡️ Social Recovery</span>
|
||||
<span class="section-arrow">${isOpen ? "▾" : "▸"}</span>
|
||||
</div>
|
||||
${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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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://") ||
|
||||
|
|
|
|||
Loading…
Reference in New Issue