From a7eda3c53f204a1ff3772263dc02ab987a0aec87 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 24 Mar 2026 16:05:50 -0700 Subject: [PATCH] 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 --- shared/components/rstack-identity.ts | 153 ++++++++++++++++++++++++++- 1 file changed, 151 insertions(+), 2 deletions(-) diff --git a/shared/components/rstack-identity.ts b/shared/components/rstack-identity.ts index 7c082c0..c04ec68 100644 --- a/shared/components/rstack-identity.ts +++ b/shared/components/rstack-identity.ts @@ -810,8 +810,8 @@ export class RStackIdentity extends HTMLElement { this.#render(); this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true })); callbacks?.onSuccess?.(); - // Auto-redirect to personal space - autoResolveSpace(data.token, username); + // Show post-signup prompt recommending second device before redirecting + this.#showPostSignupPrompt(data.token, username); } catch (err: any) { btn.disabled = false; 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 = ` + +
+

Welcome to rSpace!

+

Your account ${username} is ready.

+ +
+
📱
+
+
Link a Second Device
+
+ 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. +
+
${qrHint}
+
+
+ + + +

You can always link devices later from My Account.

+
`; + } 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 = ` + +
+

Link Another Device

+

Scan this QR code on your phone or tablet to add a passkey.

+
+ QR Code +
+ +

Link expires in 10 minutes

+
+ + +
+
`; + } else { + overlay.innerHTML = ` + +
+
+

Device Linked!

+

You can now sign in from your other device too.

+ +
`; + } + 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 = ' 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) ── 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 = ` .rstack-spaces-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6);