feat(encryptid): post-signup prompt recommending second device linking

After registration, users now see a welcome modal that prominently
recommends linking a second device via QR code before entering their
space. Provides backup access and cross-device sign-in awareness.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-24 16:05:50 -07:00
parent 772e5e4352
commit a7eda3c53f
1 changed files with 151 additions and 2 deletions

View File

@ -810,8 +810,8 @@ export class RStackIdentity extends HTMLElement {
this.#render(); this.#render();
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true })); this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true }));
callbacks?.onSuccess?.(); callbacks?.onSuccess?.();
// Auto-redirect to personal space // Show post-signup prompt recommending second device before redirecting
autoResolveSpace(data.token, username); this.#showPostSignupPrompt(data.token, username);
} catch (err: any) { } catch (err: any) {
btn.disabled = false; btn.disabled = false;
btn.innerHTML = "🔐 Create Passkey"; btn.innerHTML = "🔐 Create Passkey";
@ -861,6 +861,123 @@ export class RStackIdentity extends HTMLElement {
} }
} }
// ── Post-signup onboarding prompt ──
#showPostSignupPrompt(token: string, username: string): void {
const overlay = document.createElement("div");
overlay.className = "rstack-auth-overlay";
const goToSpace = () => {
overlay.remove();
autoResolveSpace(token, username);
};
let step: "welcome" | "linking" | "done" = "welcome";
let linkUrl = "";
const render = () => {
if (step === "welcome") {
const qrHint = /Mobi|Android|iPhone|iPad/i.test(navigator.userAgent)
? "You can link your laptop or another phone."
: "Grab your phone or tablet and scan the code.";
overlay.innerHTML = `
<style>${MODAL_STYLES}${ONBOARDING_STYLES}</style>
<div class="auth-modal onboarding-modal">
<h2>Welcome to rSpace!</h2>
<p style="margin-bottom:0.75rem">Your account <strong>${username}</strong> is ready.</p>
<div class="onboarding-card onboarding-card--primary">
<div class="onboarding-card-icon">📱</div>
<div class="onboarding-card-content">
<div class="onboarding-card-title">Link a Second Device</div>
<div class="onboarding-card-desc">
Add a passkey on your phone or tablet so you can sign in from anywhere.
This also serves as a backup if you lose access to this device.
</div>
<div class="onboarding-card-hint">${qrHint}</div>
</div>
</div>
<button class="btn btn--primary" style="width:100%;margin-top:0.75rem" data-action="link-device">📱 Link Another Device</button>
<button class="btn btn--outline" style="width:100%;margin-top:0.5rem" data-action="skip">Continue to my space</button>
<p class="onboarding-later-hint">You can always link devices later from My Account.</p>
</div>`;
} else if (step === "linking") {
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(linkUrl)}&format=png&margin=8`;
overlay.innerHTML = `
<style>${MODAL_STYLES}${ONBOARDING_STYLES}</style>
<div class="auth-modal onboarding-modal">
<h2>Link Another Device</h2>
<p style="margin-bottom:0.5rem">Scan this QR code on your phone or tablet to add a passkey.</p>
<div class="qr-container">
<img src="${qrSrc}" width="200" height="200" alt="QR Code" style="border-radius:12px;background:#fff;padding:4px" />
</div>
<div class="link-copy-row">
<input class="input" id="onboarding-link-url" type="text" readonly value="${linkUrl}" style="font-size:0.8rem;margin-bottom:0" />
<button class="btn btn--secondary btn--small" data-action="copy-link" style="flex:none;white-space:nowrap">Copy</button>
</div>
<p class="onboarding-expire-hint">Link expires in 10 minutes</p>
<div class="actions" style="margin-top:1rem">
<button class="btn btn--secondary" data-action="back">Back</button>
<button class="btn btn--primary" data-action="done">Continue to my space</button>
</div>
</div>`;
} else {
overlay.innerHTML = `
<style>${MODAL_STYLES}${ONBOARDING_STYLES}</style>
<div class="auth-modal onboarding-modal">
<div style="font-size:2.5rem;margin-bottom:0.5rem"></div>
<h2>Device Linked!</h2>
<p>You can now sign in from your other device too.</p>
<button class="btn btn--primary" style="width:100%;margin-top:1rem" data-action="done">Continue to my space</button>
</div>`;
}
attachListeners();
};
const attachListeners = () => {
overlay.querySelector('[data-action="skip"]')?.addEventListener("click", goToSpace);
overlay.querySelector('[data-action="done"]')?.addEventListener("click", goToSpace);
overlay.querySelector('[data-action="back"]')?.addEventListener("click", () => {
step = "welcome";
render();
});
overlay.querySelector('[data-action="copy-link"]')?.addEventListener("click", () => {
const input = overlay.querySelector("#onboarding-link-url") as HTMLInputElement;
navigator.clipboard.writeText(input.value).catch(() => {});
const btn = overlay.querySelector('[data-action="copy-link"]') as HTMLButtonElement;
btn.textContent = "Copied!";
setTimeout(() => { btn.textContent = "Copy"; }, 2000);
});
overlay.querySelector('[data-action="link-device"]')?.addEventListener("click", async () => {
const btn = overlay.querySelector('[data-action="link-device"]') as HTMLButtonElement;
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Generating link...';
try {
const res = await fetch(`${ENCRYPTID_URL}/api/device-link/start`, {
method: "POST",
headers: { Authorization: `Bearer ${getAccessToken()}` },
});
const data = await res.json();
if (!res.ok || data.error) throw new Error(data.error || "Failed to generate link");
linkUrl = data.linkUrl;
step = "linking";
render();
} catch (e: any) {
btn.disabled = false;
btn.innerHTML = "📱 Link Another Device";
const errEl = document.createElement("div");
errEl.className = "error";
errEl.textContent = e.message;
btn.parentElement?.appendChild(errEl);
}
});
};
document.body.appendChild(overlay);
render();
}
// ── Account modal (consolidated) ── // ── Account modal (consolidated) ──
showAccountModal(): void { showAccountModal(): void {
@ -2100,6 +2217,38 @@ const ACCOUNT_MODAL_STYLES = `
} }
`; `;
const ONBOARDING_STYLES = `
.onboarding-modal { max-width: 440px; }
.onboarding-card {
border: 1px solid var(--rs-border); border-radius: 12px;
padding: 1rem 1.25rem; display: flex; gap: 1rem; align-items: flex-start;
text-align: left; margin-top: 0.75rem; transition: border-color 0.2s;
}
.onboarding-card--primary {
border-color: rgba(6,182,212,0.4);
background: linear-gradient(135deg, rgba(6,182,212,0.06), rgba(124,58,237,0.06));
}
.onboarding-card-icon { font-size: 2rem; flex-shrink: 0; margin-top: 2px; }
.onboarding-card-content { flex: 1; min-width: 0; }
.onboarding-card-title {
font-size: 1rem; font-weight: 600; color: var(--rs-text-primary); margin-bottom: 4px;
}
.onboarding-card-desc {
font-size: 0.85rem; color: var(--rs-text-secondary); line-height: 1.5;
}
.onboarding-card-hint {
font-size: 0.8rem; color: #06b6d4; margin-top: 6px; font-weight: 500;
}
.onboarding-later-hint {
font-size: 0.75rem; color: var(--rs-text-muted); margin-top: 0.5rem;
}
.qr-container { text-align: center; margin: 0.75rem 0; }
.link-copy-row { display: flex; gap: 8px; align-items: stretch; }
.onboarding-expire-hint {
font-size: 0.75rem; color: var(--rs-text-muted); text-align: center; margin-top: 0.5rem;
}
`;
const SPACES_STYLES = ` const SPACES_STYLES = `
.rstack-spaces-overlay { .rstack-spaces-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.6); position: fixed; inset: 0; background: rgba(0,0,0,0.6);