Merge branch 'dev'
This commit is contained in:
commit
13293799b6
|
|
@ -712,6 +712,9 @@ export class RStackIdentity extends HTMLElement {
|
||||||
|
|
||||||
let openSection: string | null = null;
|
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
|
// Lazy-loaded data
|
||||||
let guardians: { id: string; name: string; email?: string; status: string }[] = [];
|
let guardians: { id: string; name: string; email?: string; status: string }[] = [];
|
||||||
let guardiansThreshold = 2;
|
let guardiansThreshold = 2;
|
||||||
|
|
@ -727,11 +730,36 @@ export class RStackIdentity extends HTMLElement {
|
||||||
|
|
||||||
const close = () => overlay.remove();
|
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 render = () => {
|
||||||
const backupEnabled = isEncryptedBackupEnabled();
|
const backupEnabled = isEncryptedBackupEnabled();
|
||||||
const currentTheme = localStorage.getItem("canvas-theme") || "dark";
|
const currentTheme = localStorage.getItem("canvas-theme") || "dark";
|
||||||
const isDark = currentTheme === "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 = `
|
overlay.innerHTML = `
|
||||||
<style>${MODAL_STYLES}${SETTINGS_STYLES}${ACCOUNT_MODAL_STYLES}</style>
|
<style>${MODAL_STYLES}${SETTINGS_STYLES}${ACCOUNT_MODAL_STYLES}</style>
|
||||||
<div class="account-modal">
|
<div class="account-modal">
|
||||||
|
|
@ -743,15 +771,18 @@ export class RStackIdentity extends HTMLElement {
|
||||||
${renderRecoverySection()}
|
${renderRecoverySection()}
|
||||||
${renderAddressSection()}
|
${renderAddressSection()}
|
||||||
|
|
||||||
<div class="account-section account-section--inline">
|
<div class="account-section account-section--inline${!backupEnabled ? " section--warning" : ""}">
|
||||||
<div class="account-section-header">
|
<div class="account-section-header">
|
||||||
<span>🔒 Data Storage</span>
|
<span>${statusDot(backupEnabled)} 🔒 Data Storage</span>
|
||||||
<label class="toggle-switch">
|
<label class="toggle-switch">
|
||||||
<input type="checkbox" id="acct-backup-toggle" ${backupEnabled ? "checked" : ""} />
|
<input type="checkbox" id="acct-backup-toggle" ${backupEnabled ? "checked" : ""} />
|
||||||
<span class="toggle-slider"></span>
|
<span class="toggle-slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="account-section account-section--inline">
|
<div class="account-section account-section--inline">
|
||||||
|
|
@ -772,6 +803,7 @@ export class RStackIdentity extends HTMLElement {
|
||||||
|
|
||||||
const renderEmailSection = () => {
|
const renderEmailSection = () => {
|
||||||
const isOpen = openSection === "email";
|
const isOpen = openSection === "email";
|
||||||
|
const done = acctStatus ? acctStatus.email : null;
|
||||||
let body = "";
|
let body = "";
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
if (emailStep === "input") {
|
if (emailStep === "input") {
|
||||||
|
|
@ -798,9 +830,9 @@ export class RStackIdentity extends HTMLElement {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return `
|
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">
|
<div class="account-section-header" data-section="email">
|
||||||
<span>✉️ Email</span>
|
<span>${statusDot(done)} ✉️ Email</span>
|
||||||
<span class="section-arrow">${isOpen ? "▾" : "▸"}</span>
|
<span class="section-arrow">${isOpen ? "▾" : "▸"}</span>
|
||||||
</div>
|
</div>
|
||||||
${body}
|
${body}
|
||||||
|
|
@ -809,9 +841,10 @@ export class RStackIdentity extends HTMLElement {
|
||||||
|
|
||||||
const renderDeviceSection = () => {
|
const renderDeviceSection = () => {
|
||||||
const isOpen = openSection === "device";
|
const isOpen = openSection === "device";
|
||||||
|
const done = acctStatus ? acctStatus.multiDevice : null;
|
||||||
const body = isOpen ? `
|
const body = isOpen ? `
|
||||||
<div class="account-section-body">
|
<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">
|
<div class="actions actions--stack">
|
||||||
<button class="btn btn--primary" data-action="register-device">🔑 Register Passkey on This Device</button>
|
<button class="btn btn--primary" data-action="register-device">🔑 Register Passkey on This Device</button>
|
||||||
</div>
|
</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 class="info-text">Each device you register can independently sign in to your account.</div>
|
||||||
</div>` : "";
|
</div>` : "";
|
||||||
return `
|
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">
|
<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>
|
<span class="section-arrow">${isOpen ? "▾" : "▸"}</span>
|
||||||
</div>
|
</div>
|
||||||
${body}
|
${body}
|
||||||
|
|
@ -867,10 +900,11 @@ export class RStackIdentity extends HTMLElement {
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const done = acctStatus ? acctStatus.socialRecovery : null;
|
||||||
return `
|
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">
|
<div class="account-section-header" data-section="recovery">
|
||||||
<span>🛡️ Social Recovery</span>
|
<span>${statusDot(done)} 🛡️ Social Recovery</span>
|
||||||
<span class="section-arrow">${isOpen ? "▾" : "▸"}</span>
|
<span class="section-arrow">${isOpen ? "▾" : "▸"}</span>
|
||||||
</div>
|
</div>
|
||||||
${body}
|
${body}
|
||||||
|
|
@ -1025,7 +1059,8 @@ export class RStackIdentity extends HTMLElement {
|
||||||
body: JSON.stringify({ email: emailAddr, code }),
|
body: JSON.stringify({ email: emailAddr, code }),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "Verification failed");
|
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 } }));
|
this.dispatchEvent(new CustomEvent("identity-action", { bubbles: true, composed: true, detail: { action: "email-added", email: emailAddr } }));
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
btn.disabled = false; btn.innerHTML = "Verify";
|
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 (!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.innerHTML = "Device Registered";
|
||||||
btn.className = "btn btn--success";
|
btn.className = "btn btn--success";
|
||||||
|
render();
|
||||||
this.dispatchEvent(new CustomEvent("identity-action", { bubbles: true, composed: true, detail: { action: "device-added" } }));
|
this.dispatchEvent(new CustomEvent("identity-action", { bubbles: true, composed: true, detail: { action: "device-added" } }));
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
btn.disabled = false; btn.innerHTML = "🔑 Register Passkey on This Device";
|
btn.disabled = false; btn.innerHTML = "🔑 Register Passkey on This Device";
|
||||||
|
|
@ -1122,6 +1159,7 @@ export class RStackIdentity extends HTMLElement {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data.error || "Failed to add guardian");
|
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 });
|
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();
|
render();
|
||||||
setTimeout(() => (overlay.querySelector("#acct-guardian-name") as HTMLInputElement)?.focus(), 50);
|
setTimeout(() => (overlay.querySelector("#acct-guardian-name") as HTMLInputElement)?.focus(), 50);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|
@ -1149,6 +1187,7 @@ export class RStackIdentity extends HTMLElement {
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error("Failed to remove guardian");
|
if (!res.ok) throw new Error("Failed to remove guardian");
|
||||||
guardians = guardians.filter(g => g.id !== id);
|
guardians = guardians.filter(g => g.id !== id);
|
||||||
|
if (acctStatus) { acctStatus.guardianCount = guardians.length; acctStatus.socialRecovery = guardians.length >= 2; }
|
||||||
render();
|
render();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (err) err.textContent = e.message;
|
if (err) err.textContent = e.message;
|
||||||
|
|
@ -1215,8 +1254,7 @@ export class RStackIdentity extends HTMLElement {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const enabled = backupToggle.checked;
|
const enabled = backupToggle.checked;
|
||||||
setEncryptedBackupEnabled(enabled);
|
setEncryptedBackupEnabled(enabled);
|
||||||
const hint = overlay.querySelector("#backup-hint") as HTMLElement;
|
render();
|
||||||
if (hint) hint.textContent = enabled ? "Save to encrypted server" : "Save locally — you manage your own data";
|
|
||||||
this.dispatchEvent(new CustomEvent("backup-toggle", { bubbles: true, composed: true, detail: { enabled } }));
|
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;
|
padding: 0 16px 10px; font-size: 0.75rem; color: #64748b; line-height: 1.4;
|
||||||
}
|
}
|
||||||
.guardian-piece { font-size: 1.1rem; flex-shrink: 0; }
|
.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 {
|
.address-form {
|
||||||
display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px;
|
display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -219,7 +219,7 @@ export async function getUserById(userId: string) {
|
||||||
export interface StoredRecoveryToken {
|
export interface StoredRecoveryToken {
|
||||||
token: string;
|
token: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
type: 'email_verify' | 'account_recovery' | 'email_verification';
|
type: 'email_verify' | 'account_recovery';
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
used: boolean;
|
used: boolean;
|
||||||
|
|
|
||||||
|
|
@ -847,6 +847,45 @@ app.delete('/api/user/addresses/:id', async (c) => {
|
||||||
// ACCOUNT SETTINGS ENDPOINTS
|
// 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
|
* POST /api/account/email/start — send verification code to email
|
||||||
* Body: { email }
|
* Body: { email }
|
||||||
|
|
@ -867,7 +906,7 @@ app.post('/api/account/email/start', async (c) => {
|
||||||
await storeRecoveryToken({
|
await storeRecoveryToken({
|
||||||
token: tokenKey,
|
token: tokenKey,
|
||||||
userId: claims.sub as string,
|
userId: claims.sub as string,
|
||||||
type: 'email_verification',
|
type: 'email_verify',
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
expiresAt: Date.now() + 10 * 60 * 1000, // 10 minutes
|
expiresAt: Date.now() + 10 * 60 * 1000, // 10 minutes
|
||||||
used: false,
|
used: false,
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,9 @@ self.addEventListener("activate", (event) => {
|
||||||
self.addEventListener("fetch", (event) => {
|
self.addEventListener("fetch", (event) => {
|
||||||
const url = new URL(event.request.url);
|
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
|
// Skip WebSocket and API requests entirely
|
||||||
if (
|
if (
|
||||||
event.request.url.startsWith("ws://") ||
|
event.request.url.startsWith("ws://") ||
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue