feat(identity): replace device nudge toast with QR code for mobile linking

Instead of a "Set up now" button, the device nudge now generates a device
link token and displays a scannable QR code + copyable link URL directly
in the toast, making it easy to link a phone or tablet.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-24 17:06:29 -07:00
parent df8631360e
commit 13a7e44e24
1 changed files with 105 additions and 51 deletions

View File

@ -420,62 +420,116 @@ export class RStackIdentity extends HTMLElement {
const status = await res.json();
if (status.multiDevice) return; // already has 2+ devices
// Show a toast nudge
// Show a toast nudge with QR code
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);
});
let linkUrl = "";
let linkError = "";
const renderNudge = () => {
const qrHTML = linkUrl
? `<div class="eid-nudge-qr">
<img src="https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(linkUrl)}&format=png&margin=6" width="160" height="160" alt="Scan to link device" />
</div>
<div class="eid-nudge-link-row">
<input class="eid-nudge-link-input" type="text" readonly value="${linkUrl}" />
<button class="eid-nudge-btn eid-nudge-btn--copy" data-action="copy">Copy</button>
</div>
<div class="eid-nudge-expire">Expires in 10 minutes</div>`
: linkError
? `<div class="eid-nudge-error">${linkError}</div>`
: `<div class="eid-nudge-loading"><span class="eid-nudge-spinner"></span> Generating link…</div>`;
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-qr { text-align: center; margin-bottom: 0.5rem; }
.eid-nudge-qr img { border-radius: 10px; background: #fff; padding: 4px; }
.eid-nudge-link-row { display: flex; gap: 6px; margin-bottom: 0.4rem; }
.eid-nudge-link-input {
flex: 1; font-size: 0.72rem; padding: 5px 8px; border-radius: 6px;
border: 1px solid var(--rs-border, #334155); background: var(--rs-bg-inset, #0f172a);
color: var(--rs-text-secondary, #94a3b8); min-width: 0; outline: none;
}
.eid-nudge-btn--copy {
padding: 5px 10px; border-radius: 6px; border: 1px solid var(--rs-border, #334155);
background: transparent; color: var(--rs-text-secondary, #94a3b8);
font-size: 0.75rem; font-weight: 600; cursor: pointer; white-space: nowrap;
}
.eid-nudge-btn--copy:hover { color: var(--rs-text-primary, #e2e8f0); }
.eid-nudge-expire { font-size: 0.75rem; color: var(--rs-text-muted, #64748b); text-align: center; margin-bottom: 0.5rem; }
.eid-nudge-loading { text-align: center; padding: 1.5rem 0; color: var(--rs-text-secondary, #94a3b8); font-size: 0.85rem; }
.eid-nudge-error { text-align: center; padding: 0.75rem 0; color: #f87171; font-size: 0.85rem; }
.eid-nudge-spinner {
display: inline-block; width: 14px; height: 14px; border: 2px solid rgba(148,163,184,0.3);
border-top-color: #94a3b8; border-radius: 50%; animation: eid-spin 0.6s linear infinite;
vertical-align: middle; margin-right: 6px;
}
.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--dismiss {
background: transparent; color: var(--rs-text-muted, #64748b);
border: 1px solid var(--rs-border, #334155); flex: 1; text-align: center;
}
.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); } }
@keyframes eid-spin { to { transform: rotate(360deg); } }
</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">
Scan this QR code on your phone or tablet to add a passkey backup.
</div>
${qrHTML}
<div class="eid-nudge-actions">
<button class="eid-nudge-btn eid-nudge-btn--dismiss" data-action="later">Dismiss</button>
</div>
`;
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);
});
toast.querySelector('[data-action="copy"]')?.addEventListener("click", () => {
navigator.clipboard.writeText(linkUrl).catch(() => {});
const btn = toast.querySelector('[data-action="copy"]') as HTMLButtonElement;
if (btn) { btn.textContent = "Copied!"; setTimeout(() => { btn.textContent = "Copy"; }, 2000); }
});
};
document.body.appendChild(toast);
renderNudge(); // show loading state
// Generate device link
try {
const linkRes = await fetch(`${ENCRYPTID_URL}/api/device-link/start`, {
method: "POST",
headers: { Authorization: `Bearer ${getAccessToken()}` },
});
const linkData = await linkRes.json();
if (!linkRes.ok || linkData.error) throw new Error(linkData.error || "Failed");
linkUrl = linkData.linkUrl;
} catch {
linkError = "Could not generate link. Try My Account → Devices.";
}
renderNudge(); // show QR or error
} catch { /* offline */ }
}