Add passkey / biometric login (WebAuthn FIDO2)

Shop floor workers can log in once with a password, enroll a passkey,
and use Face ID / Windows Hello / fingerprint for all future logins.

- UserPasskey entity + AddUserPasskeys migration (Fido2 v4.0.1)
- PasskeyController: RegisterOptions, Register, LoginOptions, Login,
  Manage, Remove endpoints
- Login page: platform-aware button (Face ID / Windows Hello / etc.)
  hidden automatically if browser doesn't support WebAuthn
- Post-login floating prompt to enroll on first use; session-dismissed
- Passkeys & Biometrics link in user dropdown menu
- Manage page: list registered devices, add new, remove individual
- passkey.js: targeted base64url conversion (only challenge + user.id
  + credential IDs) — fixes "Required parameters missing" error caused
  by blindly converting rp.id and other string fields to ArrayBuffers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 15:07:01 -04:00
parent 4f976b1332
commit 0bb96a502a
16 changed files with 16101 additions and 17 deletions
@@ -5782,7 +5782,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
CompanyId = 0,
CreatedAt = new DateTime(2026, 4, 25, 12, 32, 52, 295, DateTimeKind.Utc).AddTicks(5147),
CreatedAt = new DateTime(2026, 4, 25, 18, 27, 8, 537, DateTimeKind.Utc).AddTicks(4555),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -5793,7 +5793,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
CompanyId = 0,
CreatedAt = new DateTime(2026, 4, 25, 12, 32, 52, 295, DateTimeKind.Utc).AddTicks(5155),
CreatedAt = new DateTime(2026, 4, 25, 18, 27, 8, 537, DateTimeKind.Utc).AddTicks(4562),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -5804,7 +5804,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
CompanyId = 0,
CreatedAt = new DateTime(2026, 4, 25, 12, 32, 52, 295, DateTimeKind.Utc).AddTicks(5156),
CreatedAt = new DateTime(2026, 4, 25, 18, 27, 8, 537, DateTimeKind.Utc).AddTicks(4563),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -7255,6 +7255,53 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("TermsAcceptances");
});
modelBuilder.Entity("PowderCoating.Core.Entities.UserPasskey", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<byte[]>("CredentialId")
.IsRequired()
.HasColumnType("varbinary(900)");
b.Property<string>("DeviceFriendlyName")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("LastUsedAt")
.HasColumnType("datetime2");
b.Property<byte[]>("PublicKey")
.IsRequired()
.HasColumnType("varbinary(max)");
b.Property<long>("SignCount")
.HasColumnType("bigint");
b.Property<byte[]>("UserHandle")
.IsRequired()
.HasColumnType("varbinary(max)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("CredentialId")
.IsUnique();
b.ToTable("UserPasskeys");
});
modelBuilder.Entity("PowderCoating.Core.Entities.Vendor", b =>
{
b.Property<int>("Id")