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:
parent
cd6d979b5a
commit
50edf06900
|
|
@ -369,6 +369,9 @@ export class RStackIdentity extends HTMLElement {
|
||||||
// Validate session with server — detects logout from another browser session
|
// Validate session with server — detects logout from another browser session
|
||||||
this.#validateSessionWithServer();
|
this.#validateSessionWithServer();
|
||||||
|
|
||||||
|
// Nudge users without a second device to link one
|
||||||
|
this.#checkDeviceNudge();
|
||||||
|
|
||||||
// Propagate login/logout across tabs via storage events
|
// Propagate login/logout across tabs via storage events
|
||||||
window.addEventListener("storage", this.#onStorageChange);
|
window.addEventListener("storage", this.#onStorageChange);
|
||||||
}
|
}
|
||||||
|
|
@ -396,6 +399,86 @@ export class RStackIdentity extends HTMLElement {
|
||||||
} catch { /* network error — let token expire naturally */ }
|
} 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) => {
|
#onStorageChange = (e: StorageEvent) => {
|
||||||
if (e.key === "encryptid_session" || e.key === PERSONAS_KEY) {
|
if (e.key === "encryptid_session" || e.key === PERSONAS_KEY) {
|
||||||
this.#render();
|
this.#render();
|
||||||
|
|
@ -980,7 +1063,7 @@ export class RStackIdentity extends HTMLElement {
|
||||||
|
|
||||||
// ── Account modal (consolidated) ──
|
// ── Account modal (consolidated) ──
|
||||||
|
|
||||||
showAccountModal(): void {
|
showAccountModal(options?: { openSection?: string }): void {
|
||||||
if (document.querySelector(".rstack-account-overlay")) return;
|
if (document.querySelector(".rstack-account-overlay")) return;
|
||||||
const overlay = document.createElement("div");
|
const overlay = document.createElement("div");
|
||||||
overlay.className = "rstack-account-overlay";
|
overlay.className = "rstack-account-overlay";
|
||||||
|
|
@ -988,7 +1071,7 @@ export class RStackIdentity extends HTMLElement {
|
||||||
const session = getSession();
|
const session = getSession();
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
|
|
||||||
let openSection: string | null = null;
|
let openSection: string | null = options?.openSection ?? null;
|
||||||
|
|
||||||
// Account completion status
|
// Account completion status
|
||||||
let acctStatus: { email: boolean; emailAddress?: string | null; multiDevice: boolean; socialRecovery: boolean; guardianCount: number; credentialCount: number } | null = null;
|
let acctStatus: { email: boolean; emailAddress?: string | null; multiDevice: boolean; socialRecovery: boolean; guardianCount: number; credentialCount: number } | null = null;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue