Compare commits
5 Commits
| 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
|
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
|
-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
|
-Check my ChatGPT chat about surface area for a few solid ideas for the system
|
||||||
|
|
||||||
-Add SMS capabilities
|
-Add SMS capabilities
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
Shop Management App TO DO List
|
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
|
-Check my ChatGPT chat about surface area for a few solid ideas for the system
|
||||||
|
|
||||||
-Add SMS capabilities
|
-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)
|
-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.
|
-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
|
Ideas Removed
|
||||||
=======================
|
=======================
|
||||||
-Add Deactivate Customer button on Customer Detail page
|
-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>
|
/// </summary>
|
||||||
public DbSet<PendingRegistrationSession> PendingRegistrationSessions { get; set; }
|
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>
|
/// <summary>
|
||||||
/// Configures the EF Core model: applies entity type configurations from the assembly,
|
/// Configures the EF Core model: applies entity type configurations from the assembly,
|
||||||
/// registers global query filters, defines relationships, adds performance indexes, and seeds
|
/// 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)");
|
property.SetColumnType("decimal(18,2)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UserPasskey: unique index on CredentialId (WebAuthn requires global uniqueness)
|
||||||
|
modelBuilder.Entity<UserPasskey>()
|
||||||
|
.HasIndex(p => p.CredentialId)
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
// Configure relationships
|
// Configure relationships
|
||||||
ConfigureRelationships(modelBuilder);
|
ConfigureRelationships(modelBuilder);
|
||||||
|
|
||||||
// Seed initial data
|
// Seed initial data
|
||||||
SeedInitialData(modelBuilder);
|
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,
|
Id = 1,
|
||||||
CompanyId = 0,
|
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",
|
Description = "Standard pricing for regular customers",
|
||||||
DiscountPercent = 0m,
|
DiscountPercent = 0m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -5793,7 +5793,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
CompanyId = 0,
|
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",
|
Description = "5% discount for preferred customers",
|
||||||
DiscountPercent = 5m,
|
DiscountPercent = 5m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -5804,7 +5804,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 3,
|
Id = 3,
|
||||||
CompanyId = 0,
|
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",
|
Description = "10% discount for premium customers",
|
||||||
DiscountPercent = 10m,
|
DiscountPercent = 10m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -7255,6 +7255,53 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.ToTable("TermsAcceptances");
|
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 =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.Vendor", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
|
|||||||
@@ -247,6 +247,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</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)
|
@if (Model.SignupOpen)
|
||||||
{
|
{
|
||||||
<div class="auth-divider"><span>or</span></div>
|
<div class="auth-divider"><span>or</span></div>
|
||||||
@@ -269,17 +280,6 @@
|
|||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
<partial name="_ValidationScriptsPartial" />
|
<partial name="_ValidationScriptsPartial" />
|
||||||
<script>
|
<script src="~/js/login-toggle-pw.js"></script>
|
||||||
document.getElementById('togglePw').addEventListener('click', function () {
|
<script src="~/js/passkey.js"></script>
|
||||||
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>
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -573,53 +573,40 @@ public class JobsController : Controller
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Shows the status-bump selection page for a shop-floor QR code scan.
|
/// 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
|
/// Requires authentication — workers must be logged in before scanning. Tenant isolation
|
||||||
/// on a work order, not by logged-in users. The <paramref name="token"/> is the job's
|
/// is enforced by the normal global query filter on <c>GetByIdAsync</c>.
|
||||||
/// ShopAccessCode GUID. IgnoreQueryFilters is used so the scan works even if the worker's
|
|
||||||
/// device has no active session (common on shared shop tablets).
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[AllowAnonymous]
|
public async Task<IActionResult> StatusBump(int id)
|
||||||
public async Task<IActionResult> StatusBump(Guid token)
|
|
||||||
{
|
{
|
||||||
// Find job by ShopAccessCode — ignore tenant/soft-delete filters so the
|
var job = await _unitOfWork.Jobs.GetByIdAsync(id, false,
|
||||||
// anonymous scan always finds the job regardless of active user session.
|
|
||||||
var jobs = await _unitOfWork.Jobs.FindAsync(
|
|
||||||
j => j.ShopAccessCode == token, true,
|
|
||||||
j => j.JobStatus,
|
j => j.JobStatus,
|
||||||
j => j.JobPriority,
|
j => j.JobPriority,
|
||||||
j => j.Customer);
|
j => j.Customer);
|
||||||
|
|
||||||
var job = jobs.FirstOrDefault();
|
if (job == null) return NotFound();
|
||||||
if (job == null) return NotFound("Work order token not found.");
|
|
||||||
|
|
||||||
// Load all status lookups to determine next step
|
|
||||||
var allStatuses = (await _unitOfWork.JobStatusLookups.GetAllAsync())
|
var allStatuses = (await _unitOfWork.JobStatusLookups.GetAllAsync())
|
||||||
.OrderBy(s => s.DisplayOrder).ToList();
|
.OrderBy(s => s.DisplayOrder).ToList();
|
||||||
|
|
||||||
ViewBag.AllStatuses = allStatuses;
|
ViewBag.AllStatuses = allStatuses;
|
||||||
ViewBag.Job = job;
|
ViewBag.Job = job;
|
||||||
ViewBag.Token = token;
|
ViewBag.JobId = id;
|
||||||
return View();
|
return View();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Processes a QR-code status bump from the shop floor — also AllowAnonymous.
|
/// Processes a QR-code status bump from the shop floor. Requires authentication.
|
||||||
/// Validates the token, applies the new status, records the change history, and broadcasts
|
/// Records the authenticated user's name in status history.
|
||||||
/// a SignalR update so the office dashboard refreshes in real time.
|
|
||||||
/// The "bumped by" user is recorded as the ShopAccessCode token string (anonymous actor).
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[AllowAnonymous]
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ValidateAntiForgeryToken]
|
[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(
|
var job = await _unitOfWork.Jobs.GetByIdAsync(id, false,
|
||||||
j => j.ShopAccessCode == token, true,
|
|
||||||
j => j.JobStatus,
|
j => j.JobStatus,
|
||||||
j => j.Customer);
|
j => j.Customer);
|
||||||
|
|
||||||
var job = jobs.FirstOrDefault();
|
if (job == null) return NotFound();
|
||||||
if (job == null) return NotFound("Work order token not found.");
|
|
||||||
|
|
||||||
var allStatuses = (await _unitOfWork.JobStatusLookups.GetAllAsync()).ToList();
|
var allStatuses = (await _unitOfWork.JobStatusLookups.GetAllAsync()).ToList();
|
||||||
var newStatus = allStatuses.FirstOrDefault(s => s.Id == newStatusId);
|
var newStatus = allStatuses.FirstOrDefault(s => s.Id == newStatusId);
|
||||||
@@ -630,28 +617,29 @@ public class JobsController : Controller
|
|||||||
job.UpdatedAt = DateTime.UtcNow;
|
job.UpdatedAt = DateTime.UtcNow;
|
||||||
if (newStatus.StatusCode == "COMPLETED") job.CompletedDate = DateTime.UtcNow;
|
if (newStatus.StatusCode == "COMPLETED") job.CompletedDate = DateTime.UtcNow;
|
||||||
|
|
||||||
|
var userName = User.Identity?.Name ?? "Shop Floor";
|
||||||
await _unitOfWork.JobStatusHistory.AddAsync(new JobStatusHistory
|
await _unitOfWork.JobStatusHistory.AddAsync(new JobStatusHistory
|
||||||
{
|
{
|
||||||
JobId = job.Id,
|
JobId = job.Id,
|
||||||
FromStatusId = oldStatusId,
|
FromStatusId = oldStatusId,
|
||||||
ToStatusId = newStatusId,
|
ToStatusId = newStatusId,
|
||||||
ChangedDate = DateTime.UtcNow,
|
ChangedDate = DateTime.UtcNow,
|
||||||
Notes = "Updated via shop floor QR scan",
|
Notes = $"Updated via shop floor QR scan by {userName}",
|
||||||
CompanyId = job.CompanyId,
|
CompanyId = job.CompanyId,
|
||||||
CreatedAt = DateTime.UtcNow
|
CreatedAt = DateTime.UtcNow
|
||||||
});
|
});
|
||||||
|
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
// Reload job status for redirect display
|
return RedirectToAction(nameof(StatusBump), new { id });
|
||||||
return RedirectToAction(nameof(StatusBump), new { token });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Renders the printable work order view for a job.
|
/// Renders the printable work order view for a job.
|
||||||
/// Loads all job items with their coats and prep services so the printed sheet contains
|
/// 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 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>
|
/// </summary>
|
||||||
public async Task<IActionResult> WorkOrder(int? id)
|
public async Task<IActionResult> WorkOrder(int? id)
|
||||||
{
|
{
|
||||||
@@ -713,13 +701,19 @@ public class JobsController : Controller
|
|||||||
ViewBag.Company = company;
|
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();
|
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 qrData = qrGenerator.CreateQrCode(statusBumpUrl, QRCoder.QRCodeGenerator.ECCLevel.M);
|
||||||
using var qrCode = new QRCoder.PngByteQRCode(qrData);
|
using var qrCode = new QRCoder.PngByteQRCode(qrData);
|
||||||
var qrBytes = qrCode.GetGraphic(4);
|
ViewBag.QrCodeBase64 = Convert.ToBase64String(qrCode.GetGraphic(4));
|
||||||
ViewBag.QrCodeBase64 = Convert.ToBase64String(qrBytes);
|
|
||||||
ViewBag.StatusBumpUrl = statusBumpUrl;
|
ViewBag.StatusBumpUrl = statusBumpUrl;
|
||||||
|
|
||||||
// Generate QR codes for each unique inventory powder item so workers can
|
// 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."
|
**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.
|
**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.
|
- 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.
|
- 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
|
- Upload a profile photo
|
||||||
- Choose display theme (light/dark)
|
- Choose display theme (light/dark)
|
||||||
- Manage two-factor authentication settings
|
- 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.
|
**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
|
## BILLING & SUBSCRIPTION
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
<PackageReference Include="Azure.Identity" Version="1.21.0" />
|
<PackageReference Include="Azure.Identity" Version="1.21.0" />
|
||||||
<PackageReference Include="Azure.Monitor.Query" Version="1.7.1" />
|
<PackageReference Include="Azure.Monitor.Query" Version="1.7.1" />
|
||||||
<PackageReference Include="EPPlus" Version="7.0.0" />
|
<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="Markdig" Version="0.40.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="8.0.11" />
|
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="8.0.11" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Identity.UI" 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
|
// Add memory cache
|
||||||
builder.Services.AddMemoryCache();
|
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
|
// Configure authorization policies for multi-tenancy
|
||||||
builder.Services.AddAuthorization(options =>
|
builder.Services.AddAuthorization(options =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -576,6 +576,49 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section id="blank-work-order" class="mb-5">
|
||||||
<h2 class="h5 fw-semibold mb-3">Blank Work Order</h2>
|
<h2 class="h5 fw-semibold mb-3">Blank Work Order</h2>
|
||||||
<p>
|
<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="#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="#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="#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>
|
<a class="nav-link py-1 px-3 small text-body" href="#blank-work-order">Blank Work Order</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -169,6 +169,64 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section id="appearance" class="mb-5">
|
||||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||||
<i class="bi bi-palette text-primary me-2"></i>Appearance
|
<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="#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="#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="#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>
|
<a class="nav-link py-1 px-3 small text-body" href="#appearance">Appearance</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
Layout = null;
|
Layout = null;
|
||||||
var job = ViewBag.Job as PowderCoating.Core.Entities.Job;
|
var job = ViewBag.Job as PowderCoating.Core.Entities.Job;
|
||||||
var allStatuses = ViewBag.AllStatuses as List<PowderCoating.Core.Entities.JobStatusLookup>;
|
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
|
// Determine next/previous status options
|
||||||
var currentOrder = job!.JobStatus.DisplayOrder;
|
var currentOrder = job!.JobStatus.DisplayOrder;
|
||||||
@@ -240,7 +240,7 @@
|
|||||||
@* On hold — offer resume (next logical status after resume by advancing) *@
|
@* On hold — offer resume (next logical status after resume by advancing) *@
|
||||||
@if (nextStatus != null)
|
@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()
|
@Html.AntiForgeryToken()
|
||||||
<input type="hidden" name="newStatusId" value="@nextStatus.Id" />
|
<input type="hidden" name="newStatusId" value="@nextStatus.Id" />
|
||||||
<button type="submit" class="btn-resume">
|
<button type="submit" class="btn-resume">
|
||||||
@@ -254,7 +254,7 @@
|
|||||||
@* Advance to next step *@
|
@* Advance to next step *@
|
||||||
@if (nextStatus != null)
|
@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()
|
@Html.AntiForgeryToken()
|
||||||
<input type="hidden" name="newStatusId" value="@nextStatus.Id" />
|
<input type="hidden" name="newStatusId" value="@nextStatus.Id" />
|
||||||
<button type="submit" class="btn-advance">
|
<button type="submit" class="btn-advance">
|
||||||
@@ -270,7 +270,7 @@
|
|||||||
@* On Hold option *@
|
@* On Hold option *@
|
||||||
@if (onHoldStatus != null)
|
@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()
|
@Html.AntiForgeryToken()
|
||||||
<input type="hidden" name="newStatusId" value="@onHoldStatus.Id" />
|
<input type="hidden" name="newStatusId" value="@onHoldStatus.Id" />
|
||||||
<button type="submit" class="btn-hold">
|
<button type="submit" class="btn-hold">
|
||||||
|
|||||||
@@ -292,22 +292,33 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
<div class="work-order-title">WORK ORDER</div>
|
<div class="work-order-title">WORK ORDER</div>
|
||||||
<div class="text-center" style="font-size: 8pt; line-height: 1.6;">
|
<div style="display: flex; align-items: center; justify-content: space-between; gap: 8px;">
|
||||||
<div class="mb-2">
|
<div style="font-size: 8pt; line-height: 1.6; flex: 1; text-align: center;">
|
||||||
<span class="text-muted">Job #:</span>
|
<div class="mb-2">
|
||||||
<span style="font-size: 14pt;" class="fw-bold ms-1">@Model.JobNumber</span>
|
<span class="text-muted">Job #:</span>
|
||||||
</div>
|
<span style="font-size: 14pt;" class="fw-bold ms-1">@Model.JobNumber</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>
|
||||||
<div>
|
<div class="d-flex justify-content-center align-items-center gap-3 mb-1">
|
||||||
<span class="text-muted">Status:</span>
|
<div>
|
||||||
<span class="badge bg-@Model.StatusColorClass ms-1">@Model.StatusDisplayName</span>
|
<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>
|
||||||
|
<div class="text-center text-muted" style="font-size: 7pt;">Created: @Model.CreatedAt.ToString("MM/dd/yyyy")</div>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -605,7 +616,7 @@
|
|||||||
<i class="bi bi-arrow-right-circle me-1"></i>Update Status
|
<i class="bi bi-arrow-right-circle me-1"></i>Update Status
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size: 7.5pt; color: #6c757d; line-height: 1.5;">
|
<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>
|
</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>
|
<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) *@
|
@* Hidden container for ModelState errors (read by toast-notifications.js) *@
|
||||||
@if (!ViewData.ModelState.IsValid && ViewData.ModelState.ErrorCount > 0)
|
@if (!ViewData.ModelState.IsValid && ViewData.ModelState.ErrorCount > 0)
|
||||||
{
|
{
|
||||||
@@ -1487,6 +1514,7 @@
|
|||||||
}
|
}
|
||||||
<ul class="dropdown-menu dropdown-menu-end">
|
<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="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="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="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>
|
<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("_AiQuickQuoteWidget") *@
|
||||||
@await Html.PartialAsync("_AiHelpWidget")
|
@await Html.PartialAsync("_AiHelpWidget")
|
||||||
|
<script src="~/js/passkey.js"></script>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- ── Quick-Add Modal (reusable inline form host) ─────────────────────── -->
|
<!-- ── Quick-Add Modal (reusable inline form host) ─────────────────────── -->
|
||||||
|
|||||||
@@ -68,6 +68,12 @@
|
|||||||
"Enterprise": "price_enterprise_monthly_id_here"
|
"Enterprise": "price_enterprise_monthly_id_here"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Fido2": {
|
||||||
|
"ServerDomain": "localhost",
|
||||||
|
"ServerName": "Powder Coating Logix",
|
||||||
|
"Origins": [ "https://localhost:58461", "http://localhost:58462" ],
|
||||||
|
"TimestampDriftTolerance": 300
|
||||||
|
},
|
||||||
"Storage": {
|
"Storage": {
|
||||||
"ConnectionString": "DefaultEndpointsProtocol=https;AccountName=powdercoatingappdev;AccountKey=DN3eVfhytXb7aBC0md9h/6jE0Uzg6FJ+PK6MFc772qyqpf0kgTeXH0C2VCBBun9PiuItPd9CDKTP+ASthFCuCg==;EndpointSuffix=core.windows.net",
|
"ConnectionString": "DefaultEndpointsProtocol=https;AccountName=powdercoatingappdev;AccountKey=DN3eVfhytXb7aBC0md9h/6jE0Uzg6FJ+PK6MFc772qyqpf0kgTeXH0C2VCBBun9PiuItPd9CDKTP+ASthFCuCg==;EndpointSuffix=core.windows.net",
|
||||||
"Containers": {
|
"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