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? UpdatedAt { get; set; }
|
||||||
public DateTime? LastLoginDate { get; set; }
|
public DateTime? LastLoginDate { get; set; }
|
||||||
|
|
||||||
|
// Passkey enrollment prompt
|
||||||
|
public bool PasskeyPromptDismissed { get; set; } = false;
|
||||||
|
|
||||||
// Ban
|
// Ban
|
||||||
public bool IsBanned { get; set; } = false;
|
public bool IsBanned { get; set; } = false;
|
||||||
public DateTime? BannedAt { get; set; }
|
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")
|
b.Property<string>("Notes")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<bool>("PasskeyPromptDismissed")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.Property<string>("PasswordHash")
|
b.Property<string>("PasswordHash")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
@@ -5836,7 +5839,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
CompanyId = 0,
|
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",
|
Description = "Standard pricing for regular customers",
|
||||||
DiscountPercent = 0m,
|
DiscountPercent = 0m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -5847,7 +5850,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
CompanyId = 0,
|
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",
|
Description = "5% discount for preferred customers",
|
||||||
DiscountPercent = 5m,
|
DiscountPercent = 5m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -5858,7 +5861,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 3,
|
Id = 3,
|
||||||
CompanyId = 0,
|
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",
|
Description = "10% discount for premium customers",
|
||||||
DiscountPercent = 10m,
|
DiscountPercent = 10m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
|
|||||||
@@ -258,8 +258,8 @@ public class PasskeyController : Controller
|
|||||||
// ─── Post-login enrollment prompt ─────────────────────────────────────────
|
// ─── Post-login enrollment prompt ─────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Shown immediately after password login. If the user already has a passkey,
|
/// Shown immediately after password login. Skips to returnUrl if the user already
|
||||||
/// redirects straight to returnUrl. Otherwise presents the "Enable Face ID" page.
|
/// has a passkey or has previously dismissed the prompt.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[HttpGet("/Passkey/EnrollPrompt")]
|
[HttpGet("/Passkey/EnrollPrompt")]
|
||||||
@@ -268,15 +268,32 @@ public class PasskeyController : Controller
|
|||||||
var user = await _userManager.GetUserAsync(User);
|
var user = await _userManager.GetUserAsync(User);
|
||||||
if (user == null) return Unauthorized();
|
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);
|
var hasPasskey = await _db.UserPasskeys.AnyAsync(p => p.UserId == user.Id);
|
||||||
if (hasPasskey)
|
if (hasPasskey || user.PasskeyPromptDismissed)
|
||||||
return Redirect(returnUrl ?? "/");
|
return Redirect(returnUrl ?? "/");
|
||||||
|
|
||||||
ViewBag.ReturnUrl = returnUrl ?? "/";
|
ViewBag.ReturnUrl = returnUrl ?? "/";
|
||||||
return View();
|
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>
|
/// <summary>Shows all passkeys registered by the current user.</summary>
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[HttpGet("/Passkey/Manage")]
|
[HttpGet("/Passkey/Manage")]
|
||||||
|
|||||||
@@ -10,10 +10,14 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<div class="col-lg-10">
|
<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">
|
<a asp-action="Index" class="btn btn-outline-secondary">
|
||||||
<i class="bi bi-arrow-left me-1"></i>Back to List
|
<i class="bi bi-arrow-left me-1"></i>Back to List
|
||||||
</a>
|
</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>
|
||||||
|
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
@@ -145,37 +149,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="d-flex gap-2 justify-content-end">
|
||||||
<a asp-action="Index" class="btn btn-secondary">Cancel</a>
|
<a asp-action="Index" class="btn btn-secondary">Cancel</a>
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">
|
||||||
|
|||||||
@@ -184,6 +184,10 @@
|
|||||||
class="btn btn-outline-secondary" title="Edit">
|
class="btn btn-outline-secondary" title="Edit">
|
||||||
<i class="bi bi-pencil"></i>
|
<i class="bi bi-pencil"></i>
|
||||||
</a>
|
</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"
|
<form asp-action="ToggleActive" asp-route-id="@company.Id"
|
||||||
method="post" class="d-inline">
|
method="post" class="d-inline">
|
||||||
@Html.AntiForgeryToken()
|
@Html.AntiForgeryToken()
|
||||||
|
|||||||
@@ -121,6 +121,13 @@
|
|||||||
<a id="pk-skip-link" href="@returnUrl" class="btn btn-outline-secondary btn-lg">
|
<a id="pk-skip-link" href="@returnUrl" class="btn btn-outline-secondary btn-lg">
|
||||||
Maybe later
|
Maybe later
|
||||||
</a>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,10 @@
|
|||||||
<a asp-action="Index" class="btn btn-outline-secondary btn-sm">
|
<a asp-action="Index" class="btn btn-outline-secondary btn-sm">
|
||||||
<i class="bi bi-arrow-left me-1"></i>Back
|
<i class="bi bi-arrow-left me-1"></i>Back
|
||||||
</a>
|
</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">
|
<h4 class="mb-0">
|
||||||
<i class="bi bi-credit-card me-2 text-primary"></i>@Model.CompanyName
|
<i class="bi bi-credit-card me-2 text-primary"></i>@Model.CompanyName
|
||||||
</h4>
|
</h4>
|
||||||
|
|||||||
Reference in New Issue
Block a user