Move passkey enrollment prompt to post-login dedicated page
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 <noreply@anthropic.com>
This commit is contained in:
@@ -210,7 +210,14 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@{
|
||||
var _origReturn = Request.Query["ReturnUrl"].FirstOrDefault() ?? "/";
|
||||
var _enrollUrl = string.IsNullOrEmpty(_origReturn) || _origReturn == "/"
|
||||
? "/Passkey/EnrollPrompt"
|
||||
: "/Passkey/EnrollPrompt?returnUrl=" + Uri.EscapeDataString(_origReturn);
|
||||
}
|
||||
<form id="account" method="post">
|
||||
<input type="hidden" name="ReturnUrl" value="@_enrollUrl" />
|
||||
<div asp-validation-summary="ModelOnly" class="alert alert-danger py-2 mb-3" role="alert" style="display:@(ViewContext.ViewData.ModelState.IsValid ? "none" : "block")"></div>
|
||||
|
||||
<div class="mb-3">
|
||||
|
||||
@@ -255,6 +255,28 @@ public class PasskeyController : Controller
|
||||
|
||||
// ─── Management ───────────────────────────────────────────────────────────
|
||||
|
||||
// ─── Post-login enrollment prompt ─────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Shown immediately after password login. If the user already has a passkey,
|
||||
/// redirects straight to returnUrl. Otherwise presents the "Enable Face ID" page.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
[HttpGet("/Passkey/EnrollPrompt")]
|
||||
public async Task<IActionResult> 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();
|
||||
}
|
||||
|
||||
/// <summary>Shows all passkeys registered by the current user.</summary>
|
||||
[Authorize]
|
||||
[HttpGet("/Passkey/Manage")]
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
@{
|
||||
ViewData["Title"] = "Enable Biometric Login";
|
||||
Layout = "/Views/Shared/_AuthLayout.cshtml";
|
||||
var returnUrl = ViewBag.ReturnUrl as string ?? "/";
|
||||
}
|
||||
|
||||
<div class="d-flex" style="min-height:100vh;">
|
||||
<!-- Left brand panel — hidden on mobile, same as login page -->
|
||||
<div class="col-lg-5 d-none d-lg-flex auth-brand-panel">
|
||||
<img src="/images/pcl-logo.png" alt="Powder Coating Logix" style="max-width:220px; margin-bottom:1.5rem;" />
|
||||
<h1>Powder Coating Logix</h1>
|
||||
<p class="tagline">The complete management platform for powder coating businesses</p>
|
||||
<ul class="feature-list">
|
||||
<li><i class="bi bi-fingerprint"></i> Fast biometric login</li>
|
||||
<li><i class="bi bi-shield-check-fill"></i> Secure — your biometrics never leave your device</li>
|
||||
<li><i class="bi bi-phone-fill"></i> Works with Face ID, Touch ID & Windows Hello</li>
|
||||
<li><i class="bi bi-trash3-fill"></i> Remove any device at any time</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Right prompt panel -->
|
||||
<div class="auth-form-panel col-lg-7">
|
||||
<div class="auth-form-container text-center">
|
||||
|
||||
<div id="prompt-step">
|
||||
<div class="mb-4" style="font-size:4rem; color:#0284c7; line-height:1;">
|
||||
<i class="bi bi-fingerprint"></i>
|
||||
</div>
|
||||
<h2 class="mb-2">Speed up future logins</h2>
|
||||
<p class="subtext mb-4">
|
||||
Enable biometric login so next time you can sign in with a single tap —
|
||||
no password needed.
|
||||
</p>
|
||||
|
||||
<p id="pk-status" class="small mb-3" style="min-height:1.25em;"></p>
|
||||
|
||||
<div class="d-grid gap-2" style="max-width:320px; margin:0 auto;">
|
||||
<button id="pk-enable-btn" type="button" class="btn btn-primary btn-lg">
|
||||
<i class="bi bi-fingerprint me-2"></i><span id="pk-btn-label">Enable Biometric Login</span>
|
||||
</button>
|
||||
<a id="pk-skip-link" href="@returnUrl" class="btn btn-outline-secondary btn-lg">
|
||||
Maybe later
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="success-step" class="d-none">
|
||||
<div class="mb-4" style="font-size:4rem; color:#16a34a; line-height:1;">
|
||||
<i class="bi bi-check-circle-fill"></i>
|
||||
</div>
|
||||
<h2 class="mb-2">All set!</h2>
|
||||
<p class="subtext mb-4">Biometric login is enabled for this device.</p>
|
||||
<a href="@returnUrl" class="btn btn-primary btn-lg px-5">Continue</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/passkey.js"></script>
|
||||
<script src="~/js/passkey-enroll.js"></script>
|
||||
}
|
||||
@@ -895,33 +895,6 @@
|
||||
<div id="tempdata-info-message" style="display:none;">@TempData["Info"]</div>
|
||||
}
|
||||
|
||||
@* Passkey setup prompt — shown once per session to authenticated users who have no passkeys yet *@
|
||||
@if (User.Identity?.IsAuthenticated == true && !User.IsInRole("SuperAdmin"))
|
||||
{
|
||||
<div id="passkey-setup-prompt" class="d-none"
|
||||
style="position:fixed;bottom:1.25rem;right:1.25rem;z-index:1090;max-width:320px;">
|
||||
<div class="card shadow-lg border-0">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex align-items-start gap-2 mb-2">
|
||||
<i class="bi bi-fingerprint text-primary" style="font-size:1.4rem;flex-shrink:0;margin-top:2px;"></i>
|
||||
<div>
|
||||
<div class="fw-semibold" style="font-size:.9rem;">Enable Face ID / Biometric Login</div>
|
||||
<div class="text-muted" style="font-size:.8rem;">Skip the password next time — use your fingerprint or Face ID.</div>
|
||||
</div>
|
||||
<button type="button" id="passkey-dismiss-btn" class="btn-close ms-auto" style="font-size:.75rem;" aria-label="Dismiss"></button>
|
||||
</div>
|
||||
<p id="passkey-setup-status" class="small mb-2"></p>
|
||||
<div class="d-flex gap-2">
|
||||
<button id="passkey-enable-btn" type="button" class="btn btn-primary btn-sm flex-grow-1">
|
||||
<i class="bi bi-fingerprint me-1"></i>Enable
|
||||
</button>
|
||||
<a href="/Passkey/Manage" class="btn btn-outline-secondary btn-sm">Manage</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Hidden container for ModelState errors (read by toast-notifications.js) *@
|
||||
@if (!ViewData.ModelState.IsValid && ViewData.ModelState.ErrorCount > 0)
|
||||
{
|
||||
|
||||
@@ -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 = '<span class="spinner-border spinner-border-sm me-2"></span>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 = `<i class="bi bi-fingerprint me-2"></i>Enable ${label.replace('Use ', '')}`;
|
||||
if (statusEl) {
|
||||
statusEl.textContent = result.error || 'Setup failed. Please try again.';
|
||||
statusEl.className = 'small mb-3 text-danger';
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 = `<i class="bi bi-fingerprint me-1"></i>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 = `<i class="bi bi-fingerprint me-1"></i>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');
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user