Compare commits
5 Commits
00bf8a4cd0
...
edc599a1a2
| Author | SHA1 | Date | |
|---|---|---|---|
| edc599a1a2 | |||
| 90a5a028ad | |||
| 0bb96a502a | |||
| 4f976b1332 | |||
| 9361cd4495 |
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-ef": {
|
||||
"version": "8.0.11",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+9244
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" />
|
||||
|
||||
@@ -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 & 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 & 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 & Biometrics</a> (user menu → Passkeys & 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 & 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 & 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">• 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 & 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) ─────────────────────── -->
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user