From edce8e8c4a2550ba9ad22c9b1abef4a1e091d4aa Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Sat, 25 Apr 2026 16:41:01 -0400 Subject: [PATCH] Move passkey enrollment prompt to post-login dedicated page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After password login, users are routed through /Passkey/EnrollPrompt before reaching the dashboard. The page shows an Enable / Maybe later choice using the auth layout for a clean full-screen experience. Users who already have a passkey are skipped past instantly. Removes the floating bottom-right card from _Layout — the dedicated page is a better UX touchpoint (one moment, right after login, rather than a floating card on every page). Co-Authored-By: Claude Sonnet 4.6 --- .../Areas/Identity/Pages/Account/Login.cshtml | 7 +++ .../Controllers/PasskeyController.cs | 22 +++++++ .../Views/Passkey/EnrollPrompt.cshtml | 63 +++++++++++++++++++ .../Views/Shared/_Layout.cshtml | 27 -------- .../wwwroot/js/passkey-enroll.js | 52 +++++++++++++++ src/PowderCoating.Web/wwwroot/js/passkey.js | 57 ----------------- 6 files changed, 144 insertions(+), 84 deletions(-) create mode 100644 src/PowderCoating.Web/Views/Passkey/EnrollPrompt.cshtml create mode 100644 src/PowderCoating.Web/wwwroot/js/passkey-enroll.js diff --git a/src/PowderCoating.Web/Areas/Identity/Pages/Account/Login.cshtml b/src/PowderCoating.Web/Areas/Identity/Pages/Account/Login.cshtml index d264231..c8ecf11 100644 --- a/src/PowderCoating.Web/Areas/Identity/Pages/Account/Login.cshtml +++ b/src/PowderCoating.Web/Areas/Identity/Pages/Account/Login.cshtml @@ -210,7 +210,14 @@ } + @{ + var _origReturn = Request.Query["ReturnUrl"].FirstOrDefault() ?? "/"; + var _enrollUrl = string.IsNullOrEmpty(_origReturn) || _origReturn == "/" + ? "/Passkey/EnrollPrompt" + : "/Passkey/EnrollPrompt?returnUrl=" + Uri.EscapeDataString(_origReturn); + }
+
diff --git a/src/PowderCoating.Web/Controllers/PasskeyController.cs b/src/PowderCoating.Web/Controllers/PasskeyController.cs index af5ab56..e8f0334 100644 --- a/src/PowderCoating.Web/Controllers/PasskeyController.cs +++ b/src/PowderCoating.Web/Controllers/PasskeyController.cs @@ -255,6 +255,28 @@ public class PasskeyController : Controller // ─── Management ─────────────────────────────────────────────────────────── + // ─── Post-login enrollment prompt ───────────────────────────────────────── + + /// + /// Shown immediately after password login. If the user already has a passkey, + /// redirects straight to returnUrl. Otherwise presents the "Enable Face ID" page. + /// + [Authorize] + [HttpGet("/Passkey/EnrollPrompt")] + public async Task EnrollPrompt(string? returnUrl) + { + var user = await _userManager.GetUserAsync(User); + if (user == null) return Unauthorized(); + + // Skip prompt for users who already have at least one passkey + var hasPasskey = await _db.UserPasskeys.AnyAsync(p => p.UserId == user.Id); + if (hasPasskey) + return Redirect(returnUrl ?? "/"); + + ViewBag.ReturnUrl = returnUrl ?? "/"; + return View(); + } + /// Shows all passkeys registered by the current user. [Authorize] [HttpGet("/Passkey/Manage")] diff --git a/src/PowderCoating.Web/Views/Passkey/EnrollPrompt.cshtml b/src/PowderCoating.Web/Views/Passkey/EnrollPrompt.cshtml new file mode 100644 index 0000000..83f9fc0 --- /dev/null +++ b/src/PowderCoating.Web/Views/Passkey/EnrollPrompt.cshtml @@ -0,0 +1,63 @@ +@{ + ViewData["Title"] = "Enable Biometric Login"; + Layout = "/Views/Shared/_AuthLayout.cshtml"; + var returnUrl = ViewBag.ReturnUrl as string ?? "/"; +} + +
+ +
+ Powder Coating Logix +

Powder Coating Logix

+

The complete management platform for powder coating businesses

+
    +
  • Fast biometric login
  • +
  • Secure — your biometrics never leave your device
  • +
  • Works with Face ID, Touch ID & Windows Hello
  • +
  • Remove any device at any time
  • +
+
+ + +
+
+ +
+
+ +
+

Speed up future logins

+

+ Enable biometric login so next time you can sign in with a single tap — + no password needed. +

+ +

+ +
+ + + Maybe later + +
+
+ +
+
+ +
+

All set!

+

Biometric login is enabled for this device.

+ Continue +
+ +
+
+
+ +@section Scripts { + + +} diff --git a/src/PowderCoating.Web/Views/Shared/_Layout.cshtml b/src/PowderCoating.Web/Views/Shared/_Layout.cshtml index 41b5ca5..449d017 100644 --- a/src/PowderCoating.Web/Views/Shared/_Layout.cshtml +++ b/src/PowderCoating.Web/Views/Shared/_Layout.cshtml @@ -895,33 +895,6 @@ } - @* Passkey setup prompt — shown once per session to authenticated users who have no passkeys yet *@ - @if (User.Identity?.IsAuthenticated == true && !User.IsInRole("SuperAdmin")) - { -
-
-
-
- -
-
Enable Face ID / Biometric Login
-
Skip the password next time — use your fingerprint or Face ID.
-
- -
-

-
- - Manage -
-
-
-
- } - @* Hidden container for ModelState errors (read by toast-notifications.js) *@ @if (!ViewData.ModelState.IsValid && ViewData.ModelState.ErrorCount > 0) { diff --git a/src/PowderCoating.Web/wwwroot/js/passkey-enroll.js b/src/PowderCoating.Web/wwwroot/js/passkey-enroll.js new file mode 100644 index 0000000..2df8b3d --- /dev/null +++ b/src/PowderCoating.Web/wwwroot/js/passkey-enroll.js @@ -0,0 +1,52 @@ +document.addEventListener('DOMContentLoaded', async () => { + const enableBtn = document.getElementById('pk-enable-btn'); + const btnLabel = document.getElementById('pk-btn-label'); + const statusEl = document.getElementById('pk-status'); + const promptStep = document.getElementById('prompt-step'); + const successStep = document.getElementById('success-step'); + + if (!enableBtn) return; + + // Set platform-specific label + const label = passkeyLabel(); + if (btnLabel) btnLabel.textContent = `Enable ${label.replace('Use ', '')}`; + + const supported = await passkeySupported(); + if (!supported) { + enableBtn.disabled = true; + enableBtn.textContent = 'Not supported on this browser'; + if (statusEl) { + statusEl.textContent = 'Your browser does not support biometric login. You can enable it later from Settings → Passkeys & Biometrics on a supported device.'; + statusEl.className = 'small mb-3 text-muted'; + } + return; + } + + enableBtn.addEventListener('click', async () => { + enableBtn.disabled = true; + enableBtn.innerHTML = 'Follow the prompt on your device…'; + if (statusEl) { statusEl.textContent = ''; } + + const ua = navigator.userAgent; + const deviceName = /iphone/i.test(ua) ? 'iPhone' + : /ipad/i.test(ua) ? 'iPad' + : /android/i.test(ua) ? 'Android device' + : /macintosh/i.test(ua) ? 'Mac' + : /windows/i.test(ua) ? 'Windows PC' + : 'This device'; + + const result = await registerPasskey(deviceName); + + if (result.success) { + promptStep.classList.add('d-none'); + successStep.classList.remove('d-none'); + } else { + enableBtn.disabled = false; + enableBtn.innerHTML = `Enable ${label.replace('Use ', '')}`; + if (statusEl) { + statusEl.textContent = result.error || 'Setup failed. Please try again.'; + statusEl.className = 'small mb-3 text-danger'; + } + } + }); +}); diff --git a/src/PowderCoating.Web/wwwroot/js/passkey.js b/src/PowderCoating.Web/wwwroot/js/passkey.js index 47826d3..c3c7fe4 100644 --- a/src/PowderCoating.Web/wwwroot/js/passkey.js +++ b/src/PowderCoating.Web/wwwroot/js/passkey.js @@ -272,60 +272,3 @@ document.addEventListener('DOMContentLoaded', async () => { }); }); -// ─── Post-login prompt wiring (layout) ─────────────────────────────────────── - -document.addEventListener('DOMContentLoaded', async () => { - const prompt = document.getElementById('passkey-setup-prompt'); - if (!prompt) return; - - const supported = await passkeySupported(); - if (!supported) { prompt.remove(); return; } - - const label = passkeyLabel(); - const enableBtn = document.getElementById('passkey-enable-btn'); - if (enableBtn) enableBtn.innerHTML = `Enable ${label.replace('Use ', '')}`; - - prompt.classList.remove('d-none'); - - const dismissBtn = document.getElementById('passkey-dismiss-btn'); - const statusEl = document.getElementById('passkey-setup-status'); - - enableBtn?.addEventListener('click', async () => { - enableBtn.disabled = true; - if (statusEl) statusEl.textContent = 'Follow the prompt on your device…'; - - const ua = navigator.userAgent; - const deviceName = /iphone/i.test(ua) ? 'iPhone' - : /ipad/i.test(ua) ? 'iPad' - : /android/i.test(ua) ? 'Android device' - : /macintosh/i.test(ua) ? 'Mac' - : /windows/i.test(ua) ? 'Windows PC' - : 'This device'; - - const result = await registerPasskey(deviceName); - - if (result.success) { - if (statusEl) { - statusEl.textContent = `✓ ${label.replace('Use ', '')} enabled for this device!`; - statusEl.className = 'small mb-0 text-success'; - } - enableBtn.classList.add('d-none'); - if (dismissBtn) dismissBtn.textContent = 'Close'; - setTimeout(() => prompt.classList.add('d-none'), 3000); - } else { - enableBtn.disabled = false; - enableBtn.innerHTML = `Enable ${label.replace('Use ', '')}`; - if (statusEl) statusEl.textContent = result.error || 'Setup failed. Try again.'; - } - }); - - dismissBtn?.addEventListener('click', () => { - prompt.classList.add('d-none'); - // Remember dismissal for this session so it doesn't re-appear on every page load - sessionStorage.setItem('passkey-prompt-dismissed', '1'); - }); - - if (sessionStorage.getItem('passkey-prompt-dismissed')) { - prompt.classList.add('d-none'); - } -});