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:
@@ -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; }
|
||||
|
||||
Generated
+9304
File diff suppressed because it is too large
Load Diff
+72
@@ -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 & 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>
|
||||
|
||||
Reference in New Issue
Block a user