feat(encryptid): periodic nudge toast for users without a second device

Shows a dismissible toast notification 3s after page load when the user
has only one passkey. Links directly to the device section in My Account.
Dismissal remembered for 7 days via localStorage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-24 16:14:59 -07:00
parent cd6d979b5a
commit 50edf06900
1 changed files with 85 additions and 2 deletions

View File

@ -369,6 +369,9 @@ export class RStackIdentity extends HTMLElement {
// Validate session with server — detects logout from another browser session
this.#validateSessionWithServer();
// Nudge users without a second device to link one
this.#checkDeviceNudge();
// Propagate login/logout across tabs via storage events
window.addEventListener("storage", this.#onStorageChange);
}
@ -396,6 +399,86 @@ export class RStackIdentity extends HTMLElement {
} catch { /* network error — let token expire naturally */ }
}
async #checkDeviceNudge() {
const session = getSession();
if (!session?.accessToken) return;
// Don't nag if dismissed within the last 7 days
const NUDGE_KEY = "eid_device_nudge_dismissed";
const dismissed = localStorage.getItem(NUDGE_KEY);
if (dismissed && Date.now() - parseInt(dismissed, 10) < 7 * 24 * 60 * 60 * 1000) return;
// Wait a moment so it doesn't compete with page load
await new Promise(r => setTimeout(r, 3000));
// Fetch account status
try {
const res = await fetch(`${ENCRYPTID_URL}/api/account/status`, {
headers: { Authorization: `Bearer ${getAccessToken()}` },
});
if (!res.ok) return;
const status = await res.json();
if (status.multiDevice) return; // already has 2+ devices
// Show a toast nudge
const toast = document.createElement("div");
toast.className = "eid-device-nudge";
toast.innerHTML = `
<style>
.eid-device-nudge {
position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 9999;
background: var(--rs-bg-surface, #1e1e2e); border: 1px solid rgba(6,182,212,0.4);
border-radius: 14px; padding: 1rem 1.25rem; max-width: 340px;
box-shadow: 0 8px 32px rgba(0,0,0,0.4); animation: eid-nudge-in 0.4s ease-out;
color: var(--rs-text-primary, #e2e8f0); font-family: system-ui, sans-serif;
}
.eid-nudge-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.eid-nudge-icon { font-size: 1.5rem; }
.eid-nudge-title { font-weight: 600; font-size: 0.95rem; }
.eid-nudge-body { font-size: 0.85rem; color: var(--rs-text-secondary, #94a3b8); line-height: 1.5; margin-bottom: 0.75rem; }
.eid-nudge-actions { display: flex; gap: 8px; }
.eid-nudge-btn {
padding: 8px 14px; border-radius: 8px; border: none; font-size: 0.85rem;
font-weight: 600; cursor: pointer; transition: all 0.2s;
}
.eid-nudge-btn--primary {
background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white; flex: 1;
}
.eid-nudge-btn--primary:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(6,182,212,0.3); }
.eid-nudge-btn--dismiss {
background: transparent; color: var(--rs-text-muted, #64748b);
border: 1px solid var(--rs-border, #334155);
}
.eid-nudge-btn--dismiss:hover { color: var(--rs-text-secondary, #94a3b8); }
@keyframes eid-nudge-in { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
</style>
<div class="eid-nudge-header">
<span class="eid-nudge-icon">📱</span>
<span class="eid-nudge-title">Link a second device</span>
</div>
<div class="eid-nudge-body">
Sign in from your phone or tablet too it also acts as a backup if you ever lose access to this device.
</div>
<div class="eid-nudge-actions">
<button class="eid-nudge-btn eid-nudge-btn--primary" data-action="setup">Set up now</button>
<button class="eid-nudge-btn eid-nudge-btn--dismiss" data-action="later">Later</button>
</div>
`;
toast.querySelector('[data-action="setup"]')?.addEventListener("click", () => {
toast.remove();
this.showAccountModal({ openSection: "device" });
});
toast.querySelector('[data-action="later"]')?.addEventListener("click", () => {
localStorage.setItem(NUDGE_KEY, String(Date.now()));
toast.style.animation = "eid-nudge-in 0.3s ease-in reverse forwards";
setTimeout(() => toast.remove(), 300);
});
document.body.appendChild(toast);
} catch { /* offline */ }
}
#onStorageChange = (e: StorageEvent) => {
if (e.key === "encryptid_session" || e.key === PERSONAS_KEY) {
this.#render();
@ -980,7 +1063,7 @@ export class RStackIdentity extends HTMLElement {
// ── Account modal (consolidated) ──
showAccountModal(): void {
showAccountModal(options?: { openSection?: string }): void {
if (document.querySelector(".rstack-account-overlay")) return;
const overlay = document.createElement("div");
overlay.className = "rstack-account-overlay";
@ -988,7 +1071,7 @@ export class RStackIdentity extends HTMLElement {
const session = getSession();
if (!session) return;
let openSection: string | null = null;
let openSection: string | null = options?.openSection ?? null;
// Account completion status
let acctStatus: { email: boolean; emailAddress?: string | null; multiDevice: boolean; socialRecovery: boolean; guardianCount: number; credentialCount: number } | null = null;