From 50edf0690063f0c50ced27a6f121991a3c3637f6 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 24 Mar 2026 16:14:59 -0700 Subject: [PATCH] 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 --- shared/components/rstack-identity.ts | 87 +++++++++++++++++++++++++++- 1 file changed, 85 insertions(+), 2 deletions(-) diff --git a/shared/components/rstack-identity.ts b/shared/components/rstack-identity.ts index c04ec68..4862a1d 100644 --- a/shared/components/rstack-identity.ts +++ b/shared/components/rstack-identity.ts @@ -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 = ` + +
+ 📱 + Link a second device +
+
+ Sign in from your phone or tablet too — it also acts as a backup if you ever lose access to this device. +
+
+ + +
+ `; + + 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;