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:
parent
772e5e4352
commit
a7eda3c53f
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue