Compare commits

...

5 Commits

Author SHA1 Message Date
spouliot edc599a1a2 Clean up TODO list and remove stale deploy_migration.sql
Completed items removed from TODO: AI catalog price check, catalog item
images, AI company lookup. deploy_migration.sql replaced by the
versioned scripts/042426_deploy_migration.sql.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 15:12:31 -04:00
spouliot 90a5a028ad Update docs and AI assistant for passkey biometric login
- HelpKnowledgeBase: passkey entry under USER PROFILE section with
  full how-it-works detail (setup, login flow, browser requirements,
  account-lock enforcement, per-device management)
- UserProfile help article: new Passkeys & Biometrics section between
  Two-Factor Auth and Appearance, with setup steps, login steps,
  browser compatibility note, and lost-device warning
- TOC nav link added to UserProfile article sidebar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 15:08:46 -04:00
spouliot 0bb96a502a 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>
2026-04-25 15:07:01 -04:00
spouliot 4f976b1332 Require auth on all work order QR codes and add top view QR
- StatusBump (GET + POST) now requires authentication; routes by job ID
  instead of anonymous ShopAccessCode GUID; records actual user name in
  status history instead of anonymous token string
- WorkOrder action generates a second "View Job" QR in the header linking
  to the authenticated Details page (for verifying specs and seeing catalog
  images on mobile); status bump QR updated to ID-based URL
- WorkOrder view: top QR added to header alongside job number; status bump
  label updated (removed "no login required" copy)
- StatusBump view: updated form routing from asp-route-token to asp-route-id
- HelpKnowledgeBase and Jobs help article updated with two-tier QR docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 13:27:43 -04:00
spouliot 9361cd4495 Add production Jenkins pipeline for Azure App Service deployment
Fully manual pipeline (no triggers): build/test → publish → generate
idempotent EF migration SQL (archived as artifact) → apply to Azure SQL
via sqlcmd → ZIP deploy to App Service → smoke test.

Includes jenkins/Dockerfile (adds .NET 8 SDK, Azure CLI, mssql-tools18,
dotnet-ef 8.0.11 to jenkins/jenkins:lts) and .config/dotnet-tools.json
tool manifest.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 09:50:52 -04:00
28 changed files with 12768 additions and 67 deletions
+12
View File
@@ -0,0 +1,12 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "8.0.11",
"commands": [
"dotnet-ef"
]
}
}
}
+100
View File
@@ -0,0 +1,100 @@
# Jenkins Production Deployment Setup
## What was created
| File | Purpose |
|---|---|
| `Jenkinsfile` | Production pipeline — manual trigger only |
| `jenkins/Dockerfile` | Custom image: Jenkins LTS + .NET 8 + Azure CLI + sqlcmd + dotnet-ef |
| `.config/dotnet-tools.json` | Tool manifest pinning dotnet-ef 8.0.11 |
---
## One-time setup steps
### 1. Build and run your custom Jenkins image
On your Ubuntu Docker host:
```bash
cd /path/to/repo
docker build -t pcl-jenkins ./jenkins
docker run -d -p 8080:8080 -p 50000:50000 \
-v jenkins_home:/var/jenkins_home \
--name pcl-jenkins pcl-jenkins
```
If you already have a Jenkins container running, rebuild the image and recreate the container (volume data is preserved).
---
### 2. Create an Azure Service Principal
Run this once from **your machine** (not Jenkins):
```bash
az login
az ad sp create-for-rbac \
--name "pcl-jenkins-deploy" \
--role contributor \
--scopes /subscriptions/<YOUR_SUBSCRIPTION_ID>/resourceGroups/<YOUR_RG>
```
Save the output — you need `appId`, `password`, `tenant`, and your subscription ID.
---
### 3. Create a SQL Server deployment login
In SSMS or Azure portal query editor, run on your Azure SQL server (as admin):
```sql
CREATE LOGIN pcl_deploy WITH PASSWORD = 'ChooseAStrongPassword123!';
USE PowderCoatingDb;
CREATE USER pcl_deploy FOR LOGIN pcl_deploy;
ALTER ROLE db_owner ADD MEMBER pcl_deploy; -- needs DDL rights for migrations
```
> After migrations are stable you can demote this to `db_datareader`/`db_datawriter` + explicit DDL permissions, but `db_owner` is easiest to start.
---
### 4. Add Jenkins credentials
Go to **Jenkins → Manage Jenkins → Credentials → System → Global** and add 10 **Secret Text** credentials with these exact IDs:
| Credential ID | Value |
|---|---|
| `PCL_AZURE_CLIENT_ID` | `appId` from step 2 |
| `PCL_AZURE_CLIENT_SECRET` | `password` from step 2 |
| `PCL_AZURE_TENANT_ID` | `tenant` from step 2 |
| `PCL_AZURE_SUBSCRIPTION_ID` | Your Azure subscription GUID |
| `PCL_AZURE_RESOURCE_GROUP` | e.g. `powder-coating-prod` |
| `PCL_AZURE_APP_NAME` | Your App Service name (e.g. `pcl-app`) |
| `PCL_SQL_SERVER` | e.g. `pcl-sql.database.windows.net` |
| `PCL_SQL_DATABASE` | e.g. `PowderCoatingDb` |
| `PCL_SQL_USER` | `pcl_deploy` |
| `PCL_SQL_PASSWORD` | The password you set in step 3 |
---
### 5. Create the Jenkins Pipeline job
1. **New Item → Pipeline** — name it "PCL Production Deploy"
2. Under **Pipeline**, set **Definition** = `Pipeline script from SCM`
3. SCM = Git, repo URL, branch `*/master`, Script Path = `Jenkinsfile`
4. **Do NOT** check any triggers (no poll SCM, no build periodically, no webhook)
5. Save
To deploy: open the job → **Build Now**. That's your "Go!" button.
---
## How each stage works
| Stage | What happens |
|---|---|
| **Checkout** | Pulls `master`, logs the commit SHA |
| **Build & Test** | `dotnet restore``dotnet build -c Release``dotnet test` (results published to Jenkins) |
| **Publish** | `dotnet publish -c Release``./publish/` |
| **Generate Migration Script** | `dotnet ef migrations script --idempotent` — no DB connection needed. Script is **archived as a build artifact** so you can inspect it before or after |
| **Apply Migration** | `sqlcmd` runs the idempotent script against Azure SQL. `-b` flag makes it fail-fast on errors |
| **Deploy to Azure** | ZIP the publish folder, `az webapp deployment source config-zip` |
| **Smoke Test** | `curl` the App Service root URL — expects HTTP 200 or 302 |
Vendored
+168
View File
@@ -0,0 +1,168 @@
pipeline {
agent any
// No triggers — start this pipeline manually from the Jenkins UI only.
environment {
DOTNET_CLI_HOME = '/tmp/dotnet_cli_home'
WEB_PROJECT = 'src/PowderCoating.Web/PowderCoating.Web.csproj'
INFRA_PROJECT = 'src/PowderCoating.Infrastructure/PowderCoating.Infrastructure.csproj'
PUBLISH_DIR = "${WORKSPACE}/publish"
DEPLOY_ZIP = "${WORKSPACE}/deploy_${BUILD_NUMBER}.zip"
MIGRATION_SQL = "${WORKSPACE}/migration_${BUILD_NUMBER}.sql"
}
stages {
stage('Checkout') {
steps {
checkout([
$class: 'GitSCM',
branches: [[name: 'refs/heads/master']],
userRemoteConfigs: scm.userRemoteConfigs
])
echo "Building commit: ${GIT_COMMIT}"
}
}
stage('Build & Test') {
steps {
sh 'dotnet restore'
sh 'dotnet build --no-restore -c Release'
sh '''
dotnet test --no-build -c Release \
--logger "trx;LogFileName=results.trx" \
--results-directory TestResults
'''
}
post {
always {
junit testResults: 'TestResults/*.trx', allowEmptyResults: true
}
}
}
stage('Publish') {
steps {
sh """
dotnet publish '${WEB_PROJECT}' \
-c Release --no-build \
-o '${PUBLISH_DIR}'
"""
}
}
// Generates an idempotent SQL migration script (no live DB connection required).
// The script checks which migrations have already been applied before running each one.
stage('Generate Migration Script') {
steps {
sh """
dotnet ef migrations script \
--idempotent \
--output '${MIGRATION_SQL}' \
--project '${INFRA_PROJECT}' \
--startup-project '${WEB_PROJECT}' \
--context ApplicationDbContext \
--no-build
"""
archiveArtifacts artifacts: "migration_${BUILD_NUMBER}.sql", fingerprint: true
echo "Migration script archived — review it in the Jenkins build artifacts before this pipeline runs next time."
}
}
stage('Apply Migration to Azure SQL') {
steps {
withCredentials([
string(credentialsId: 'PCL_SQL_SERVER', variable: 'SQL_SERVER'),
string(credentialsId: 'PCL_SQL_DATABASE', variable: 'SQL_DATABASE'),
string(credentialsId: 'PCL_SQL_USER', variable: 'SQL_USER'),
string(credentialsId: 'PCL_SQL_PASSWORD', variable: 'SQL_PASSWORD')
]) {
sh '''
echo "Applying migration to ${SQL_SERVER}/${SQL_DATABASE} ..."
/opt/mssql-tools18/bin/sqlcmd \
-S "${SQL_SERVER}" \
-d "${SQL_DATABASE}" \
-U "${SQL_USER}" \
-P "${SQL_PASSWORD}" \
-C \
-b \
-i "${MIGRATION_SQL}"
echo "Migration applied successfully."
'''
}
}
}
stage('Deploy to Azure App Service') {
steps {
withCredentials([
string(credentialsId: 'PCL_AZURE_CLIENT_ID', variable: 'AZ_CLIENT_ID'),
string(credentialsId: 'PCL_AZURE_CLIENT_SECRET', variable: 'AZ_CLIENT_SECRET'),
string(credentialsId: 'PCL_AZURE_TENANT_ID', variable: 'AZ_TENANT_ID'),
string(credentialsId: 'PCL_AZURE_SUBSCRIPTION_ID', variable: 'AZ_SUBSCRIPTION_ID'),
string(credentialsId: 'PCL_AZURE_RESOURCE_GROUP', variable: 'AZ_RG'),
string(credentialsId: 'PCL_AZURE_APP_NAME', variable: 'AZ_APP')
]) {
sh '''
az login --service-principal \
--username "$AZ_CLIENT_ID" \
--password "$AZ_CLIENT_SECRET" \
--tenant "$AZ_TENANT_ID" \
--output none
az account set --subscription "$AZ_SUBSCRIPTION_ID"
echo "Packaging deployment artifact ..."
cd "$PUBLISH_DIR"
zip -r "$DEPLOY_ZIP" .
echo "Pushing ZIP to ${AZ_APP} ..."
az webapp deployment source config-zip \
--resource-group "$AZ_RG" \
--name "$AZ_APP" \
--src "$DEPLOY_ZIP"
az logout
echo "Deploy complete."
'''
}
}
}
stage('Smoke Test') {
steps {
withCredentials([
string(credentialsId: 'PCL_AZURE_APP_NAME', variable: 'AZ_APP')
]) {
sh '''
APP_URL="https://${AZ_APP}.azurewebsites.net"
echo "Smoke-testing ${APP_URL} ..."
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
--max-time 45 --retry 3 --retry-delay 10 \
"${APP_URL}")
echo "HTTP status: ${HTTP_STATUS}"
# 200 = OK, 302 = redirect to login (both are healthy)
if [ "$HTTP_STATUS" != "200" ] && [ "$HTTP_STATUS" != "302" ]; then
echo "SMOKE TEST FAILED — got HTTP ${HTTP_STATUS}"
exit 1
fi
echo "Smoke test passed."
'''
}
}
}
}
post {
success {
echo "Production deployment #${BUILD_NUMBER} (${GIT_COMMIT}) completed successfully."
}
failure {
echo "Pipeline #${BUILD_NUMBER} FAILED — review the stage logs above."
}
always {
cleanWs()
}
}
}
+6
View File
@@ -1,6 +1,12 @@
Shop Management App TO DO List
==============================
-Look into possibly having AI scan a product catalog and suggest prices for items.
-Add images to product catalog items for easily identification of parts
-AI Company Lookup (similar to inventory lookup)
-Add ability to save a quoted item to the product catalog either from an AI Photo Quote or from the calculated item
-Check my ChatGPT chat about surface area for a few solid ideas for the system
-Add SMS capabilities
+2
View File
@@ -1,5 +1,6 @@
Shop Management App TO DO List
==============================
-Add ability to save a quoted item to the product catalog either from an AI Photo Quote or from the calculated item
-Check my ChatGPT chat about surface area for a few solid ideas for the system
-Add SMS capabilities
@@ -172,6 +173,7 @@ AI Agent item where we upload a picture and it will calculate the approximate sq
-Allow printing blank work orders (model after the SCP Powder Coating blank work order)
-IDEA: Print powders to use on work order with their QR code so they can be scanned right from there and usage recorded.
Ideas Removed
=======================
-Add Deactivate Customer button on Customer Detail page
+50
View File
@@ -0,0 +1,50 @@
# Custom Jenkins image for Powder Coating Logix production deployments.
# Adds: .NET 8 SDK, Azure CLI, sqlcmd (mssql-tools18), dotnet-ef global tool.
#
# Build: docker build -t pcl-jenkins ./jenkins
# Run: docker run -d -p 8080:8080 -p 50000:50000 \
# -v jenkins_home:/var/jenkins_home \
# --name pcl-jenkins pcl-jenkins
FROM jenkins/jenkins:lts
USER root
# ── Base utilities ────────────────────────────────────────────────────────────
RUN apt-get update && apt-get install -y --no-install-recommends \
wget curl gnupg2 apt-transport-https lsb-release zip unzip ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# ── .NET 8 SDK ────────────────────────────────────────────────────────────────
RUN wget -q https://packages.microsoft.com/config/debian/12/packages-microsoft-prod.deb \
-O /tmp/ms-prod.deb \
&& dpkg -i /tmp/ms-prod.deb \
&& rm /tmp/ms-prod.deb \
&& apt-get update \
&& apt-get install -y --no-install-recommends dotnet-sdk-8.0 \
&& rm -rf /var/lib/apt/lists/*
# ── Azure CLI ─────────────────────────────────────────────────────────────────
RUN curl -sL https://aka.ms/InstallAzureCLIDeb | bash \
&& rm -rf /var/lib/apt/lists/*
# ── mssql-tools18 (sqlcmd) ───────────────────────────────────────────────────
RUN curl -fsSL https://packages.microsoft.com/keys/microsoft.asc \
| gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg \
&& echo "deb [signed-by=/usr/share/keyrings/microsoft-prod.gpg] \
https://packages.microsoft.com/debian/12/prod bookworm main" \
> /etc/apt/sources.list.d/mssql-release.list \
&& apt-get update \
&& ACCEPT_EULA=Y apt-get install -y --no-install-recommends \
mssql-tools18 unixodbc-dev \
&& rm -rf /var/lib/apt/lists/*
ENV PATH="$PATH:/opt/mssql-tools18/bin"
# ── dotnet-ef global tool ─────────────────────────────────────────────────────
# Installed into /root/.dotnet/tools (not JENKINS_HOME, which is a volume mount
# and would be wiped on first run). A symlink exposes it system-wide.
RUN DOTNET_CLI_HOME=/root dotnet tool install --global dotnet-ef --version 8.0.11 \
&& ln -s /root/.dotnet/tools/dotnet-ef /usr/local/bin/dotnet-ef
USER jenkins
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,39 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Stores a WebAuthn public-key credential (passkey) registered by an application user.
/// One row per device per user. Does not inherit BaseEntity — passkeys are identity
/// credentials, not business-domain records, and require no soft-delete or company-scoped
/// global query filter (the Login flow queries across tenants by credentialId before auth).
/// </summary>
public class UserPasskey
{
public int Id { get; set; }
/// <summary>FK to AspNetUsers.Id (GUID string).</summary>
public string UserId { get; set; } = default!;
/// <summary>Stored for display/management queries. NOT used as a query filter.</summary>
public int CompanyId { get; set; }
/// <summary>WebAuthn credential ID — unique identifier for this passkey.</summary>
public byte[] CredentialId { get; set; } = default!;
/// <summary>COSE-encoded public key from the authenticator.</summary>
public byte[] PublicKey { get; set; } = default!;
/// <summary>Opaque user handle sent by the authenticator during login.</summary>
public byte[] UserHandle { get; set; } = default!;
/// <summary>
/// Monotonically increasing counter used to detect cloned authenticators.
/// Stored as long to avoid SQL Server uint mapping issues; Fido2NetLib uses uint.
/// </summary>
public long SignCount { get; set; }
/// <summary>User-supplied or browser-provided friendly name, e.g. "Scott's iPhone".</summary>
public string? DeviceFriendlyName { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? LastUsedAt { get; set; }
}
@@ -385,6 +385,13 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
/// </summary>
public DbSet<PendingRegistrationSession> PendingRegistrationSessions { get; set; }
/// <summary>
/// WebAuthn passkey credentials registered by users for biometric login (Face ID, fingerprint).
/// No global query filter — the login flow queries by credentialId before authentication,
/// requiring cross-tenant lookup. Per-user isolation is enforced in the controller.
/// </summary>
public DbSet<UserPasskey> UserPasskeys { get; set; }
/// <summary>
/// Configures the EF Core model: applies entity type configurations from the assembly,
/// registers global query filters, defines relationships, adds performance indexes, and seeds
@@ -792,9 +799,14 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
property.SetColumnType("decimal(18,2)");
}
// UserPasskey: unique index on CredentialId (WebAuthn requires global uniqueness)
modelBuilder.Entity<UserPasskey>()
.HasIndex(p => p.CredentialId)
.IsUnique();
// Configure relationships
ConfigureRelationships(modelBuilder);
// Seed initial data
SeedInitialData(modelBuilder);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,91 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddUserPasskeys : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "UserPasskeys",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
UserId = table.Column<string>(type: "nvarchar(max)", nullable: false),
CompanyId = table.Column<int>(type: "int", nullable: false),
CredentialId = table.Column<byte[]>(type: "varbinary(900)", nullable: false),
PublicKey = table.Column<byte[]>(type: "varbinary(max)", nullable: false),
UserHandle = table.Column<byte[]>(type: "varbinary(max)", nullable: false),
SignCount = table.Column<long>(type: "bigint", nullable: false),
DeviceFriendlyName = table.Column<string>(type: "nvarchar(max)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
LastUsedAt = table.Column<DateTime>(type: "datetime2", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_UserPasskeys", x => x.Id);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 4, 25, 18, 27, 8, 537, DateTimeKind.Utc).AddTicks(4555));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 4, 25, 18, 27, 8, 537, DateTimeKind.Utc).AddTicks(4562));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 4, 25, 18, 27, 8, 537, DateTimeKind.Utc).AddTicks(4563));
migrationBuilder.CreateIndex(
name: "IX_UserPasskeys_CredentialId",
table: "UserPasskeys",
column: "CredentialId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "UserPasskeys");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 4, 25, 12, 32, 52, 295, DateTimeKind.Utc).AddTicks(5147));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 4, 25, 12, 32, 52, 295, DateTimeKind.Utc).AddTicks(5155));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 4, 25, 12, 32, 52, 295, DateTimeKind.Utc).AddTicks(5156));
}
}
}
@@ -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")
@@ -247,6 +247,17 @@
</div>
</form>
<!-- Passkey / Biometric login — shown only if browser supports WebAuthn -->
<div class="passkey-login-section">
<div class="auth-divider"><span>or</span></div>
<div class="d-grid mb-2">
<button id="passkey-login-btn" type="button" class="btn btn-outline-secondary btn-lg d-flex align-items-center justify-content-center gap-2">
<i class="bi bi-fingerprint"></i> Use Face ID / Biometric
</button>
</div>
<p id="passkey-error" class="text-danger small text-center d-none mb-0"></p>
</div>
@if (Model.SignupOpen)
{
<div class="auth-divider"><span>or</span></div>
@@ -269,17 +280,6 @@
@section Scripts {
<partial name="_ValidationScriptsPartial" />
<script>
document.getElementById('togglePw').addEventListener('click', function () {
var input = document.getElementById('passwordInput');
var icon = document.getElementById('togglePwIcon');
if (input.type === 'password') {
input.type = 'text';
icon.className = 'bi bi-eye-slash';
} else {
input.type = 'password';
icon.className = 'bi bi-eye';
}
});
</script>
<script src="~/js/login-toggle-pw.js"></script>
<script src="~/js/passkey.js"></script>
}
@@ -573,53 +573,40 @@ public class JobsController : Controller
/// <summary>
/// Shows the status-bump selection page for a shop-floor QR code scan.
/// This endpoint is AllowAnonymous — it is accessed by workers scanning a printed QR code
/// on a work order, not by logged-in users. The <paramref name="token"/> is the job's
/// ShopAccessCode GUID. IgnoreQueryFilters is used so the scan works even if the worker's
/// device has no active session (common on shared shop tablets).
/// Requires authentication — workers must be logged in before scanning. Tenant isolation
/// is enforced by the normal global query filter on <c>GetByIdAsync</c>.
/// </summary>
[AllowAnonymous]
public async Task<IActionResult> StatusBump(Guid token)
public async Task<IActionResult> StatusBump(int id)
{
// Find job by ShopAccessCode — ignore tenant/soft-delete filters so the
// anonymous scan always finds the job regardless of active user session.
var jobs = await _unitOfWork.Jobs.FindAsync(
j => j.ShopAccessCode == token, true,
var job = await _unitOfWork.Jobs.GetByIdAsync(id, false,
j => j.JobStatus,
j => j.JobPriority,
j => j.Customer);
var job = jobs.FirstOrDefault();
if (job == null) return NotFound("Work order token not found.");
if (job == null) return NotFound();
// Load all status lookups to determine next step
var allStatuses = (await _unitOfWork.JobStatusLookups.GetAllAsync())
.OrderBy(s => s.DisplayOrder).ToList();
ViewBag.AllStatuses = allStatuses;
ViewBag.Job = job;
ViewBag.Token = token;
ViewBag.JobId = id;
return View();
}
/// <summary>
/// Processes a QR-code status bump from the shop floor — also AllowAnonymous.
/// Validates the token, applies the new status, records the change history, and broadcasts
/// a SignalR update so the office dashboard refreshes in real time.
/// The "bumped by" user is recorded as the ShopAccessCode token string (anonymous actor).
/// Processes a QR-code status bump from the shop floor. Requires authentication.
/// Records the authenticated user's name in status history.
/// </summary>
[AllowAnonymous]
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> StatusBump(Guid token, int newStatusId)
public async Task<IActionResult> StatusBump(int id, int newStatusId)
{
var jobs = await _unitOfWork.Jobs.FindAsync(
j => j.ShopAccessCode == token, true,
var job = await _unitOfWork.Jobs.GetByIdAsync(id, false,
j => j.JobStatus,
j => j.Customer);
var job = jobs.FirstOrDefault();
if (job == null) return NotFound("Work order token not found.");
if (job == null) return NotFound();
var allStatuses = (await _unitOfWork.JobStatusLookups.GetAllAsync()).ToList();
var newStatus = allStatuses.FirstOrDefault(s => s.Id == newStatusId);
@@ -630,28 +617,29 @@ public class JobsController : Controller
job.UpdatedAt = DateTime.UtcNow;
if (newStatus.StatusCode == "COMPLETED") job.CompletedDate = DateTime.UtcNow;
var userName = User.Identity?.Name ?? "Shop Floor";
await _unitOfWork.JobStatusHistory.AddAsync(new JobStatusHistory
{
JobId = job.Id,
FromStatusId = oldStatusId,
ToStatusId = newStatusId,
ChangedDate = DateTime.UtcNow,
Notes = "Updated via shop floor QR scan",
Notes = $"Updated via shop floor QR scan by {userName}",
CompanyId = job.CompanyId,
CreatedAt = DateTime.UtcNow
});
await _unitOfWork.CompleteAsync();
// Reload job status for redirect display
return RedirectToAction(nameof(StatusBump), new { token });
return RedirectToAction(nameof(StatusBump), new { id });
}
/// <summary>
/// Renders the printable work order view for a job.
/// Loads all job items with their coats and prep services so the printed sheet contains
/// the full powder specification, colors, and preparation instructions for shop workers.
/// The work order also includes the QR code for ShopAccessCode-based status bumps.
/// Generates two sets of QR codes: a top "view" code linking to the authenticated job
/// details page, and bottom action codes for status bumping and powder usage logging.
/// </summary>
public async Task<IActionResult> WorkOrder(int? id)
{
@@ -713,13 +701,19 @@ public class JobsController : Controller
ViewBag.Company = company;
}
// Generate QR code for shop floor status bumping
var statusBumpUrl = Url.Action("StatusBump", "Jobs", new { token = job.ShopAccessCode }, Request.Scheme)!;
using var qrGenerator = new QRCoder.QRCodeGenerator();
// Top QR: view/verify the job on mobile (authenticated job details page)
var detailsUrl = Url.Action("Details", "Jobs", new { id = job.Id }, Request.Scheme)!;
using var viewQrData = qrGenerator.CreateQrCode(detailsUrl, QRCoder.QRCodeGenerator.ECCLevel.M);
using var viewQrCode = new QRCoder.PngByteQRCode(viewQrData);
ViewBag.ViewQrCodeBase64 = Convert.ToBase64String(viewQrCode.GetGraphic(4));
// Bottom QR: status bump (authenticated, job ID routed)
var statusBumpUrl = Url.Action("StatusBump", "Jobs", new { id = job.Id }, Request.Scheme)!;
using var qrData = qrGenerator.CreateQrCode(statusBumpUrl, QRCoder.QRCodeGenerator.ECCLevel.M);
using var qrCode = new QRCoder.PngByteQRCode(qrData);
var qrBytes = qrCode.GetGraphic(4);
ViewBag.QrCodeBase64 = Convert.ToBase64String(qrBytes);
ViewBag.QrCodeBase64 = Convert.ToBase64String(qrCode.GetGraphic(4));
ViewBag.StatusBumpUrl = statusBumpUrl;
// Generate QR codes for each unique inventory powder item so workers can
@@ -0,0 +1,272 @@
using System.Text;
using Fido2NetLib;
using Fido2NetLib.Objects;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Handles WebAuthn / FIDO2 passkey registration and authentication.
/// Registration requires an authenticated session (user logs in once with password,
/// then enrolls a passkey for future logins). Authentication is anonymous — the
/// browser sends the credential before any session exists.
/// </summary>
[Route("[controller]/[action]")]
public class PasskeyController : Controller
{
private readonly IFido2 _fido2;
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
private readonly ApplicationDbContext _db;
private readonly ILogger<PasskeyController> _logger;
private const string RegChallengeKey = "passkey:reg:challenge";
private const string AuthChallengeKey = "passkey:auth:challenge";
public PasskeyController(
IFido2 fido2,
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager,
ApplicationDbContext db,
ILogger<PasskeyController> logger)
{
_fido2 = fido2;
_userManager = userManager;
_signInManager = signInManager;
_db = db;
_logger = logger;
}
// ─── Registration ────────────────────────────────────────────────────────
/// <summary>
/// Returns a WebAuthn creation options object for the currently signed-in user.
/// Stores the challenge in session so Register can verify it.
/// </summary>
[HttpPost]
[Authorize]
public async Task<IActionResult> RegisterOptions()
{
var user = await _userManager.GetUserAsync(User);
if (user == null) return Unauthorized();
var existingKeys = await _db.UserPasskeys
.Where(p => p.UserId == user.Id)
.Select(p => p.CredentialId)
.ToListAsync();
var fidoUser = new Fido2User
{
Id = Encoding.UTF8.GetBytes(user.Id),
Name = user.Email!,
DisplayName = user.FullName ?? user.Email!
};
var excludeCredentials = existingKeys
.Select(k => new PublicKeyCredentialDescriptor(k))
.ToList();
var authenticatorSelection = new AuthenticatorSelection
{
ResidentKey = ResidentKeyRequirement.Required,
UserVerification = UserVerificationRequirement.Required
};
var options = _fido2.RequestNewCredential(new RequestNewCredentialParams
{
User = fidoUser,
ExcludeCredentials = excludeCredentials,
AuthenticatorSelection = authenticatorSelection,
AttestationPreference = AttestationConveyancePreference.None
});
HttpContext.Session.SetString(RegChallengeKey, options.ToJson());
return Ok(options);
}
/// <summary>
/// Verifies the authenticator response and persists the new passkey credential.
/// </summary>
[HttpPost]
[Authorize]
public async Task<IActionResult> Register(
[FromBody] AuthenticatorAttestationRawResponse attestationResponse,
[FromQuery] string? deviceName)
{
var user = await _userManager.GetUserAsync(User);
if (user == null) return Unauthorized();
var optionsJson = HttpContext.Session.GetString(RegChallengeKey);
if (string.IsNullOrEmpty(optionsJson))
return BadRequest(new { error = "Session expired — please try again." });
HttpContext.Session.Remove(RegChallengeKey);
RegisteredPublicKeyCredential credential;
try
{
var options = CredentialCreateOptions.FromJson(optionsJson);
credential = await _fido2.MakeNewCredentialAsync(new MakeNewCredentialParams
{
AttestationResponse = attestationResponse,
OriginalOptions = options,
IsCredentialIdUniqueToUserCallback = async (args, _) =>
!await _db.UserPasskeys.AnyAsync(p => p.CredentialId == args.CredentialId)
});
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Passkey registration failed for user {UserId}", user.Id);
return BadRequest(new { error = ex.Message });
}
var passkey = new UserPasskey
{
UserId = user.Id,
CompanyId = user.CompanyId,
CredentialId = credential.Id,
PublicKey = credential.PublicKey,
UserHandle = credential.User.Id,
SignCount = credential.SignCount,
DeviceFriendlyName = string.IsNullOrWhiteSpace(deviceName) ? null : deviceName.Trim()
};
_db.UserPasskeys.Add(passkey);
await _db.SaveChangesAsync();
_logger.LogInformation("Passkey registered for user {UserId} ({DeviceName})",
user.Id, passkey.DeviceFriendlyName ?? "(unnamed)");
return Ok(new { message = "Passkey registered successfully." });
}
// ─── Authentication ───────────────────────────────────────────────────────
/// <summary>
/// Returns a WebAuthn assertion options object. No session required — called before login.
/// </summary>
[HttpPost]
[AllowAnonymous]
public IActionResult LoginOptions()
{
var options = _fido2.GetAssertionOptions(new GetAssertionOptionsParams
{
AllowedCredentials = [],
UserVerification = UserVerificationRequirement.Required
});
HttpContext.Session.SetString(AuthChallengeKey, options.ToJson());
return Ok(options);
}
/// <summary>
/// Verifies the assertion response against stored credentials and signs the user in.
/// </summary>
[HttpPost]
[AllowAnonymous]
public async Task<IActionResult> Login([FromBody] AuthenticatorAssertionRawResponse assertionResponse)
{
var optionsJson = HttpContext.Session.GetString(AuthChallengeKey);
if (string.IsNullOrEmpty(optionsJson))
return BadRequest(new { error = "Session expired — please try again." });
HttpContext.Session.Remove(AuthChallengeKey);
// Look up passkey by credential ID (RawId is byte[], Id is base64url string)
var credentialId = assertionResponse.RawId;
var passkey = await _db.UserPasskeys
.FirstOrDefaultAsync(p => p.CredentialId == credentialId);
if (passkey == null)
return BadRequest(new { error = "Passkey not recognised." });
// Load the user — verify account is still active
var user = await _userManager.FindByIdAsync(passkey.UserId);
if (user == null || !user.IsActive)
return BadRequest(new { error = "Account not found or deactivated." });
if (await _userManager.IsLockedOutAsync(user))
return BadRequest(new { error = "Account is locked. Please contact your administrator." });
VerifyAssertionResult verifyResult;
try
{
var options = AssertionOptions.FromJson(optionsJson);
verifyResult = await _fido2.MakeAssertionAsync(new MakeAssertionParams
{
AssertionResponse = assertionResponse,
OriginalOptions = options,
StoredPublicKey = passkey.PublicKey,
StoredSignatureCounter = (uint)passkey.SignCount,
IsUserHandleOwnerOfCredentialIdCallback = (args, _) =>
Task.FromResult(args.UserHandle.SequenceEqual(passkey.UserHandle))
});
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Passkey assertion failed for user {UserId}", passkey.UserId);
return BadRequest(new { error = "Passkey verification failed." });
}
// Update sign count and last-used timestamp
passkey.SignCount = verifyResult.SignCount;
passkey.LastUsedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
// Sign in — passkey satisfies both factors; no further 2FA required
await _signInManager.SignInAsync(user, isPersistent: false);
_logger.LogInformation("User {UserId} signed in via passkey", user.Id);
return Ok(new { redirectUrl = Url.Action("Index", "Dashboard") });
}
// ─── Management ───────────────────────────────────────────────────────────
/// <summary>Shows all passkeys registered by the current user.</summary>
[Authorize]
[HttpGet("/Passkey/Manage")]
public async Task<IActionResult> Manage()
{
var user = await _userManager.GetUserAsync(User);
if (user == null) return Unauthorized();
var passkeys = await _db.UserPasskeys
.Where(p => p.UserId == user.Id)
.OrderByDescending(p => p.CreatedAt)
.ToListAsync();
return View(passkeys);
}
/// <summary>Removes a specific passkey for the current user.</summary>
[HttpPost]
[Authorize]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Remove(int id)
{
var user = await _userManager.GetUserAsync(User);
if (user == null) return Unauthorized();
var passkey = await _db.UserPasskeys
.FirstOrDefaultAsync(p => p.Id == id && p.UserId == user.Id);
if (passkey == null)
return NotFound();
_db.UserPasskeys.Remove(passkey);
await _db.SaveChangesAsync();
_logger.LogInformation("Passkey {PasskeyId} removed for user {UserId}", id, user.Id);
TempData["Success"] = "Passkey removed.";
return RedirectToAction(nameof(Manage));
}
}
@@ -296,6 +296,16 @@ public static class HelpKnowledgeBase
**Creating an invoice from a job:** On the Job Details page, look for the Invoice section and click "Create Invoice."
**Work Order QR Codes:** Every printed job work order includes two tiers of QR codes one for viewing the job, and a separate set for taking action on it. All QR codes require the worker to be logged in.
*Top QR View Job:* Located in the header next to the job number. Scanning it opens the full Job Details page on the worker's phone shows all items, catalog images, powder specs, coatings, prep services, and special instructions. Use this to verify you're working the right job and to see catalog product images on mobile.
*Bottom QR codes Actions:*
- **Update Status** advances the job to its next status stage. Opens a dedicated mobile-friendly status bump page where the worker confirms the new stage. The status change is recorded in history with the logged-in worker's name.
- **Log Powder Usage** one QR per unique powder/inventory item on the job. Scanning opens the inventory usage log page pre-filled with that item and the job, so the worker can record actual lbs used without navigating through the app.
All QR codes require login workers must have an active account. Logging in once on their phone is sufficient for the session.
**Blank Work Order:** Print a pre-formatted paper work order to hand to a walk-in customer before creating a digital job record.
- Access: Jobs list page printer icon button "Blank Work Order" in the top-right toolbar. Or navigate directly to /WorkOrder/Blank.
- The PDF opens in a new tab ready to print. It includes: company logo and address, Drop Off Date field, Client Name / Client Phone / Due Date fields, 12-row parts table (Part Description / Color / Quote), Notes box, customizable Terms & Conditions text, and a Customer Signature line.
@@ -959,9 +969,21 @@ public static class HelpKnowledgeBase
- Upload a profile photo
- Choose display theme (light/dark)
- Manage two-factor authentication settings
- Register passkeys for biometric login (Face ID, fingerprint, Windows Hello)
**Two-Factor Authentication:** [/TwoFactorSetup](/TwoFactorSetup) Set up or manage 2FA for your account.
**Passkeys & Biometric Login:** [/Passkey/Manage](/Passkey/Manage) Register your phone's Face ID, fingerprint, or Windows Hello so future logins don't require typing a password.
### How passkeys work
- Log in once with your password as normal.
- After login, a prompt appears in the bottom-right corner offering to enable biometric login on that device.
- Click Enable and follow the device prompt (Face ID on iPhone, fingerprint/face on Android, Windows Hello on PC).
- Next time you log in, tap "Use Face ID / Biometric" (or the platform equivalent) on the login page no password needed.
- Each device you enroll appears on the Passkeys & Biometrics page. You can remove individual devices at any time.
- The server always checks that your account is active and not locked before allowing passkey login a disabled account cannot bypass this with a passkey.
- Passkeys require HTTPS and a compatible browser (Safari 16+, Chrome 108+, Edge 108+, or any modern Android browser).
---
## BILLING & SUBSCRIPTION
@@ -18,6 +18,8 @@
<PackageReference Include="Azure.Identity" Version="1.21.0" />
<PackageReference Include="Azure.Monitor.Query" Version="1.7.1" />
<PackageReference Include="EPPlus" Version="7.0.0" />
<PackageReference Include="Fido2" Version="4.0.1" />
<PackageReference Include="Fido2.AspNet" Version="4.0.1" />
<PackageReference Include="Markdig" Version="0.40.0" />
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="8.0.11" />
+11
View File
@@ -290,6 +290,17 @@ builder.Services.AddSession(options =>
// Add memory cache
builder.Services.AddMemoryCache();
// Register Fido2/WebAuthn for passkey (biometric) login
builder.Services.AddFido2(options =>
{
options.ServerDomain = builder.Configuration["Fido2:ServerDomain"] ?? "localhost";
options.ServerName = builder.Configuration["Fido2:ServerName"] ?? "Powder Coating Logix";
var origins = builder.Configuration.GetSection("Fido2:Origins").Get<HashSet<string>>();
if (origins?.Count > 0) options.Origins = origins;
options.TimestampDriftTolerance = int.Parse(
builder.Configuration["Fido2:TimestampDriftTolerance"] ?? "300");
});
// Configure authorization policies for multi-tenancy
builder.Services.AddAuthorization(options =>
{
@@ -576,6 +576,49 @@
</div>
</section>
<section id="work-order-qr-codes" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-qr-code text-primary me-2"></i>Work Order QR Codes
</h2>
<p>
Every printed job work order includes two tiers of QR codes — one for <strong>viewing</strong>
the job and a separate set for <strong>acting</strong> on it. This gives shop workers everything
they need from a printed sheet without touching the desktop app.
All QR codes require a logged-in account.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2"><i class="bi bi-eye me-1"></i>Top QR — View Job</h3>
<p>
Located in the work order header, next to the job number. Scan it with your phone to open the
full <strong>Job Details</strong> page — items, catalog product images, powder specs, coatings,
prep services, and special instructions. Use it to verify you're working the right job or to
see catalog item images on your phone without hunting through the app.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2"><i class="bi bi-arrow-right-circle me-1"></i>Bottom QR — Update Status</h3>
<p>
Scan to open a mobile-friendly status bump page for this job. Tap the button to advance to the
next stage (or put the job on hold). The status change is recorded in history with your name —
no anonymous bumps.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2"><i class="bi bi-box-seam me-1"></i>Bottom QR — Log Powder Usage</h3>
<p>
One QR per unique powder on the job. Scanning opens the inventory usage log page pre-filled
with that powder and the job number, so you can record actual lbs used in seconds without
navigating through the app.
</p>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
<i class="bi bi-lock flex-shrink-0 mt-1"></i>
<div>
<strong>Login required:</strong> All three QR codes require workers to be logged in to their
account. Logging in once on their phone is enough for the session. Make sure every shop
floor worker has an account set up before handing out printed work orders.
</div>
</div>
</section>
<section id="blank-work-order" class="mb-5">
<h2 class="h5 fw-semibold mb-3">Blank Work Order</h2>
<p>
@@ -643,6 +686,7 @@
<a class="nav-link py-1 px-3 small text-body" href="#part-intake">Part Intake</a>
<a class="nav-link py-1 px-3 small text-body" href="#shop-mobile">Shop Mobile</a>
<a class="nav-link py-1 px-3 small text-body" href="#changing-customer">Changing the Customer</a>
<a class="nav-link py-1 px-3 small text-body" href="#work-order-qr-codes">Work Order QR Codes</a>
<a class="nav-link py-1 px-3 small text-body" href="#blank-work-order">Blank Work Order</a>
</nav>
</div>
@@ -169,6 +169,64 @@
</div>
</section>
<section id="passkeys" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-fingerprint text-primary me-2"></i>Passkeys &amp; Biometric Login
</h2>
<p>
Passkeys let you sign in using your device's built-in biometrics — Face ID or Touch ID on iPhone and Mac,
fingerprint or face unlock on Android, or Windows Hello on a PC — without ever typing your password.
This is especially useful for shop floor workers who may have dirty or gloved hands.
</p>
<h5 class="fw-semibold mt-3 mb-2">Setting Up a Passkey</h5>
<ol class="mb-3">
<li class="mb-1">Log in with your password as normal.</li>
<li class="mb-1">
A prompt appears in the bottom-right corner of the screen after login. Click
<strong>Enable</strong> and follow the device prompt (Face ID, fingerprint, Windows Hello PIN, etc.).
</li>
<li class="mb-1">The passkey is saved to that device. Repeat on each device you want to use biometrics on.</li>
</ol>
<p>
Alternatively, go to <a href="/Passkey/Manage">Passkeys &amp; Biometrics</a> from the user
menu (top-right) at any time to add a new passkey for the current device.
</p>
<h5 class="fw-semibold mt-3 mb-2">Signing In with a Passkey</h5>
<ol class="mb-3">
<li class="mb-1">Open the login page.</li>
<li class="mb-1">
Click the <strong>Use Face ID / Biometric</strong> button (the label matches your device —
"Use Windows Hello", "Use Touch ID", etc.).
</li>
<li class="mb-1">Follow the device prompt. You are signed in immediately — no password required.</li>
</ol>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-3" role="alert">
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
<div>
The biometric button only appears if your browser and device support passkeys
(Safari 16+, Chrome 108+, Edge 108+, or any modern Android browser over HTTPS).
On unsupported browsers it is hidden automatically.
</div>
</div>
<h5 class="fw-semibold mt-3 mb-2">Managing Passkeys</h5>
<p>
Go to <a href="/Passkey/Manage">Passkeys &amp; Biometrics</a> (user menu → Passkeys &amp; Biometrics)
to see all devices you have enrolled. Each entry shows the device name, the date it was added,
and when it was last used. Click <strong>Remove</strong> to revoke a passkey from a specific device —
useful if you lose a phone or change devices.
</p>
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-0" role="alert">
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 mt-1"></i>
<div>
Removing a passkey does not log you out — it just means that device will require a password
on the next login. If you lose a device, remove its passkey here as soon as possible.
</div>
</div>
</section>
<section id="appearance" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-palette text-primary me-2"></i>Appearance
@@ -224,6 +282,7 @@
<a class="nav-link py-1 px-3 small text-body" href="#changing-password">Changing Your Password</a>
<a class="nav-link py-1 px-3 small text-body" href="#profile-photo">Profile Photo</a>
<a class="nav-link py-1 px-3 small text-body" href="#two-factor-auth">Two-Factor Auth</a>
<a class="nav-link py-1 px-3 small text-body" href="#passkeys">Passkeys &amp; Biometrics</a>
<a class="nav-link py-1 px-3 small text-body" href="#appearance">Appearance</a>
</nav>
</div>
@@ -3,7 +3,7 @@
Layout = null;
var job = ViewBag.Job as PowderCoating.Core.Entities.Job;
var allStatuses = ViewBag.AllStatuses as List<PowderCoating.Core.Entities.JobStatusLookup>;
var token = (Guid)ViewBag.Token;
var jobId = (int)ViewBag.JobId;
// Determine next/previous status options
var currentOrder = job!.JobStatus.DisplayOrder;
@@ -240,7 +240,7 @@
@* On hold — offer resume (next logical status after resume by advancing) *@
@if (nextStatus != null)
{
<form method="post" asp-action="StatusBump" asp-route-token="@token">
<form method="post" asp-action="StatusBump" asp-route-id="@jobId">
@Html.AntiForgeryToken()
<input type="hidden" name="newStatusId" value="@nextStatus.Id" />
<button type="submit" class="btn-resume">
@@ -254,7 +254,7 @@
@* Advance to next step *@
@if (nextStatus != null)
{
<form method="post" asp-action="StatusBump" asp-route-token="@token">
<form method="post" asp-action="StatusBump" asp-route-id="@jobId">
@Html.AntiForgeryToken()
<input type="hidden" name="newStatusId" value="@nextStatus.Id" />
<button type="submit" class="btn-advance">
@@ -270,7 +270,7 @@
@* On Hold option *@
@if (onHoldStatus != null)
{
<form method="post" asp-action="StatusBump" asp-route-token="@token">
<form method="post" asp-action="StatusBump" asp-route-id="@jobId">
@Html.AntiForgeryToken()
<input type="hidden" name="newStatusId" value="@onHoldStatus.Id" />
<button type="submit" class="btn-hold">
@@ -292,22 +292,33 @@
</div>
<div class="col-6">
<div class="work-order-title">WORK ORDER</div>
<div class="text-center" style="font-size: 8pt; line-height: 1.6;">
<div class="mb-2">
<span class="text-muted">Job #:</span>
<span style="font-size: 14pt;" class="fw-bold ms-1">@Model.JobNumber</span>
</div>
<div class="d-flex justify-content-center align-items-center gap-3 mb-1">
<div>
<span class="text-muted">Priority:</span>
<span class="badge bg-@Model.PriorityColorClass ms-1">@Model.PriorityDisplayName</span>
<div style="display: flex; align-items: center; justify-content: space-between; gap: 8px;">
<div style="font-size: 8pt; line-height: 1.6; flex: 1; text-align: center;">
<div class="mb-2">
<span class="text-muted">Job #:</span>
<span style="font-size: 14pt;" class="fw-bold ms-1">@Model.JobNumber</span>
</div>
<div>
<span class="text-muted">Status:</span>
<span class="badge bg-@Model.StatusColorClass ms-1">@Model.StatusDisplayName</span>
<div class="d-flex justify-content-center align-items-center gap-3 mb-1">
<div>
<span class="text-muted">Priority:</span>
<span class="badge bg-@Model.PriorityColorClass ms-1">@Model.PriorityDisplayName</span>
</div>
<div>
<span class="text-muted">Status:</span>
<span class="badge bg-@Model.StatusColorClass ms-1">@Model.StatusDisplayName</span>
</div>
</div>
<div class="text-center text-muted" style="font-size: 7pt;">Created: @Model.CreatedAt.ToString("MM/dd/yyyy")</div>
</div>
<div class="text-center text-muted" style="font-size: 7pt;">Created: @Model.CreatedAt.ToString("MM/dd/yyyy")</div>
@if (ViewBag.ViewQrCodeBase64 != null)
{
<div style="text-align: center; flex-shrink: 0;">
<img src="data:image/png;base64,@ViewBag.ViewQrCodeBase64"
alt="View Job"
style="width: 64px; height: 64px; image-rendering: pixelated; display: block;" />
<div style="font-size: 6.5pt; color: #6c757d; margin-top: 2px;">View Job</div>
</div>
}
</div>
</div>
</div>
@@ -605,7 +616,7 @@
<i class="bi bi-arrow-right-circle me-1"></i>Update Status
</div>
<div style="font-size: 7.5pt; color: #6c757d; line-height: 1.5;">
Advance job to next<br />status — no login required.
Advance job to<br />next status.
</div>
</div>
</div>
@@ -0,0 +1,97 @@
@model IEnumerable<PowderCoating.Core.Entities.UserPasskey>
@{
ViewData["Title"] = "My Passkeys";
}
<div class="container-fluid py-4" style="max-width:760px;">
<div class="d-flex align-items-center gap-3 mb-4">
<div class="rounded-circle d-flex align-items-center justify-content-center"
style="width:48px;height:48px;background:#e0f2fe;">
<i class="bi bi-fingerprint" style="font-size:1.5rem;color:#0284c7;"></i>
</div>
<div>
<h4 class="mb-0 fw-semibold">Passkeys &amp; Biometric Login</h4>
<p class="text-muted small mb-0">
Passkeys let you sign in with Face ID, fingerprint, or your device PIN — no password needed.
</p>
</div>
</div>
@if (TempData["Success"] is string msg)
{
<div class="alert alert-success alert-permanent">
<i class="bi bi-check-circle-fill me-2"></i>@msg
</div>
}
<!-- Add new passkey -->
<div class="card shadow-sm mb-4">
<div class="card-body">
<h6 class="card-title mb-1">Add a passkey for this device</h6>
<p class="text-muted small mb-3">
You'll be prompted to authenticate using Face ID, Touch ID, Windows Hello, or a security key.
</p>
<div class="d-flex gap-2 align-items-center flex-wrap">
<input type="text" id="pk-device-name" class="form-control" style="max-width:220px;"
placeholder="Device name (e.g. iPhone 15)" maxlength="64" />
<button type="button" id="pk-add-btn" class="btn btn-primary">
<i class="bi bi-plus-circle me-1"></i>Add Passkey
</button>
</div>
<p id="pk-add-status" class="mt-2 small mb-0"></p>
</div>
</div>
<!-- Existing passkeys -->
@if (!Model.Any())
{
<div class="text-center py-5 text-muted">
<i class="bi bi-fingerprint" style="font-size:3rem;opacity:.3;"></i>
<p class="mt-3">No passkeys registered yet.<br />Add one above to enable biometric login on this device.</p>
</div>
}
else
{
<div class="list-group shadow-sm">
@foreach (var pk in Model)
{
<div class="list-group-item list-group-item-action d-flex align-items-center gap-3">
<i class="bi bi-phone" style="font-size:1.4rem;color:#64748b;flex-shrink:0;"></i>
<div class="flex-grow-1 min-width-0">
<div class="fw-medium text-truncate">
@(pk.DeviceFriendlyName ?? "Unnamed device")
</div>
<div class="text-muted small">
Added @pk.CreatedAt.ToLocalTime().ToString("MMM d, yyyy")
@if (pk.LastUsedAt.HasValue)
{
<span class="ms-2">&bull; Last used @pk.LastUsedAt.Value.ToLocalTime().ToString("MMM d, yyyy")</span>
}
</div>
</div>
<form method="post" asp-action="Remove" asp-route-id="@pk.Id"
onsubmit="return confirm('Remove this passkey?');">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-outline-danger btn-sm">
<i class="bi bi-trash3"></i> Remove
</button>
</form>
</div>
}
</div>
<p class="text-muted small mt-3">
Removing a passkey means you'll need to use your password on that device next time.
</p>
}
<div class="mt-4">
<a asp-controller="CompanySettings" asp-action="Index" class="text-decoration-none">
<i class="bi bi-arrow-left me-1"></i>Back to Settings
</a>
</div>
</div>
@section Scripts {
<script src="~/js/passkey.js"></script>
<script src="~/js/passkey-manage.js"></script>
}
@@ -895,6 +895,33 @@
<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)
{
@@ -1487,6 +1514,7 @@
}
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" asp-controller="Profile" asp-action="Index"><i class="bi bi-person me-2"></i>Profile</a></li>
<li><a class="dropdown-item" asp-controller="Passkey" asp-action="Manage"><i class="bi bi-fingerprint me-2"></i>Passkeys &amp; Biometrics</a></li>
<li><a class="dropdown-item" asp-controller="TwoFactorSetup" asp-action="Index"><i class="bi bi-shield-lock me-2"></i>Two-Factor Auth</a></li>
<li><a class="dropdown-item" asp-controller="ReleaseNotes" asp-action="Index"><i class="bi bi-rocket-takeoff me-2"></i>What's New</a></li>
<li><a class="dropdown-item" asp-controller="Help" asp-action="Index"><i class="bi bi-question-circle me-2"></i>Help</a></li>
@@ -2091,6 +2119,7 @@
{
@* @await Html.PartialAsync("_AiQuickQuoteWidget") *@
@await Html.PartialAsync("_AiHelpWidget")
<script src="~/js/passkey.js"></script>
}
<!-- ── Quick-Add Modal (reusable inline form host) ─────────────────────── -->
+6
View File
@@ -68,6 +68,12 @@
"Enterprise": "price_enterprise_monthly_id_here"
}
},
"Fido2": {
"ServerDomain": "localhost",
"ServerName": "Powder Coating Logix",
"Origins": [ "https://localhost:58461", "http://localhost:58462" ],
"TimestampDriftTolerance": 300
},
"Storage": {
"ConnectionString": "DefaultEndpointsProtocol=https;AccountName=powdercoatingappdev;AccountKey=DN3eVfhytXb7aBC0md9h/6jE0Uzg6FJ+PK6MFc772qyqpf0kgTeXH0C2VCBBun9PiuItPd9CDKTP+ASthFCuCg==;EndpointSuffix=core.windows.net",
"Containers": {
@@ -0,0 +1,11 @@
document.getElementById('togglePw').addEventListener('click', function () {
var input = document.getElementById('passwordInput');
var icon = document.getElementById('togglePwIcon');
if (input.type === 'password') {
input.type = 'text';
icon.className = 'bi bi-eye-slash';
} else {
input.type = 'password';
icon.className = 'bi bi-eye';
}
});
@@ -0,0 +1,42 @@
document.addEventListener('DOMContentLoaded', async () => {
const addBtn = document.getElementById('pk-add-btn');
const statusEl = document.getElementById('pk-add-status');
const deviceNameInput = document.getElementById('pk-device-name');
if (!addBtn) return;
const supported = await passkeySupported();
if (!supported) {
addBtn.disabled = true;
addBtn.textContent = 'Not supported on this browser';
if (statusEl) {
statusEl.textContent = 'Your browser does not support passkeys. Try Safari on iOS 16+, Chrome 108+, or Edge 108+.';
statusEl.className = 'mt-2 small mb-0 text-muted';
}
return;
}
addBtn.addEventListener('click', async () => {
const name = deviceNameInput.value.trim();
addBtn.disabled = true;
addBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Follow the prompt…';
if (statusEl) { statusEl.textContent = ''; statusEl.className = 'mt-2 small mb-0'; }
const result = await registerPasskey(name);
if (result.success) {
if (statusEl) {
statusEl.textContent = '✓ Passkey added! Reloading…';
statusEl.className = 'mt-2 small mb-0 text-success';
}
setTimeout(() => window.location.reload(), 1200);
} else {
addBtn.disabled = false;
addBtn.innerHTML = '<i class="bi bi-plus-circle me-1"></i>Add Passkey';
if (statusEl) {
statusEl.textContent = result.error || 'Setup failed. Please try again.';
statusEl.className = 'mt-2 small mb-0 text-danger';
}
}
});
});
+321
View File
@@ -0,0 +1,321 @@
/**
* passkey.js WebAuthn / FIDO2 client helpers.
*
* WebAuthn deals in raw ArrayBuffers; the server (and Fido2NetLib) serialize
* them as base64url strings. These helpers convert back and forth so the JS
* fetch payloads match what the server expects.
*/
// ─── base64url helpers ────────────────────────────────────────────────────────
function bufferToBase64url(buffer) {
const bytes = new Uint8Array(buffer);
let str = '';
for (const b of bytes) str += String.fromCharCode(b);
return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
function base64urlToBuffer(base64url) {
const padded = base64url.replace(/-/g, '+').replace(/_/g, '/');
const binary = atob(padded);
const buffer = new ArrayBuffer(binary.length);
const bytes = new Uint8Array(buffer);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return buffer;
}
/**
* Converts server-side CredentialCreateOptions into the shape expected by
* navigator.credentials.create(). Only the fields that the WebAuthn spec
* requires as ArrayBuffer are converted everything else (rp.id, attestation,
* type strings, etc.) must stay as plain strings.
*/
function prepareCreateOptions(opts) {
return {
...opts,
challenge: base64urlToBuffer(opts.challenge),
user: {
...opts.user,
id: base64urlToBuffer(opts.user.id)
},
excludeCredentials: (opts.excludeCredentials ?? []).map(c => ({
...c,
id: base64urlToBuffer(c.id)
}))
};
}
/**
* Converts server-side AssertionOptions into the shape expected by
* navigator.credentials.get(). Only challenge and allowCredentials[].id
* need to be ArrayBuffers.
*/
function prepareGetOptions(opts) {
return {
...opts,
challenge: base64urlToBuffer(opts.challenge),
allowCredentials: (opts.allowCredentials ?? []).map(c => ({
...c,
id: base64urlToBuffer(c.id)
}))
};
}
/** Recursively convert all ArrayBuffers in a credential to base64url strings for JSON. */
function encodeCredential(obj) {
if (obj instanceof ArrayBuffer) return bufferToBase64url(obj);
if (ArrayBuffer.isView(obj)) return bufferToBase64url(obj.buffer.slice(obj.byteOffset, obj.byteOffset + obj.byteLength));
if (Array.isArray(obj)) return obj.map(encodeCredential);
if (obj && typeof obj === 'object') {
const out = {};
for (const [k, v] of Object.entries(obj)) out[k] = encodeCredential(v);
return out;
}
return obj;
}
// ─── Registration (after password login) ─────────────────────────────────────
/**
* Starts the passkey registration flow for the currently signed-in user.
* @param {string} deviceName Optional friendly name (e.g. "Scott's iPhone")
*/
async function registerPasskey(deviceName) {
try {
// 1. Ask the server for creation options (challenge)
const optRes = await fetch('/Passkey/RegisterOptions', { method: 'POST' });
if (!optRes.ok) throw new Error(await optRes.text());
const optionsRaw = await optRes.json();
// 2. Convert server options into WebAuthn API format
const options = prepareCreateOptions(optionsRaw);
// 3. Prompt the authenticator (FaceID / fingerprint / security key)
const credential = await navigator.credentials.create({ publicKey: options });
if (!credential) throw new Error('No credential returned from authenticator.');
// 4. Build the response payload (all ArrayBuffers → base64url)
const payload = {
id: bufferToBase64url(credential.rawId),
rawId: bufferToBase64url(credential.rawId),
type: credential.type,
response: {
attestationObject: bufferToBase64url(credential.response.attestationObject),
clientDataJSON: bufferToBase64url(credential.response.clientDataJSON)
}
};
// 5. Send to server
const qs = deviceName ? `?deviceName=${encodeURIComponent(deviceName)}` : '';
const regRes = await fetch(`/Passkey/Register${qs}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!regRes.ok) {
const err = await regRes.json().catch(() => ({ error: regRes.statusText }));
throw new Error(err.error || 'Registration failed.');
}
return { success: true };
} catch (err) {
console.error('Passkey registration error:', err);
return { success: false, error: err.message };
}
}
// ─── Authentication (at login page) ──────────────────────────────────────────
/**
* Attempts to sign in using a registered passkey.
* Resolves with { success, redirectUrl } or { success: false, error }.
*/
async function loginWithPasskey() {
try {
// 1. Get assertion options from the server
const optRes = await fetch('/Passkey/LoginOptions', { method: 'POST' });
if (!optRes.ok) throw new Error(await optRes.text());
const optionsRaw = await optRes.json();
// 2. Convert to WebAuthn format
const options = prepareGetOptions(optionsRaw);
// 3. Prompt authenticator — browser shows passkey picker automatically
const assertion = await navigator.credentials.get({ publicKey: options });
if (!assertion) throw new Error('No credential returned.');
// 4. Build payload
const payload = {
id: bufferToBase64url(assertion.rawId),
rawId: bufferToBase64url(assertion.rawId),
type: assertion.type,
response: {
authenticatorData: bufferToBase64url(assertion.response.authenticatorData),
clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON),
signature: bufferToBase64url(assertion.response.signature),
userHandle: assertion.response.userHandle
? bufferToBase64url(assertion.response.userHandle)
: null
}
};
// 5. Verify on server
const loginRes = await fetch('/Passkey/Login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!loginRes.ok) {
const err = await loginRes.json().catch(() => ({ error: loginRes.statusText }));
throw new Error(err.error || 'Login failed.');
}
const data = await loginRes.json();
return { success: true, redirectUrl: data.redirectUrl };
} catch (err) {
if (err.name === 'NotAllowedError') {
// User cancelled or timed out — not a real error
return { success: false, cancelled: true };
}
console.error('Passkey login error:', err);
return { success: false, error: err.message };
}
}
// ─── Feature detection ────────────────────────────────────────────────────────
/** True if this browser + platform support WebAuthn conditional UI (passkeys). */
async function passkeySupported() {
if (!window.PublicKeyCredential) return false;
try {
return await PublicKeyCredential.isConditionalMediationAvailable?.() ?? false;
} catch {
return false;
}
}
/**
* Returns a platform-appropriate label for the passkey button, e.g.
* "Use Face ID / Touch ID" on iOS, "Use Windows Hello" on Windows.
*/
function passkeyLabel() {
const ua = navigator.userAgent;
const platform = navigator.platform ?? '';
// iOS / iPadOS (iPads report MacIntel on platform in some browsers — check UA too)
if (/iphone|ipad|ipod/i.test(ua) || (/macintosh/i.test(ua) && navigator.maxTouchPoints > 1)) {
return 'Use Face ID / Touch ID';
}
// Android
if (/android/i.test(ua)) {
return 'Use Fingerprint / Face Unlock';
}
// macOS (non-touch — Touch ID on MacBook)
if (/mac/i.test(platform) || /macintosh/i.test(ua)) {
return 'Use Touch ID';
}
// Windows
if (/win/i.test(platform) || /windows/i.test(ua)) {
return 'Use Windows Hello';
}
// ChromeOS / Linux / unknown
return 'Use Passkey / Biometric';
}
// ─── Login page wiring ────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', async () => {
const passkeyBtn = document.getElementById('passkey-login-btn');
if (!passkeyBtn) return;
const supported = await passkeySupported();
if (!supported) {
passkeyBtn.closest('.passkey-login-section')?.remove();
return;
}
const label = passkeyLabel();
passkeyBtn.innerHTML = `<i class="bi bi-fingerprint"></i> ${label}`;
passkeyBtn.addEventListener('click', async () => {
passkeyBtn.disabled = true;
passkeyBtn.textContent = 'Waiting for authentication…';
const result = await loginWithPasskey();
if (result.success) {
window.location.href = result.redirectUrl || '/';
} else if (!result.cancelled) {
passkeyBtn.disabled = false;
passkeyBtn.innerHTML = `<i class="bi bi-fingerprint"></i> ${label}`;
const errEl = document.getElementById('passkey-error');
if (errEl) {
errEl.textContent = result.error || 'Authentication failed. Try again.';
errEl.classList.remove('d-none');
}
} else {
passkeyBtn.disabled = false;
passkeyBtn.innerHTML = `<i class="bi bi-fingerprint"></i> ${label}`;
}
});
});
// ─── 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');
}
});