Add passkey prompt dismissal and consolidate company admin navigation

- Add "Don't ask me again" to passkey enrollment prompt (PasskeyPromptDismissed
  field on ApplicationUser; DismissPrompt POST action; migration applied)
- Add Subscription & Features button to Companies/Index btn-group and
  Companies/Edit header for direct navigation to SubscriptionManagement/Manage
- Add Edit Company back-link on SubscriptionManagement/Manage
- Remove duplicate AI Features section from Companies/Edit (managed exclusively
  via Subscription & Features page)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-26 10:34:50 -04:00
parent 3899860c1f
commit a4b8ae611a
9 changed files with 9426 additions and 39 deletions
@@ -61,6 +61,9 @@ public class ApplicationUser : IdentityUser
public DateTime? UpdatedAt { get; set; }
public DateTime? LastLoginDate { get; set; }
// Passkey enrollment prompt
public bool PasskeyPromptDismissed { get; set; } = false;
// Ban
public bool IsBanned { get; set; } = false;
public DateTime? BannedAt { get; set; }
@@ -0,0 +1,72 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddPasskeyPromptDismissed : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "PasskeyPromptDismissed",
table: "AspNetUsers",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5921));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5931));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5932));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PasskeyPromptDismissed",
table: "AspNetUsers");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 4, 26, 12, 26, 21, 727, DateTimeKind.Utc).AddTicks(5012));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 4, 26, 12, 26, 21, 727, DateTimeKind.Utc).AddTicks(5018));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 4, 26, 12, 26, 21, 727, DateTimeKind.Utc).AddTicks(5020));
}
}
}
@@ -555,6 +555,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.Property<bool>("PasskeyPromptDismissed")
.HasColumnType("bit");
b.Property<string>("PasswordHash")
.HasColumnType("nvarchar(max)");
@@ -5836,7 +5839,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
CompanyId = 0,
CreatedAt = new DateTime(2026, 4, 26, 12, 26, 21, 727, DateTimeKind.Utc).AddTicks(5012),
CreatedAt = new DateTime(2026, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5921),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -5847,7 +5850,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
CompanyId = 0,
CreatedAt = new DateTime(2026, 4, 26, 12, 26, 21, 727, DateTimeKind.Utc).AddTicks(5018),
CreatedAt = new DateTime(2026, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5931),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -5858,7 +5861,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
CompanyId = 0,
CreatedAt = new DateTime(2026, 4, 26, 12, 26, 21, 727, DateTimeKind.Utc).AddTicks(5020),
CreatedAt = new DateTime(2026, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5932),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -258,8 +258,8 @@ public class PasskeyController : Controller
// ─── 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.
/// Shown immediately after password login. Skips to returnUrl if the user already
/// has a passkey or has previously dismissed the prompt.
/// </summary>
[Authorize]
[HttpGet("/Passkey/EnrollPrompt")]
@@ -268,15 +268,32 @@ public class PasskeyController : Controller
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)
if (hasPasskey || user.PasskeyPromptDismissed)
return Redirect(returnUrl ?? "/");
ViewBag.ReturnUrl = returnUrl ?? "/";
return View();
}
/// <summary>
/// Permanently dismisses the passkey enrollment prompt for this user. They can
/// re-enable it from Profile → Security at any time.
/// </summary>
[Authorize]
[HttpPost("/Passkey/DismissPrompt")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DismissPrompt(string? returnUrl)
{
var user = await _userManager.GetUserAsync(User);
if (user == null) return Unauthorized();
user.PasskeyPromptDismissed = true;
await _userManager.UpdateAsync(user);
return Redirect(returnUrl ?? "/");
}
/// <summary>Shows all passkeys registered by the current user.</summary>
[Authorize]
[HttpGet("/Passkey/Manage")]
@@ -10,10 +10,14 @@
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-10">
<div class="d-flex justify-content-end mb-4">
<div class="d-flex justify-content-between mb-4">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back to List
</a>
<a asp-controller="SubscriptionManagement" asp-action="Manage" asp-route-id="@Model.Id"
class="btn btn-outline-info">
<i class="bi bi-credit-card me-1"></i>Subscription &amp; Features
</a>
</div>
<div class="card shadow-sm">
@@ -145,37 +149,6 @@
</div>
</div>
<h5 class="card-title mb-3 pb-2 border-bottom">AI Features</h5>
<p class="text-muted small mb-3">Control which AI-powered features are available to this company and set monthly usage limits.</p>
<div class="row g-3 mb-4">
<div class="col-md-4">
<div class="form-check form-switch">
<input asp-for="AiPhotoQuotesEnabled" class="form-check-input" type="checkbox" />
<label asp-for="AiPhotoQuotesEnabled" class="form-check-label fw-medium">AI Photo Quotes</label>
</div>
<div class="form-text">Allow this company to use photo-based AI quoting.</div>
</div>
<div class="col-md-4">
<div class="form-check form-switch">
<input asp-for="AiInventoryAssistEnabled" class="form-check-input" type="checkbox" />
<label asp-for="AiInventoryAssistEnabled" class="form-check-label fw-medium">AI Inventory Assist</label>
</div>
<div class="form-text">Allow this company to use AI lookup on inventory items.</div>
</div>
<div class="col-md-4">
<div class="form-check form-switch">
<input asp-for="AiCatalogPriceCheckEnabled" class="form-check-input" type="checkbox" />
<label asp-for="AiCatalogPriceCheckEnabled" class="form-check-label fw-medium">AI Catalog Price Check</label>
</div>
<div class="form-text">Override: grants access regardless of plan tier.</div>
</div>
<div class="col-md-4">
<label asp-for="MaxAiPhotoQuotesPerMonthOverride" class="form-label">Monthly AI Quote Limit Override</label>
<input asp-for="MaxAiPhotoQuotesPerMonthOverride" type="number" class="form-control" min="-1" placeholder="Leave blank to use plan default" />
<div class="form-text">-1 = unlimited. 0 = disabled. Blank = use subscription plan default.</div>
</div>
</div>
<div class="d-flex gap-2 justify-content-end">
<a asp-action="Index" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">
@@ -184,6 +184,10 @@
class="btn btn-outline-secondary" title="Edit">
<i class="bi bi-pencil"></i>
</a>
<a asp-controller="SubscriptionManagement" asp-action="Manage" asp-route-id="@company.Id"
class="btn btn-outline-info" title="Manage Subscription & Features">
<i class="bi bi-credit-card"></i>
</a>
<form asp-action="ToggleActive" asp-route-id="@company.Id"
method="post" class="d-inline">
@Html.AntiForgeryToken()
@@ -121,6 +121,13 @@
<a id="pk-skip-link" href="@returnUrl" class="btn btn-outline-secondary btn-lg">
Maybe later
</a>
<form method="post" action="/Passkey/DismissPrompt">
@Html.AntiForgeryToken()
<input type="hidden" name="returnUrl" value="@returnUrl" />
<button type="submit" class="btn btn-link text-muted w-100" style="font-size:0.85rem;">
Don't ask me again
</button>
</form>
</div>
</div>
@@ -25,6 +25,10 @@
<a asp-action="Index" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Back
</a>
<a asp-controller="Companies" asp-action="Edit" asp-route-id="@Model.Id"
class="btn btn-outline-secondary btn-sm">
<i class="bi bi-building me-1"></i>Edit Company
</a>
<h4 class="mb-0">
<i class="bi bi-credit-card me-2 text-primary"></i>@Model.CompanyName
</h4>