From db765589441f4ae89c3c8ce82edcca840bc544f4 Mon Sep 17 00:00:00 2001 From: Adrian Astles Date: Thu, 3 Jul 2025 21:53:07 +0800 Subject: [PATCH 01/12] refactor: rename passkeyChallenge to webauthnChallenge - Renamed table for consistency with webauthnCredentials - Created migration script 1.8.1.ts for table rename - Updated schema definitions in SQLite and PostgreSQL - Maintains WebAuthn standard naming convention --- messages/en-US.json | 19 +- package-lock.json | 948 +++++++++++++++++----------- package.json | 11 +- server/db/pg/schema.ts | 10 + server/db/sqlite/driver.ts | 2 + server/db/sqlite/migrate.ts | 8 + server/db/sqlite/schema.ts | 23 + server/index.ts | 2 +- server/routers/auth/index.ts | 1 + server/routers/auth/passkey.ts | 606 ++++++++++++++++++ server/routers/external.ts | 33 + server/setup/migrationsSqlite.ts | 20 +- server/setup/scriptsSqlite/1.4.0.ts | 31 + server/setup/scriptsSqlite/1.7.0.ts | 39 ++ server/setup/scriptsSqlite/1.8.0.ts | 38 ++ server/setup/scriptsSqlite/1.8.1.ts | 27 + src/components/LoginForm.tsx | 61 +- src/components/PasskeyForm.tsx | 234 +++++++ src/components/ProfileIcon.tsx | 9 +- 19 files changed, 1735 insertions(+), 387 deletions(-) create mode 100644 server/routers/auth/passkey.ts create mode 100644 server/setup/scriptsSqlite/1.4.0.ts create mode 100644 server/setup/scriptsSqlite/1.7.0.ts create mode 100644 server/setup/scriptsSqlite/1.8.0.ts create mode 100644 server/setup/scriptsSqlite/1.8.1.ts create mode 100644 src/components/PasskeyForm.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 4990774b..36927b51 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1132,5 +1132,22 @@ "initialSetupTitle": "Initial Server Setup", "initialSetupDescription": "Create the intial server admin account. Only one server admin can exist. You can always change these credentials later.", "createAdminAccount": "Create Admin Account", - "setupErrorCreateAdmin": "An error occurred while creating the server admin account." + "setupErrorCreateAdmin": "An error occurred while creating the server admin account.", + "passkeyManage": "Manage Passkeys", + "passkeyDescription": "Add or remove passkeys for passwordless authentication", + "passkeyRegister": "Register New Passkey", + "passkeyList": "Your Passkeys", + "passkeyNone": "No passkeys registered yet", + "passkeyNameRequired": "Name is required", + "passkeyRemove": "Remove", + "passkeyLastUsed": "Last used: {date}", + "passkeyNameLabel": "Name", + "passkeyNamePlaceholder": "Enter a name for this passkey", + "passkeyRegisterSuccess": "Passkey registered successfully", + "passkeyRegisterError": "Failed to register passkey", + "passkeyRemoveSuccess": "Passkey removed successfully", + "passkeyRemoveError": "Failed to remove passkey", + "passkeyLoadError": "Failed to load passkeys", + "passkeyLogin": "Login with Passkey", + "passkeyAuthError": "Failed to authenticate with passkey" } diff --git a/package-lock.json b/package-lock.json index 24196000..e967c70a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,8 @@ "@react-email/components": "0.0.41", "@react-email/render": "^1.1.2", "@react-email/tailwind": "1.0.5", + "@simplewebauthn/browser": "^13.1.0", + "@simplewebauthn/server": "^9.0.3", "@tailwindcss/forms": "^0.5.10", "@tanstack/react-table": "8.21.3", "arctic": "^3.7.0", @@ -79,7 +81,7 @@ "react-hook-form": "7.56.4", "react-icons": "^5.5.0", "rebuild": "0.1.2", - "semver": "7.7.2", + "semver": "^7.7.2", "swagger-ui-express": "^5.0.1", "tailwind-merge": "2.6.0", "tw-animate-css": "^1.3.3", @@ -101,6 +103,7 @@ "@types/cors": "2.8.19", "@types/crypto-js": "^4.2.2", "@types/express": "5.0.0", + "@types/express-session": "^1.18.2", "@types/jmespath": "^0.15.2", "@types/js-yaml": "4.0.9", "@types/jsonwebtoken": "^9.0.9", @@ -108,7 +111,7 @@ "@types/nodemailer": "6.4.17", "@types/react": "19.1.7", "@types/react-dom": "19.1.6", - "@types/semver": "7.7.0", + "@types/semver": "^7.7.0", "@types/swagger-ui-express": "^4.1.8", "@types/ws": "8.18.1", "@types/yargs": "17.0.33", @@ -152,9 +155,9 @@ } }, "node_modules/@asteasolutions/zod-to-openapi": { - "version": "7.3.3", - "resolved": "https://registry.npmjs.org/@asteasolutions/zod-to-openapi/-/zod-to-openapi-7.3.3.tgz", - "integrity": "sha512-ioiw+R+gBGAUwmDp+/gJA16tedBivzDaji5wOvWej0ZYDE0CXTSSfJfXbrBIuWKh6JQhuXgNDniJdeDueKUZTA==", + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@asteasolutions/zod-to-openapi/-/zod-to-openapi-7.3.4.tgz", + "integrity": "sha512-/2rThQ5zPi9OzVwes6U7lK1+Yvug0iXu25olp7S0XsYmOqnyMfxH7gdSQjn/+DSOHRg7wnotwGJSyL+fBKdnEA==", "license": "MIT", "dependencies": { "openapi3-ts": "^4.1.2" @@ -179,22 +182,32 @@ } }, "node_modules/@babel/generator": { - "version": "7.27.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.5.tgz", - "integrity": "sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", + "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.27.5", - "@babel/types": "^7.27.3", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", + "@babel/parser": "^7.28.0", + "@babel/types": "^7.28.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -216,13 +229,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.27.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.5.tgz", - "integrity": "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", + "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.27.3" + "@babel/types": "^7.28.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -247,38 +260,28 @@ } }, "node_modules/@babel/traverse": { - "version": "7.27.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.4.tgz", - "integrity": "sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", + "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.27.3", - "@babel/parser": "^7.27.4", + "@babel/generator": "^7.28.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", - "@babel/types": "^7.27.3", - "debug": "^4.3.1", - "globals": "^11.1.0" + "@babel/types": "^7.28.0", + "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/traverse/node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/types": { - "version": "7.27.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.6.tgz", - "integrity": "sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz", + "integrity": "sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg==", "dev": true, "license": "MIT", "dependencies": { @@ -1383,12 +1386,12 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.2.tgz", - "integrity": "sha512-4SaFZCNfJqvk/kenHpI8xvN42DMaoycy4PzKc5otHxRswww1kAt82OlBuwRVLofCACCTZEcla2Ydxv8scMXaTg==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.3.tgz", + "integrity": "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==", "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.15.0", + "@eslint/core": "^0.15.1", "levn": "^0.4.1" }, "engines": { @@ -1396,9 +1399,9 @@ } }, "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.0.tgz", - "integrity": "sha512-b7ePw78tEWWkpgZCDYkbqDOP8dmM6qe+AOC6iuJqlq1R/0ahMAeH3qynpnqKFGkMltrp44ohV4ubGyvLX28tzw==", + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", + "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" @@ -1408,31 +1411,31 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.1.tgz", - "integrity": "sha512-azI0DrjMMfIug/ExbBaeDVJXcY0a7EPvPjb2xAJPa4HeimBX+Z18HK8QQR3jb6356SnDDdxx+hinMLcJEDdOjw==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.2.tgz", + "integrity": "sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==", "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.9" + "@floating-ui/utils": "^0.2.10" } }, "node_modules/@floating-ui/dom": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.1.tgz", - "integrity": "sha512-cwsmW/zyw5ltYTUeeYJ60CnQuPqmGwuGVhG9w0PRaRKkAyi38BT5CKrpIbb+jtahSwUl04cWzSx9ZOIxeS6RsQ==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.2.tgz", + "integrity": "sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.7.1", - "@floating-ui/utils": "^0.2.9" + "@floating-ui/core": "^1.7.2", + "@floating-ui/utils": "^0.2.10" } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.3.tgz", - "integrity": "sha512-huMBfiU9UnQ2oBwIhgzyIiSpVgvlDstU8CX0AF+wS+KzmYMs0J2a3GwuFHV1Lz+jlrQGeC1fF+Nv0QoumyV0bA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.4.tgz", + "integrity": "sha512-JbbpPhp38UmXDDAu60RJmbeme37Jbgsm7NrHGgzYYFKmblzRUh6Pa641dII6LsjwF4XlScDrde2UAzDo/b9KPw==", "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.0.0" + "@floating-ui/dom": "^1.7.2" }, "peerDependencies": { "react": ">=16.8.0", @@ -1440,9 +1443,9 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", - "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "license": "MIT" }, "node_modules/@formatjs/ecma402-abstract": { @@ -1505,6 +1508,12 @@ "tslib": "2" } }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", + "license": "MIT" + }, "node_modules/@hookform/resolvers": { "version": "3.9.1", "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.9.1.tgz", @@ -2023,18 +2032,14 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", + "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -2047,27 +2052,17 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", + "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.29", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2075,6 +2070,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.11.tgz", @@ -2897,6 +2898,64 @@ "integrity": "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q==", "license": "MIT" }, + "node_modules/@peculiar/asn1-android": { + "version": "2.3.16", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.3.16.tgz", + "integrity": "sha512-a1viIv3bIahXNssrOIkXZIlI2ePpZaNmR30d4aBL99mu2rO+mT9D6zBsp7H6eROWGtmwv0Ionp5olJurIo09dw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.15", + "asn1js": "^3.0.5", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.3.15", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.3.15.tgz", + "integrity": "sha512-/HtR91dvgog7z/WhCVdxZJ/jitJuIu8iTqiyWVgRE9Ac5imt2sT/E4obqIVGKQw7PIy+X6i8lVBoT6wC73XUgA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.15", + "@peculiar/asn1-x509": "^2.3.15", + "asn1js": "^3.0.5", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.3.15", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.3.15.tgz", + "integrity": "sha512-p6hsanvPhexRtYSOHihLvUUgrJ8y0FtOM97N5UEpC+VifFYyZa0iZ5cXjTkZoDwxJ/TTJ1IJo3HVTB2JJTpXvg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.15", + "@peculiar/asn1-x509": "^2.3.15", + "asn1js": "^3.0.5", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.3.15", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.15.tgz", + "integrity": "sha512-QPeD8UA8axQREpgR5UTAfu2mqQmm97oUqahDtNdBcfj3qAnoXzFdQW+aNf/tD2WVXF8Fhmftxoj0eMIT++gX2w==", + "license": "MIT", + "dependencies": { + "asn1js": "^3.0.5", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.3.15", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.3.15.tgz", + "integrity": "sha512-0dK5xqTqSLaxv1FHXIcd4Q/BZNuopg+u1l23hT9rOmQ1g4dNtw0g/RnEi+TboB0gOwGtrWn269v27cMgchFIIg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.15", + "asn1js": "^3.0.5", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -4024,6 +4083,24 @@ "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, + "node_modules/@react-email/components/node_modules/@react-email/render": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.1.2.tgz", + "integrity": "sha512-RnRehYN3v9gVlNMehHPHhyp2RQo7+pSkHDtXPvg3s0GbzM9SQMW4Qrf8GRNvtpLC4gsI+Wt0VatNRUFqjvevbw==", + "license": "MIT", + "dependencies": { + "html-to-text": "^9.0.5", + "prettier": "^3.5.3", + "react-promise-suspense": "^0.3.4" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, "node_modules/@react-email/container": { "version": "0.0.15", "resolved": "https://registry.npmjs.org/@react-email/container/-/container-0.0.15.tgz", @@ -4145,9 +4222,9 @@ } }, "node_modules/@react-email/render": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.1.2.tgz", - "integrity": "sha512-RnRehYN3v9gVlNMehHPHhyp2RQo7+pSkHDtXPvg3s0GbzM9SQMW4Qrf8GRNvtpLC4gsI+Wt0VatNRUFqjvevbw==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.1.3.tgz", + "integrity": "sha512-TjjF1tdTmOqYEIWWg9wMx5q9JbQRbWmnG7owQbSGEHkNfc/c/vBu7hjfrki907lgQEAkYac9KPTyIjOKhvhJCg==", "license": "MIT", "dependencies": { "html-to-text": "^9.0.5", @@ -4217,9 +4294,9 @@ "license": "MIT" }, "node_modules/@rushstack/eslint-patch": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.11.0.tgz", - "integrity": "sha512-zxnHvoMQVqewTJr/W4pKjF0bMGiKJv1WX7bSrkl46Hg0QjESbzBROWK0Wg4RphzSOS5Jiy7eFimmM3UgMrMZbQ==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.12.0.tgz", + "integrity": "sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==", "license": "MIT" }, "node_modules/@scarf/scarf": { @@ -4248,6 +4325,38 @@ "url": "https://ko-fi.com/killymxi" } }, + "node_modules/@simplewebauthn/browser": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.1.0.tgz", + "integrity": "sha512-WuHZ/PYvyPJ9nxSzgHtOEjogBhwJfC8xzYkPC+rR/+8chl/ft4ngjiK8kSU5HtRJfczupyOh33b25TjYbvwAcg==", + "license": "MIT" + }, + "node_modules/@simplewebauthn/server": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-9.0.3.tgz", + "integrity": "sha512-FMZieoBosrVLFxCnxPFD9Enhd1U7D8nidVDT4MsHc6l4fdVcjoeHjDueeXCloO1k5O/fZg1fsSXXPKbY2XTzDA==", + "license": "MIT", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.3.10", + "@peculiar/asn1-ecc": "^2.3.8", + "@peculiar/asn1-rsa": "^2.3.8", + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "@simplewebauthn/types": "^9.0.1", + "cross-fetch": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@simplewebauthn/types": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-9.0.1.tgz", + "integrity": "sha512-tGSRP1QvsAvsJmnOlRQyw/mvK9gnPtjEc5fg2+m8n+QUa+D7rvrKkOYyfpy42GTs90X3RDOnqJgfHt+qO67/+w==", + "license": "MIT" + }, "node_modules/@socket.io/component-emitter": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", @@ -4283,9 +4392,9 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.10.tgz", - "integrity": "sha512-2ACf1znY5fpRBwRhMgj9ZXvb2XZW8qs+oTfotJ2C5xR0/WNL7UHZ7zXl6s+rUqedL1mNi+0O+WQr5awGowS3PQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.11.tgz", + "integrity": "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q==", "dev": true, "license": "MIT", "dependencies": { @@ -4295,13 +4404,13 @@ "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", - "tailwindcss": "4.1.10" + "tailwindcss": "4.1.11" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.10.tgz", - "integrity": "sha512-v0C43s7Pjw+B9w21htrQwuFObSkio2aV/qPx/mhrRldbqxbWJK6KizM+q7BF1/1CmuLqZqX3CeYF7s7P9fbA8Q==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.11.tgz", + "integrity": "sha512-Q69XzrtAhuyfHo+5/HMgr1lAiPP/G40OMFAnws7xcFEYqcypZmdW8eGXaOUIeOl1dzPJBPENXgbjsOyhg2nkrg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -4313,24 +4422,24 @@ "node": ">= 10" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.10", - "@tailwindcss/oxide-darwin-arm64": "4.1.10", - "@tailwindcss/oxide-darwin-x64": "4.1.10", - "@tailwindcss/oxide-freebsd-x64": "4.1.10", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.10", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.10", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.10", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.10", - "@tailwindcss/oxide-linux-x64-musl": "4.1.10", - "@tailwindcss/oxide-wasm32-wasi": "4.1.10", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.10", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.10" + "@tailwindcss/oxide-android-arm64": "4.1.11", + "@tailwindcss/oxide-darwin-arm64": "4.1.11", + "@tailwindcss/oxide-darwin-x64": "4.1.11", + "@tailwindcss/oxide-freebsd-x64": "4.1.11", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.11", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.11", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.11", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.11", + "@tailwindcss/oxide-linux-x64-musl": "4.1.11", + "@tailwindcss/oxide-wasm32-wasi": "4.1.11", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.11", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.11" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.10.tgz", - "integrity": "sha512-VGLazCoRQ7rtsCzThaI1UyDu/XRYVyH4/EWiaSX6tFglE+xZB5cvtC5Omt0OQ+FfiIVP98su16jDVHDEIuH4iQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.11.tgz", + "integrity": "sha512-3IfFuATVRUMZZprEIx9OGDjG3Ou3jG4xQzNTvjDoKmU9JdmoCohQJ83MYd0GPnQIu89YoJqvMM0G3uqLRFtetg==", "cpu": [ "arm64" ], @@ -4345,9 +4454,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.10.tgz", - "integrity": "sha512-ZIFqvR1irX2yNjWJzKCqTCcHZbgkSkSkZKbRM3BPzhDL/18idA8uWCoopYA2CSDdSGFlDAxYdU2yBHwAwx8euQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.11.tgz", + "integrity": "sha512-ESgStEOEsyg8J5YcMb1xl8WFOXfeBmrhAwGsFxxB2CxY9evy63+AtpbDLAyRkJnxLy2WsD1qF13E97uQyP1lfQ==", "cpu": [ "arm64" ], @@ -4362,9 +4471,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.10.tgz", - "integrity": "sha512-eCA4zbIhWUFDXoamNztmS0MjXHSEJYlvATzWnRiTqJkcUteSjO94PoRHJy1Xbwp9bptjeIxxBHh+zBWFhttbrQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.11.tgz", + "integrity": "sha512-EgnK8kRchgmgzG6jE10UQNaH9Mwi2n+yw1jWmof9Vyg2lpKNX2ioe7CJdf9M5f8V9uaQxInenZkOxnTVL3fhAw==", "cpu": [ "x64" ], @@ -4379,9 +4488,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.10.tgz", - "integrity": "sha512-8/392Xu12R0cc93DpiJvNpJ4wYVSiciUlkiOHOSOQNH3adq9Gi/dtySK7dVQjXIOzlpSHjeCL89RUUI8/GTI6g==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.11.tgz", + "integrity": "sha512-xdqKtbpHs7pQhIKmqVpxStnY1skuNh4CtbcyOHeX1YBE0hArj2romsFGb6yUmzkq/6M24nkxDqU8GYrKrz+UcA==", "cpu": [ "x64" ], @@ -4396,9 +4505,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.10.tgz", - "integrity": "sha512-t9rhmLT6EqeuPT+MXhWhlRYIMSfh5LZ6kBrC4FS6/+M1yXwfCtp24UumgCWOAJVyjQwG+lYva6wWZxrfvB+NhQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.11.tgz", + "integrity": "sha512-ryHQK2eyDYYMwB5wZL46uoxz2zzDZsFBwfjssgB7pzytAeCCa6glsiJGjhTEddq/4OsIjsLNMAiMlHNYnkEEeg==", "cpu": [ "arm" ], @@ -4413,9 +4522,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.10.tgz", - "integrity": "sha512-3oWrlNlxLRxXejQ8zImzrVLuZ/9Z2SeKoLhtCu0hpo38hTO2iL86eFOu4sVR8cZc6n3z7eRXXqtHJECa6mFOvA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.11.tgz", + "integrity": "sha512-mYwqheq4BXF83j/w75ewkPJmPZIqqP1nhoghS9D57CLjsh3Nfq0m4ftTotRYtGnZd3eCztgbSPJ9QhfC91gDZQ==", "cpu": [ "arm64" ], @@ -4430,9 +4539,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.10.tgz", - "integrity": "sha512-saScU0cmWvg/Ez4gUmQWr9pvY9Kssxt+Xenfx1LG7LmqjcrvBnw4r9VjkFcqmbBb7GCBwYNcZi9X3/oMda9sqQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.11.tgz", + "integrity": "sha512-m/NVRFNGlEHJrNVk3O6I9ggVuNjXHIPoD6bqay/pubtYC9QIdAMpS+cswZQPBLvVvEF6GtSNONbDkZrjWZXYNQ==", "cpu": [ "arm64" ], @@ -4447,9 +4556,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.10.tgz", - "integrity": "sha512-/G3ao/ybV9YEEgAXeEg28dyH6gs1QG8tvdN9c2MNZdUXYBaIY/Gx0N6RlJzfLy/7Nkdok4kaxKPHKJUlAaoTdA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.11.tgz", + "integrity": "sha512-YW6sblI7xukSD2TdbbaeQVDysIm/UPJtObHJHKxDEcW2exAtY47j52f8jZXkqE1krdnkhCMGqP3dbniu1Te2Fg==", "cpu": [ "x64" ], @@ -4464,9 +4573,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.10.tgz", - "integrity": "sha512-LNr7X8fTiKGRtQGOerSayc2pWJp/9ptRYAa4G+U+cjw9kJZvkopav1AQc5HHD+U364f71tZv6XamaHKgrIoVzA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.11.tgz", + "integrity": "sha512-e3C/RRhGunWYNC3aSF7exsQkdXzQ/M+aYuZHKnw4U7KQwTJotnWsGOIVih0s2qQzmEzOFIJ3+xt7iq67K/p56Q==", "cpu": [ "x64" ], @@ -4481,9 +4590,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.10.tgz", - "integrity": "sha512-d6ekQpopFQJAcIK2i7ZzWOYGZ+A6NzzvQ3ozBvWFdeyqfOZdYHU66g5yr+/HC4ipP1ZgWsqa80+ISNILk+ae/Q==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.11.tgz", + "integrity": "sha512-Xo1+/GU0JEN/C/dvcammKHzeM6NqKovG+6921MR6oadee5XPBaKOumrJCXvopJ/Qb5TH7LX/UAywbqrP4lax0g==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -4502,7 +4611,7 @@ "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@emnapi/wasi-threads": "^1.0.2", - "@napi-rs/wasm-runtime": "^0.2.10", + "@napi-rs/wasm-runtime": "^0.2.11", "@tybys/wasm-util": "^0.9.0", "tslib": "^2.8.0" }, @@ -4511,9 +4620,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.10.tgz", - "integrity": "sha512-i1Iwg9gRbwNVOCYmnigWCCgow8nDWSFmeTUU5nbNx3rqbe4p0kRbEqLwLJbYZKmSSp23g4N6rCDmm7OuPBXhDA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz", + "integrity": "sha512-UgKYx5PwEKrac3GPNPf6HVMNhUIGuUh4wlDFR2jYYdkX6pL/rn73zTq/4pzUm8fOjAn5L8zDeHp9iXmUGOXZ+w==", "cpu": [ "arm64" ], @@ -4528,9 +4637,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.10.tgz", - "integrity": "sha512-sGiJTjcBSfGq2DVRtaSljq5ZgZS2SDHSIfhOylkBvHVjwOsodBhnb3HdmiKkVuUGKD0I7G63abMOVaskj1KpOA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.11.tgz", + "integrity": "sha512-YfHoggn1j0LK7wR82TOucWc5LDCguHnoS879idHekmmiR7g9HUtMw9MI0NHatS28u/Xlkfi9w5RJWgz2Dl+5Qg==", "cpu": [ "x64" ], @@ -4545,17 +4654,17 @@ } }, "node_modules/@tailwindcss/postcss": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.10.tgz", - "integrity": "sha512-B+7r7ABZbkXJwpvt2VMnS6ujcDoR2OOcFaqrLIo1xbcdxje4Vf+VgJdBzNNbrAjBj/rLZ66/tlQ1knIGNLKOBQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.11.tgz", + "integrity": "sha512-q/EAIIpF6WpLhKEuQSEVMZNMIY8KhWoAemZ9eylNAih9jxMGAYPPWBn3I9QL/2jZ+e7OEz/tZkX5HwbBR4HohA==", "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.1.10", - "@tailwindcss/oxide": "4.1.10", + "@tailwindcss/node": "4.1.11", + "@tailwindcss/oxide": "4.1.11", "postcss": "^8.4.41", - "tailwindcss": "4.1.10" + "tailwindcss": "4.1.11" } }, "node_modules/@tanstack/react-table": { @@ -4691,6 +4800,16 @@ "@types/send": "*" } }, + "node_modules/@types/express-session": { + "version": "1.18.2", + "resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.18.2.tgz", + "integrity": "sha512-k+I0BxwVXsnEU2hV77cCobC08kIsn4y44C3gC0b46uxZVMaXA04lSPgRLR/bSL2w0t0ShJiG8o4jPzRG/nscFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -4725,9 +4844,9 @@ "license": "MIT" }, "node_modules/@types/jsonwebtoken": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.9.tgz", - "integrity": "sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==", + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", "dev": true, "license": "MIT", "dependencies": { @@ -4750,9 +4869,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.15.31", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.31.tgz", - "integrity": "sha512-jnVe5ULKl6tijxUhvQeNbQG/84fHfg+yMak02cT8QVhBx/F05rAVxCGBYYTh2EKz22D6JF5ktXuNwdx7b9iEGw==", + "version": "22.16.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.16.0.tgz", + "integrity": "sha512-B2egV9wALML1JCpv3VQoQ+yesQKAmNMBIAY7OteVrikcOcAkWm+dGL6qpeCktPjAv6N1JLnhbNiqS35UpFyBsQ==", "devOptional": true, "license": "MIT", "dependencies": { @@ -4878,16 +4997,16 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.34.0.tgz", - "integrity": "sha512-QXwAlHlbcAwNlEEMKQS2RCgJsgXrTJdjXT08xEgbPFa2yYQgVjBymxP5DrfrE7X7iodSzd9qBUHUycdyVJTW1w==", + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.1.tgz", + "integrity": "sha512-9XNTlo7P7RJxbVeICaIIIEipqxLKguyh+3UbXuT2XQuFp6d8VOeDEGuz5IiX0dgZo8CiI6aOFLg4e8cF71SFVg==", "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.34.0", - "@typescript-eslint/type-utils": "8.34.0", - "@typescript-eslint/utils": "8.34.0", - "@typescript-eslint/visitor-keys": "8.34.0", + "@typescript-eslint/scope-manager": "8.35.1", + "@typescript-eslint/type-utils": "8.35.1", + "@typescript-eslint/utils": "8.35.1", + "@typescript-eslint/visitor-keys": "8.35.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -4901,7 +5020,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.34.0", + "@typescript-eslint/parser": "^8.35.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } @@ -4916,15 +5035,15 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.34.0.tgz", - "integrity": "sha512-vxXJV1hVFx3IXz/oy2sICsJukaBrtDEQSBiV48/YIV5KWjX1dO+bcIr/kCPrW6weKXvsaGKFNlwH0v2eYdRRbA==", + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.35.1.tgz", + "integrity": "sha512-3MyiDfrfLeK06bi/g9DqJxP5pV74LNv4rFTyvGDmT3x2p1yp1lOd+qYZfiRPIOf/oON+WRZR5wxxuF85qOar+w==", "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.34.0", - "@typescript-eslint/types": "8.34.0", - "@typescript-eslint/typescript-estree": "8.34.0", - "@typescript-eslint/visitor-keys": "8.34.0", + "@typescript-eslint/scope-manager": "8.35.1", + "@typescript-eslint/types": "8.35.1", + "@typescript-eslint/typescript-estree": "8.35.1", + "@typescript-eslint/visitor-keys": "8.35.1", "debug": "^4.3.4" }, "engines": { @@ -4940,13 +5059,13 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.34.0.tgz", - "integrity": "sha512-iEgDALRf970/B2YExmtPMPF54NenZUf4xpL3wsCRx/lgjz6ul/l13R81ozP/ZNuXfnLCS+oPmG7JIxfdNYKELw==", + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.35.1.tgz", + "integrity": "sha512-VYxn/5LOpVxADAuP3NrnxxHYfzVtQzLKeldIhDhzC8UHaiQvYlXvKuVho1qLduFbJjjy5U5bkGwa3rUGUb1Q6Q==", "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.34.0", - "@typescript-eslint/types": "^8.34.0", + "@typescript-eslint/tsconfig-utils": "^8.35.1", + "@typescript-eslint/types": "^8.35.1", "debug": "^4.3.4" }, "engines": { @@ -4961,13 +5080,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.34.0.tgz", - "integrity": "sha512-9Ac0X8WiLykl0aj1oYQNcLZjHgBojT6cW68yAgZ19letYu+Hxd0rE0veI1XznSSst1X5lwnxhPbVdwjDRIomRw==", + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.35.1.tgz", + "integrity": "sha512-s/Bpd4i7ht2934nG+UoSPlYXd08KYz3bmjLEb7Ye1UVob0d1ENiT3lY8bsCmik4RqfSbPw9xJJHbugpPpP5JUg==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.34.0", - "@typescript-eslint/visitor-keys": "8.34.0" + "@typescript-eslint/types": "8.35.1", + "@typescript-eslint/visitor-keys": "8.35.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4978,9 +5097,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.34.0.tgz", - "integrity": "sha512-+W9VYHKFIzA5cBeooqQxqNriAP0QeQ7xTiDuIOr71hzgffm3EL2hxwWBIIj4GuofIbKxGNarpKqIq6Q6YrShOA==", + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.35.1.tgz", + "integrity": "sha512-K5/U9VmT9dTHoNowWZpz+/TObS3xqC5h0xAIjXPw+MNcKV9qg6eSatEnmeAwkjHijhACH0/N7bkhKvbt1+DXWQ==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4994,13 +5113,13 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.34.0.tgz", - "integrity": "sha512-n7zSmOcUVhcRYC75W2pnPpbO1iwhJY3NLoHEtbJwJSNlVAZuwqu05zY3f3s2SDWWDSo9FdN5szqc73DCtDObAg==", + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.35.1.tgz", + "integrity": "sha512-HOrUBlfVRz5W2LIKpXzZoy6VTZzMu2n8q9C2V/cFngIC5U1nStJgv0tMV4sZPzdf4wQm9/ToWUFPMN9Vq9VJQQ==", "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.34.0", - "@typescript-eslint/utils": "8.34.0", + "@typescript-eslint/typescript-estree": "8.35.1", + "@typescript-eslint/utils": "8.35.1", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -5017,9 +5136,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.34.0.tgz", - "integrity": "sha512-9V24k/paICYPniajHfJ4cuAWETnt7Ssy+R0Rbcqo5sSFr3QEZ/8TSoUi9XeXVBGXCaLtwTOKSLGcInCAvyZeMA==", + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.35.1.tgz", + "integrity": "sha512-q/O04vVnKHfrrhNAscndAn1tuQhIkwqnaW+eu5waD5IPts2eX1dgJxgqcPx5BX109/qAz7IG6VrEPTOYKCNfRQ==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5030,15 +5149,15 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.34.0.tgz", - "integrity": "sha512-rOi4KZxI7E0+BMqG7emPSK1bB4RICCpF7QD3KCLXn9ZvWoESsOMlHyZPAHyG04ujVplPaHbmEvs34m+wjgtVtg==", + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.35.1.tgz", + "integrity": "sha512-Vvpuvj4tBxIka7cPs6Y1uvM7gJgdF5Uu9F+mBJBPY4MhvjrjWGK4H0lVgLJd/8PWZ23FTqsaJaLEkBCFUk8Y9g==", "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.34.0", - "@typescript-eslint/tsconfig-utils": "8.34.0", - "@typescript-eslint/types": "8.34.0", - "@typescript-eslint/visitor-keys": "8.34.0", + "@typescript-eslint/project-service": "8.35.1", + "@typescript-eslint/tsconfig-utils": "8.35.1", + "@typescript-eslint/types": "8.35.1", + "@typescript-eslint/visitor-keys": "8.35.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -5110,15 +5229,15 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.34.0.tgz", - "integrity": "sha512-8L4tWatGchV9A1cKbjaavS6mwYwp39jql8xUmIIKJdm+qiaeHy5KMKlBrf30akXAWBzn2SqKsNOtSENWUwg7XQ==", + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.35.1.tgz", + "integrity": "sha512-lhnwatFmOFcazAsUm3ZnZFpXSxiwoa1Lj50HphnDe1Et01NF4+hrdXONSUHIcbVu2eFb1bAf+5yjXkGVkXBKAQ==", "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.34.0", - "@typescript-eslint/types": "8.34.0", - "@typescript-eslint/typescript-estree": "8.34.0" + "@typescript-eslint/scope-manager": "8.35.1", + "@typescript-eslint/types": "8.35.1", + "@typescript-eslint/typescript-estree": "8.35.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5133,13 +5252,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.34.0.tgz", - "integrity": "sha512-qHV7pW7E85A0x6qyrFn+O+q1k1p3tQCsqIZ1KZ5ESLXY57aTvUd3/a4rdPTeXisvhXn2VQG0VSKUqs8KHF2zcA==", + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.35.1.tgz", + "integrity": "sha512-VRwixir4zBWCSTP/ljEo091lbpypz57PoeAQ9imjG+vbeof9LplljsL1mos4ccG6H9IjfrVGM359RozUnuFhpw==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.34.0", - "eslint-visitor-keys": "^4.2.0" + "@typescript-eslint/types": "8.35.1", + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5150,9 +5269,9 @@ } }, "node_modules/@unrs/resolver-binding-android-arm-eabi": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.9.0.tgz", - "integrity": "sha512-h1T2c2Di49ekF2TE8ZCoJkb+jwETKUIPDJ/nO3tJBKlLFPu+fyd93f0rGP/BvArKx2k2HlRM4kqkNarj3dvZlg==", + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.10.1.tgz", + "integrity": "sha512-zohDKXT1Ok0yhbVGff4YAg9HUs5ietG5GpvJBPFSApZnGe7uf2cd26DRhKZbn0Be6xHUZrSzP+RAgMmzyc71EA==", "cpu": [ "arm" ], @@ -5163,9 +5282,9 @@ ] }, "node_modules/@unrs/resolver-binding-android-arm64": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.9.0.tgz", - "integrity": "sha512-sG1NHtgXtX8owEkJ11yn34vt0Xqzi3k9TJ8zppDmyG8GZV4kVWw44FHwKwHeEFl07uKPeC4ZoyuQaGh5ruJYPA==", + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.10.1.tgz", + "integrity": "sha512-tAN6k5UrTd4nicpA7s2PbjR/jagpDzAmvXFjbpTazUe5FRsFxVcBlS1F5Lzp5jtWU6bdiqRhSvd4X8rdpCffeA==", "cpu": [ "arm64" ], @@ -5176,9 +5295,9 @@ ] }, "node_modules/@unrs/resolver-binding-darwin-arm64": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.9.0.tgz", - "integrity": "sha512-nJ9z47kfFnCxN1z/oYZS7HSNsFh43y2asePzTEZpEvK7kGyuShSl3RRXnm/1QaqFL+iP+BjMwuB+DYUymOkA5A==", + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.10.1.tgz", + "integrity": "sha512-+FCsag8WkauI4dQ50XumCXdfvDCZEpMUnvZDsKMxfOisnEklpDFXc6ThY0WqybBYZbiwR5tWcFaZmI0G6b4vrg==", "cpu": [ "arm64" ], @@ -5189,9 +5308,9 @@ ] }, "node_modules/@unrs/resolver-binding-darwin-x64": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.9.0.tgz", - "integrity": "sha512-TK+UA1TTa0qS53rjWn7cVlEKVGz2B6JYe0C++TdQjvWYIyx83ruwh0wd4LRxYBM5HeuAzXcylA9BH2trARXJTw==", + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.10.1.tgz", + "integrity": "sha512-qYKGGm5wk71ONcXTMZ0+J11qQeOAPz3nw6VtqrBUUELRyXFyvK8cHhHsLBFR4GHnilc2pgY1HTB2TvdW9wO26Q==", "cpu": [ "x64" ], @@ -5202,9 +5321,9 @@ ] }, "node_modules/@unrs/resolver-binding-freebsd-x64": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.9.0.tgz", - "integrity": "sha512-6uZwzMRFcD7CcCd0vz3Hp+9qIL2jseE/bx3ZjaLwn8t714nYGwiE84WpaMCYjU+IQET8Vu/+BNAGtYD7BG/0yA==", + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.10.1.tgz", + "integrity": "sha512-hOHMAhbvIQ63gkpgeNsXcWPSyvXH7ZEyeg254hY0Lp/hX8NdW+FsUWq73g9946Pc/BrcVI/I3C1cmZ4RCX9bNw==", "cpu": [ "x64" ], @@ -5215,9 +5334,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.9.0.tgz", - "integrity": "sha512-bPUBksQfrgcfv2+mm+AZinaKq8LCFvt5PThYqRotqSuuZK1TVKkhbVMS/jvSRfYl7jr3AoZLYbDkItxgqMKRkg==", + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.10.1.tgz", + "integrity": "sha512-6ds7+zzHJgTDmpe0gmFcOTvSUhG5oZukkt+cCsSb3k4Uiz2yEQB4iCRITX2hBwSW+p8gAieAfecITjgqCkswXw==", "cpu": [ "arm" ], @@ -5228,9 +5347,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.9.0.tgz", - "integrity": "sha512-uT6E7UBIrTdCsFQ+y0tQd3g5oudmrS/hds5pbU3h4s2t/1vsGWbbSKhBSCD9mcqaqkBwoqlECpUrRJCmldl8PA==", + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.10.1.tgz", + "integrity": "sha512-P7A0G2/jW00diNJyFeq4W9/nxovD62Ay8CMP4UK9OymC7qO7rG1a8Upad68/bdfpIOn7KSp7Aj/6lEW3yyznAA==", "cpu": [ "arm" ], @@ -5241,9 +5360,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.9.0.tgz", - "integrity": "sha512-vdqBh911wc5awE2bX2zx3eflbyv8U9xbE/jVKAm425eRoOVv/VseGZsqi3A3SykckSpF4wSROkbQPvbQFn8EsA==", + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.10.1.tgz", + "integrity": "sha512-Cg6xzdkrpltcTPO4At+A79zkC7gPDQIgosJmVV8M104ImB6KZi1MrNXgDYIAfkhUYjPzjNooEDFRAwwPadS7ZA==", "cpu": [ "arm64" ], @@ -5254,9 +5373,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-arm64-musl": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.9.0.tgz", - "integrity": "sha512-/8JFZ/SnuDr1lLEVsxsuVwrsGquTvT51RZGvyDB/dOK3oYK2UqeXzgeyq6Otp8FZXQcEYqJwxb9v+gtdXn03eQ==", + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.10.1.tgz", + "integrity": "sha512-aNeg99bVkXa4lt+oZbjNRPC8ZpjJTKxijg/wILrJdzNyAymO2UC/HUK1UfDjt6T7U5p/mK24T3CYOi3/+YEQSA==", "cpu": [ "arm64" ], @@ -5267,9 +5386,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.9.0.tgz", - "integrity": "sha512-FkJjybtrl+rajTw4loI3L6YqSOpeZfDls4SstL/5lsP2bka9TiHUjgMBjygeZEis1oC8LfJTS8FSgpKPaQx2tQ==", + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.10.1.tgz", + "integrity": "sha512-ylz5ojeXrkPrtnzVhpCO+YegG63/aKhkoTlY8PfMfBfLaUG8v6m6iqrL7sBUKdVBgOB4kSTUPt9efQdA/Y3Z/w==", "cpu": [ "ppc64" ], @@ -5280,9 +5399,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.9.0.tgz", - "integrity": "sha512-w/NZfHNeDusbqSZ8r/hp8iL4S39h4+vQMc9/vvzuIKMWKppyUGKm3IST0Qv0aOZ1rzIbl9SrDeIqK86ZpUK37w==", + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.10.1.tgz", + "integrity": "sha512-xcWyhmJfXXOxK7lvE4+rLwBq+on83svlc0AIypfe6x4sMJR+S4oD7n9OynaQShfj2SufPw2KJAotnsNb+4nN2g==", "cpu": [ "riscv64" ], @@ -5293,9 +5412,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.9.0.tgz", - "integrity": "sha512-bEPBosut8/8KQbUixPry8zg/fOzVOWyvwzOfz0C0Rw6dp+wIBseyiHKjkcSyZKv/98edrbMknBaMNJfA/UEdqw==", + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.10.1.tgz", + "integrity": "sha512-mW9JZAdOCyorgi1eLJr4gX7xS67WNG9XNPYj5P8VuttK72XNsmdw9yhOO4tDANMgiLXFiSFaiL1gEpoNtRPw/A==", "cpu": [ "riscv64" ], @@ -5306,9 +5425,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.9.0.tgz", - "integrity": "sha512-LDtMT7moE3gK753gG4pc31AAqGUC86j3AplaFusc717EUGF9ZFJ356sdQzzZzkBk1XzMdxFyZ4f/i35NKM/lFA==", + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.10.1.tgz", + "integrity": "sha512-NZGKhBy6xkJ0k09cWNZz4DnhBcGlhDd3W+j7EYoNvf5TSwj2K6kbmfqTWITEgkvjsMUjm1wsrc4IJaH6VtjyHQ==", "cpu": [ "s390x" ], @@ -5319,9 +5438,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-x64-gnu": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.9.0.tgz", - "integrity": "sha512-WmFd5KINHIXj8o1mPaT8QRjA9HgSXhN1gl9Da4IZihARihEnOylu4co7i/yeaIpcfsI6sYs33cNZKyHYDh0lrA==", + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.10.1.tgz", + "integrity": "sha512-VsjgckJ0gNMw7p0d8In6uPYr+s0p16yrT2rvG4v2jUpEMYkpnfnCiALa9SWshbvlGjKQ98Q2x19agm3iFk8w8Q==", "cpu": [ "x64" ], @@ -5332,9 +5451,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-x64-musl": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.9.0.tgz", - "integrity": "sha512-CYuXbANW+WgzVRIl8/QvZmDaZxrqvOldOwlbUjIM4pQ46FJ0W5cinJ/Ghwa/Ng1ZPMJMk1VFdsD/XwmCGIXBWg==", + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.10.1.tgz", + "integrity": "sha512-idMnajMeejnaFi0Mx9UTLSYFDAOTfAEP7VjXNgxKApso3Eu2Njs0p2V95nNIyFi4oQVGFmIuCkoznAXtF/Zbmw==", "cpu": [ "x64" ], @@ -5345,9 +5464,9 @@ ] }, "node_modules/@unrs/resolver-binding-wasm32-wasi": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.9.0.tgz", - "integrity": "sha512-6Rp2WH0OoitMYR57Z6VE8Y6corX8C6QEMWLgOV6qXiJIeZ1F9WGXY/yQ8yDC4iTraotyLOeJ2Asea0urWj2fKQ==", + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.10.1.tgz", + "integrity": "sha512-7jyhjIRNFjzlr8x5pth6Oi9hv3a7ubcVYm2GBFinkBQKcFhw4nIs5BtauSNtDW1dPIGrxF0ciynCZqzxMrYMsg==", "cpu": [ "wasm32" ], @@ -5361,9 +5480,9 @@ } }, "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.9.0.tgz", - "integrity": "sha512-rknkrTRuvujprrbPmGeHi8wYWxmNVlBoNW8+4XF2hXUnASOjmuC9FNF1tGbDiRQWn264q9U/oGtixyO3BT8adQ==", + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.10.1.tgz", + "integrity": "sha512-TY79+N+Gkoo7E99K+zmsKNeiuNJYlclZJtKqsHSls8We2iGhgxtletVsiBYie93MSTDRDMI8pkBZJlIJSZPrdA==", "cpu": [ "arm64" ], @@ -5374,9 +5493,9 @@ ] }, "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.9.0.tgz", - "integrity": "sha512-Ceymm+iBl+bgAICtgiHyMLz6hjxmLJKqBim8tDzpX61wpZOx2bPK6Gjuor7I2RiUynVjvvkoRIkrPyMwzBzF3A==", + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.10.1.tgz", + "integrity": "sha512-BAJN5PEPlEV+1m8+PCtFoKm3LQ1P57B4Z+0+efU0NzmCaGk7pUaOxuPgl+m3eufVeeNBKiPDltG0sSB9qEfCxw==", "cpu": [ "ia32" ], @@ -5387,9 +5506,9 @@ ] }, "node_modules/@unrs/resolver-binding-win32-x64-msvc": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.9.0.tgz", - "integrity": "sha512-k59o9ZyeyS0hAlcaKFezYSH2agQeRFEB7KoQLXl3Nb3rgkqT1NY9Vwy+SqODiLmYnEjxWJVRE/yq2jFVqdIxZw==", + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.10.1.tgz", + "integrity": "sha512-2v3erKKmmCyIVvvhI2nF15qEbdBpISTq44m9pyd5gfIJB1PN94oePTLWEd82XUbIbvKhv76xTSeUQSCOGesLeg==", "cpu": [ "x64" ], @@ -5721,6 +5840,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1js": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.6.tgz", + "integrity": "sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -6047,9 +6180,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001722", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001722.tgz", - "integrity": "sha512-DCQHBBZtiK6JVkAGw7drvAMK0Q0POD/xZvEmDp6baiMMP6QXXk9HpD6mNYBZWhOPG6LvIDb82ITqtWjhDckHCA==", + "version": "1.0.30001726", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001726.tgz", + "integrity": "sha512-VQAUIUzBiZ/UnlM28fSp2CRF3ivUn1BWEvxMcVTNwpw91Py1pGbPIyIKtd+tzct9C3ouceCVdGAXxZOpZAsgdw==", "funding": [ { "type": "opencollective", @@ -6454,6 +6587,35 @@ "node": ">= 0.10" } }, + "node_modules/cross-fetch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", + "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, + "node_modules/cross-fetch/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -6801,9 +6963,9 @@ } }, "node_modules/dotenv": { - "version": "16.5.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", - "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -7029,9 +7191,9 @@ } }, "node_modules/end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "license": "MIT", "dependencies": { "once": "^1.4.0" @@ -7119,9 +7281,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.18.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", - "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", + "version": "5.18.2", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz", + "integrity": "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7552,9 +7714,9 @@ } }, "node_modules/eslint-module-utils": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz", - "integrity": "sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", "license": "MIT", "dependencies": { "debug": "^3.2.7" @@ -7578,29 +7740,29 @@ } }, "node_modules/eslint-plugin-import": { - "version": "2.31.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz", - "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "license": "MIT", "dependencies": { "@rtsao/scc": "^1.1.0", - "array-includes": "^3.1.8", - "array.prototype.findlastindex": "^1.2.5", - "array.prototype.flat": "^1.3.2", - "array.prototype.flatmap": "^1.3.2", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.12.0", + "eslint-module-utils": "^2.12.1", "hasown": "^2.0.2", - "is-core-module": "^2.15.1", + "is-core-module": "^2.16.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "object.groupby": "^1.0.3", - "object.values": "^1.2.0", + "object.values": "^1.2.1", "semver": "^6.3.1", - "string.prototype.trimend": "^1.0.8", + "string.prototype.trimend": "^1.0.9", "tsconfig-paths": "^3.15.0" }, "engines": { @@ -10244,9 +10406,9 @@ "license": "MIT" }, "node_modules/napi-postinstall": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.2.4.tgz", - "integrity": "sha512-ZEzHJwBhZ8qQSbknHqYcdtQVr8zUgGyM/q6h6qAyhtyVMNrSgDhrC4disf03dYW0e+czXyLnZINnCTEkWy0eJg==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.0.tgz", + "integrity": "sha512-M7NqKyhODKV1gRLdkwE7pDsZP2/SC2a2vHkOYh9MCpKMbWVfyVfUw5MaH83Fv6XMjxr5jryUp3IDDL9rlxsTeA==", "license": "MIT", "bin": { "napi-postinstall": "lib/cli.js" @@ -10328,9 +10490,9 @@ } }, "node_modules/next-intl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.1.0.tgz", - "integrity": "sha512-JNJRjc7sdnfUxhZmGcvzDszZ60tQKrygV/VLsgzXhnJDxQPn1cN2rVpc53adA1SvBJwPK2O6Sc6b4gYSILjCzw==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.3.4.tgz", + "integrity": "sha512-VWLIDlGbnL/o4LnveJTJD1NOYN8lh3ZAGTWw2krhfgg53as3VsS4jzUVnArJdqvwtlpU/2BIDbWTZ7V4o1jFEw==", "funding": [ { "type": "individual", @@ -10341,7 +10503,7 @@ "dependencies": { "@formatjs/intl-localematcher": "^0.5.4", "negotiator": "^1.0.0", - "use-intl": "^4.1.0" + "use-intl": "^4.3.4" }, "peerDependencies": { "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0", @@ -13140,12 +13302,12 @@ } }, "node_modules/openapi3-ts": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-4.4.0.tgz", - "integrity": "sha512-9asTNB9IkKEzWMcHmVZE7Ts3kC9G7AFHfs8i7caD8HbI76gEjdkId4z/AkP83xdZsH7PLAnnbl47qZkXuxpArw==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-4.5.0.tgz", + "integrity": "sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ==", "license": "MIT", "dependencies": { - "yaml": "^2.5.0" + "yaml": "^2.8.0" } }, "node_modules/optimist": { @@ -13712,22 +13874,22 @@ } }, "node_modules/pg": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.0.tgz", - "integrity": "sha512-7SKfdvP8CTNXjMUzfcVTaI+TDzBEeaUnVwiVGZQD1Hh33Kpev7liQba9uLd4CfN8r9mCVsD0JIpq03+Unpz+kg==", + "version": "8.16.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", + "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", "dependencies": { - "pg-connection-string": "^2.9.0", - "pg-pool": "^3.10.0", - "pg-protocol": "^1.10.0", + "pg-connection-string": "^2.9.1", + "pg-pool": "^3.10.1", + "pg-protocol": "^1.10.3", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "engines": { - "node": ">= 8.0.0" + "node": ">= 16.0.0" }, "optionalDependencies": { - "pg-cloudflare": "^1.2.5" + "pg-cloudflare": "^1.2.7" }, "peerDependencies": { "pg-native": ">=3.0.1" @@ -13739,16 +13901,16 @@ } }, "node_modules/pg-cloudflare": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.5.tgz", - "integrity": "sha512-OOX22Vt0vOSRrdoUPKJ8Wi2OpE/o/h9T8X1s4qSkCedbNah9ei2W2765be8iMVxQUsvgT7zIAT2eIa9fs5+vtg==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", + "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==", "license": "MIT", "optional": true }, "node_modules/pg-connection-string": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.0.tgz", - "integrity": "sha512-P2DEBKuvh5RClafLngkAuGe9OUlFV7ebu8w1kmaaOgPcpJd1RIFh7otETfI6hAR8YupOLFTY7nuvvIn7PLciUQ==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", + "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==", "license": "MIT" }, "node_modules/pg-int8": { @@ -13761,18 +13923,18 @@ } }, "node_modules/pg-pool": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.0.tgz", - "integrity": "sha512-DzZ26On4sQ0KmqnO34muPcmKbhrjmyiO4lCCR0VwEd7MjmiKf5NTg/6+apUEu0NF7ESa37CGzFxH513CoUmWnA==", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", + "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", "license": "MIT", "peerDependencies": { "pg": ">=8.0" } }, "node_modules/pg-protocol": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.0.tgz", - "integrity": "sha512-IpdytjudNuLv8nhlHs/UrVBhU0e78J0oIS/0AVdTbWxSOkFUVdsHC/NrorO6nXsQNDTT1kzDSOMJubBQviX18Q==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", + "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", "license": "MIT" }, "node_modules/pg-types": { @@ -13841,9 +14003,9 @@ } }, "node_modules/postcss": { - "version": "8.5.5", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.5.tgz", - "integrity": "sha512-d/jtm+rdNT8tpXuHY5MMtcbJFBkhXE6593XVR9UoGCH8jSFGci7jGvMGH5RYd5PBJW+00NZQt6gf7CbagJCrhg==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "dev": true, "funding": [ { @@ -13944,9 +14106,9 @@ } }, "node_modules/prettier": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", - "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" @@ -13998,9 +14160,9 @@ "license": "MIT" }, "node_modules/pump": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", - "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", @@ -14016,6 +14178,24 @@ "node": ">=6" } }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz", + "integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/qrcode.react": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", @@ -15499,9 +15679,9 @@ } }, "node_modules/swagger-ui-dist": { - "version": "5.24.1", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.24.1.tgz", - "integrity": "sha512-ITeWc7CCAfK53u8jnV39UNqStQZjSt+bVYtJHsOEL3vVj/WV9/8HmsF8Ej4oD8r+Xk1HpWyeW/t59r1QNeAcUQ==", + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.26.0.tgz", + "integrity": "sha512-U8m1LruHrk33gIIT5qDKhXMygT4FonRGBE92zMbxP4i9ULolPlKISy5Pd3RCES8pWdbGzXhvm/Q6jdA/HsrClg==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "=1.4.0" @@ -15533,9 +15713,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.10.tgz", - "integrity": "sha512-P3nr6WkvKV/ONsTzj6Gb57sWPMX29EPNPopo7+FcpkQaNsrNpZ1pv8QmrYI2RqEKD7mlGqLnGovlcYnBK0IqUA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz", + "integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==", "license": "MIT" }, "node_modules/tapable": { @@ -15643,6 +15823,12 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/triple-beam": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", @@ -15941,15 +16127,15 @@ } }, "node_modules/typescript-eslint": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.34.0.tgz", - "integrity": "sha512-MRpfN7uYjTrTGigFCt8sRyNqJFhjN0WwZecldaqhWm+wy0gaRt8Edb/3cuUy0zdq2opJWT6iXINKAtewnDOltQ==", + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.35.1.tgz", + "integrity": "sha512-xslJjFzhOmHYQzSB/QTeASAHbjmxOGEP6Coh93TXmUBFQoJ1VU35UHIDmG06Jd6taf3wqqC1ntBnCMeymy5Ovw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.34.0", - "@typescript-eslint/parser": "8.34.0", - "@typescript-eslint/utils": "8.34.0" + "@typescript-eslint/eslint-plugin": "8.35.1", + "@typescript-eslint/parser": "8.35.1", + "@typescript-eslint/utils": "8.35.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -15998,37 +16184,37 @@ } }, "node_modules/unrs-resolver": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.9.0.tgz", - "integrity": "sha512-wqaRu4UnzBD2ABTC1kLfBjAqIDZ5YUTr/MLGa7By47JV1bJDSW7jq/ZSLigB7enLe7ubNaJhtnBXgrc/50cEhg==", + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.10.1.tgz", + "integrity": "sha512-EFrL7Hw4kmhZdwWO3dwwFJo6hO3FXuQ6Bg8BK/faHZ9m1YxqBS31BNSTxklIQkxK/4LlV8zTYnPsIRLBzTzjCA==", "hasInstallScript": true, "license": "MIT", "dependencies": { - "napi-postinstall": "^0.2.2" + "napi-postinstall": "^0.3.0" }, "funding": { "url": "https://opencollective.com/unrs-resolver" }, "optionalDependencies": { - "@unrs/resolver-binding-android-arm-eabi": "1.9.0", - "@unrs/resolver-binding-android-arm64": "1.9.0", - "@unrs/resolver-binding-darwin-arm64": "1.9.0", - "@unrs/resolver-binding-darwin-x64": "1.9.0", - "@unrs/resolver-binding-freebsd-x64": "1.9.0", - "@unrs/resolver-binding-linux-arm-gnueabihf": "1.9.0", - "@unrs/resolver-binding-linux-arm-musleabihf": "1.9.0", - "@unrs/resolver-binding-linux-arm64-gnu": "1.9.0", - "@unrs/resolver-binding-linux-arm64-musl": "1.9.0", - "@unrs/resolver-binding-linux-ppc64-gnu": "1.9.0", - "@unrs/resolver-binding-linux-riscv64-gnu": "1.9.0", - "@unrs/resolver-binding-linux-riscv64-musl": "1.9.0", - "@unrs/resolver-binding-linux-s390x-gnu": "1.9.0", - "@unrs/resolver-binding-linux-x64-gnu": "1.9.0", - "@unrs/resolver-binding-linux-x64-musl": "1.9.0", - "@unrs/resolver-binding-wasm32-wasi": "1.9.0", - "@unrs/resolver-binding-win32-arm64-msvc": "1.9.0", - "@unrs/resolver-binding-win32-ia32-msvc": "1.9.0", - "@unrs/resolver-binding-win32-x64-msvc": "1.9.0" + "@unrs/resolver-binding-android-arm-eabi": "1.10.1", + "@unrs/resolver-binding-android-arm64": "1.10.1", + "@unrs/resolver-binding-darwin-arm64": "1.10.1", + "@unrs/resolver-binding-darwin-x64": "1.10.1", + "@unrs/resolver-binding-freebsd-x64": "1.10.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.10.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.10.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.10.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.10.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.10.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.10.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.10.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.10.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.10.1", + "@unrs/resolver-binding-linux-x64-musl": "1.10.1", + "@unrs/resolver-binding-wasm32-wasi": "1.10.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.10.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.10.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.10.1" } }, "node_modules/uri-js": { @@ -16062,9 +16248,9 @@ } }, "node_modules/use-intl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.1.0.tgz", - "integrity": "sha512-mQvDYFvoGn+bm/PWvlQOtluKCknsQ5a9F1Cj0hMfBjMBVTwnOqLPd6srhjvVdEQEQFVyHM1PfyifKqKYb11M9Q==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.3.4.tgz", + "integrity": "sha512-sHfiU0QeJ1rirNWRxvCyvlSh9+NczcOzRnPyMeo2rtHXhVnBsvMRjE+UG4eh3lRhCxrvcqei/I0lBxsc59on1w==", "license": "MIT", "dependencies": { "@formatjs/fast-memoize": "^2.2.0", @@ -16165,6 +16351,22 @@ "node": ">= 8" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", diff --git a/package.json b/package.json index 1b0d6620..229e47ab 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,8 @@ "@react-email/components": "0.0.41", "@react-email/render": "^1.1.2", "@react-email/tailwind": "1.0.5", + "@simplewebauthn/browser": "^13.1.0", + "@simplewebauthn/server": "^9.0.3", "@tailwindcss/forms": "^0.5.10", "@tanstack/react-table": "8.21.3", "arctic": "^3.7.0", @@ -97,7 +99,7 @@ "react-hook-form": "7.56.4", "react-icons": "^5.5.0", "rebuild": "0.1.2", - "semver": "7.7.2", + "semver": "^7.7.2", "swagger-ui-express": "^5.0.1", "tailwind-merge": "2.6.0", "tw-animate-css": "^1.3.3", @@ -106,9 +108,9 @@ "winston": "3.17.0", "winston-daily-rotate-file": "5.0.0", "ws": "8.18.2", + "yargs": "18.0.0", "zod": "3.25.56", - "zod-validation-error": "3.4.1", - "yargs": "18.0.0" + "zod-validation-error": "3.4.1" }, "devDependencies": { "@dotenvx/dotenvx": "1.44.1", @@ -119,6 +121,7 @@ "@types/cors": "2.8.19", "@types/crypto-js": "^4.2.2", "@types/express": "5.0.0", + "@types/express-session": "^1.18.2", "@types/jmespath": "^0.15.2", "@types/js-yaml": "4.0.9", "@types/jsonwebtoken": "^9.0.9", @@ -126,7 +129,7 @@ "@types/nodemailer": "6.4.17", "@types/react": "19.1.7", "@types/react-dom": "19.1.6", - "@types/semver": "7.7.0", + "@types/semver": "^7.7.0", "@types/swagger-ui-express": "^4.1.8", "@types/ws": "8.18.1", "@types/yargs": "17.0.33", diff --git a/server/db/pg/schema.ts b/server/db/pg/schema.ts index cb641974..05389d42 100644 --- a/server/db/pg/schema.ts +++ b/server/db/pg/schema.ts @@ -491,6 +491,16 @@ export const idpOrg = pgTable("idpOrg", { orgMapping: varchar("orgMapping") }); +export const webauthnChallenge = pgTable("webauthnChallenge", { + sessionId: varchar("sessionId").primaryKey(), + challenge: varchar("challenge").notNull(), + passkeyName: varchar("passkeyName"), + userId: varchar("userId").references(() => users.userId, { + onDelete: "cascade" + }), + expiresAt: bigint("expiresAt", { mode: "number" }).notNull() // Unix timestamp +}); + export type Org = InferSelectModel; export type User = InferSelectModel; export type Site = InferSelectModel; diff --git a/server/db/sqlite/driver.ts b/server/db/sqlite/driver.ts index 9a12b43d..51e3db08 100644 --- a/server/db/sqlite/driver.ts +++ b/server/db/sqlite/driver.ts @@ -13,6 +13,8 @@ bootstrapVolume(); function createDb() { const sqlite = new Database(location); + sqlite.pragma('foreign_keys = ON'); + sqlite.exec('VACUUM;'); // This will initialize the database file with a valid SQLite header return DrizzleSqlite(sqlite, { schema }); } diff --git a/server/db/sqlite/migrate.ts b/server/db/sqlite/migrate.ts index 20b9043f..10cec1a6 100644 --- a/server/db/sqlite/migrate.ts +++ b/server/db/sqlite/migrate.ts @@ -1,12 +1,20 @@ import { migrate } from "drizzle-orm/better-sqlite3/migrator"; import db from "./driver"; import path from "path"; +import { location } from "./driver"; +import Database from "better-sqlite3"; +import type { Database as BetterSqlite3Database } from "better-sqlite3"; const migrationsFolder = path.join("server/migrations"); const runMigrations = async () => { console.log("Running migrations..."); try { + // Initialize the database file with a valid SQLite header + const sqlite = new Database(location) as BetterSqlite3Database; + sqlite.pragma('foreign_keys = ON'); + + // Run the migrations migrate(db as any, { migrationsFolder: migrationsFolder, }); diff --git a/server/db/sqlite/schema.ts b/server/db/sqlite/schema.ts index b587d1c7..1a6dadef 100644 --- a/server/db/sqlite/schema.ts +++ b/server/db/sqlite/schema.ts @@ -135,6 +135,29 @@ export const users = sqliteTable("user", { .default(false) }); +export const passkeys = sqliteTable("webauthnCredentials", { + credentialId: text("credentialId").primaryKey(), + userId: text("userId").notNull().references(() => users.userId, { + onDelete: "cascade" + }), + publicKey: text("publicKey").notNull(), + signCount: integer("signCount").notNull(), + transports: text("transports"), + name: text("name"), + lastUsed: text("lastUsed").notNull(), + dateCreated: text("dateCreated").notNull() +}); + +export const webauthnChallenge = sqliteTable("webauthnChallenge", { + sessionId: text("sessionId").primaryKey(), + challenge: text("challenge").notNull(), + passkeyName: text("passkeyName"), + userId: text("userId").references(() => users.userId, { + onDelete: "cascade" + }), + expiresAt: integer("expiresAt").notNull() // Unix timestamp +}); + export const newts = sqliteTable("newt", { newtId: text("id").primaryKey(), secretHash: text("secretHash").notNull(), diff --git a/server/index.ts b/server/index.ts index 4daeb711..a07bbc93 100644 --- a/server/index.ts +++ b/server/index.ts @@ -35,7 +35,7 @@ declare global { interface Request { apiKey?: ApiKey; user?: User; - session?: Session; + session: Session; userOrg?: UserOrg; apiKeyOrg?: ApiKeyOrg; userOrgRoleId?: number; diff --git a/server/routers/auth/index.ts b/server/routers/auth/index.ts index 6955e16c..4c7014a4 100644 --- a/server/routers/auth/index.ts +++ b/server/routers/auth/index.ts @@ -12,3 +12,4 @@ export * from "./resetPassword"; export * from "./checkResourceSession"; export * from "./setServerAdmin"; export * from "./initialSetupComplete"; +export * from "./passkey"; diff --git a/server/routers/auth/passkey.ts b/server/routers/auth/passkey.ts new file mode 100644 index 00000000..ebe1a4e5 --- /dev/null +++ b/server/routers/auth/passkey.ts @@ -0,0 +1,606 @@ +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { fromError } from "zod-validation-error"; +import { z } from "zod"; +import { db } from "@server/db"; +import { User, passkeys, users, webauthnChallenge } from "@server/db"; +import { eq, and, lt } from "drizzle-orm"; +import { response } from "@server/lib"; +import logger from "@server/logger"; +import { + generateRegistrationOptions, + verifyRegistrationResponse, + generateAuthenticationOptions, + verifyAuthenticationResponse +} from "@simplewebauthn/server"; +import type { + GenerateRegistrationOptionsOpts, + VerifyRegistrationResponseOpts, + GenerateAuthenticationOptionsOpts, + VerifyAuthenticationResponseOpts, + VerifiedRegistrationResponse, + VerifiedAuthenticationResponse +} from "@simplewebauthn/server"; +import config from "@server/lib/config"; +import { UserType } from "@server/types/UserTypes"; + +// The RP ID is the domain name of your application +const rpID = new URL(config.getRawConfig().app.dashboard_url).hostname; +const rpName = "Pangolin"; +const origin = config.getRawConfig().app.dashboard_url; + +// Database-based challenge storage (replaces in-memory storage) +// Challenges are stored in the webauthnChallenge table with automatic expiration +// This supports clustered deployments and persists across server restarts + +// Clean up expired challenges every 5 minutes +setInterval(async () => { + try { + const now = Date.now(); + await db + .delete(webauthnChallenge) + .where(lt(webauthnChallenge.expiresAt, now)); + logger.debug("Cleaned up expired passkey challenges"); + } catch (error) { + logger.error("Failed to clean up expired passkey challenges", error); + } +}, 5 * 60 * 1000); + +// Helper functions for challenge management +async function storeChallenge(sessionId: string, challenge: string, passkeyName?: string, userId?: string) { + const expiresAt = Date.now() + (10 * 60 * 1000); // 10 minutes + + // Delete any existing challenge for this session + await db.delete(webauthnChallenge).where(eq(webauthnChallenge.sessionId, sessionId)); + + // Insert new challenge + await db.insert(webauthnChallenge).values({ + sessionId, + challenge, + passkeyName, + userId, + expiresAt + }); +} + +async function getChallenge(sessionId: string) { + const [challengeData] = await db + .select() + .from(webauthnChallenge) + .where(eq(webauthnChallenge.sessionId, sessionId)) + .limit(1); + + if (!challengeData) { + return null; + } + + // Check if expired + if (challengeData.expiresAt < Date.now()) { + await db.delete(webauthnChallenge).where(eq(webauthnChallenge.sessionId, sessionId)); + return null; + } + + return challengeData; +} + +async function clearChallenge(sessionId: string) { + await db.delete(webauthnChallenge).where(eq(webauthnChallenge.sessionId, sessionId)); +} + +export const registerPasskeyBody = z.object({ + name: z.string().min(1) +}).strict(); + +export const verifyRegistrationBody = z.object({ + credential: z.any() +}).strict(); + +export const startAuthenticationBody = z.object({ + email: z.string().email().optional() +}).strict(); + +export const verifyAuthenticationBody = z.object({ + credential: z.any() +}).strict(); + +export async function startRegistration( + req: Request, + res: Response, + next: NextFunction +): Promise { + const parsedBody = registerPasskeyBody.safeParse(req.body); + + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { name } = parsedBody.data; + const user = req.user as User; + + // Only allow internal users to use passkeys + if (user.type !== UserType.Internal) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Passkeys are only available for internal users" + ) + ); + } + + try { + // Get existing passkeys for user + const existingPasskeys = await db + .select() + .from(passkeys) + .where(eq(passkeys.userId, user.userId)); + + const excludeCredentials = existingPasskeys.map(key => ({ + id: Buffer.from(key.credentialId, 'base64'), + type: 'public-key' as const, + transports: key.transports ? JSON.parse(key.transports) : undefined + })); + + const options: GenerateRegistrationOptionsOpts = { + rpName, + rpID, + userID: user.userId, + userName: user.email || user.username, + attestationType: 'none', + excludeCredentials, + authenticatorSelection: { + residentKey: 'preferred', + userVerification: 'preferred', + } + }; + + const registrationOptions = await generateRegistrationOptions(options); + + // Store challenge in database + await storeChallenge(req.session.sessionId, registrationOptions.challenge, name, user.userId); + + return response(res, { + data: registrationOptions, + success: true, + error: false, + message: "Registration options generated", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to generate registration options" + ) + ); + } +} + +export async function verifyRegistration( + req: Request, + res: Response, + next: NextFunction +): Promise { + const parsedBody = verifyRegistrationBody.safeParse(req.body); + + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { credential } = parsedBody.data; + const user = req.user as User; + + // Only allow internal users to use passkeys + if (user.type !== UserType.Internal) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Passkeys are only available for internal users" + ) + ); + } + + try { + // Get challenge from database + const challengeData = await getChallenge(req.session.sessionId); + + if (!challengeData) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "No challenge found in session or challenge expired" + ) + ); + } + + const verification = await verifyRegistrationResponse({ + response: credential, + expectedChallenge: challengeData.challenge, + expectedOrigin: origin, + expectedRPID: rpID, + requireUserVerification: false + }); + + const { verified, registrationInfo } = verification; + + if (!verified || !registrationInfo) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Verification failed" + ) + ); + } + + // Store the passkey in the database + await db.insert(passkeys).values({ + credentialId: Buffer.from(registrationInfo.credentialID).toString('base64'), + userId: user.userId, + publicKey: Buffer.from(registrationInfo.credentialPublicKey).toString('base64'), + signCount: registrationInfo.counter || 0, + transports: credential.response.transports ? JSON.stringify(credential.response.transports) : null, + name: challengeData.passkeyName, + lastUsed: new Date().toISOString(), + dateCreated: new Date().toISOString() + }); + + // Clear challenge data + await clearChallenge(req.session.sessionId); + + return response(res, { + data: null, + success: true, + error: false, + message: "Passkey registered successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to verify registration" + ) + ); + } +} + +export async function listPasskeys( + req: Request, + res: Response, + next: NextFunction +): Promise { + const user = req.user as User; + + // Only allow internal users to use passkeys + if (user.type !== UserType.Internal) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Passkeys are only available for internal users" + ) + ); + } + + try { + const userPasskeys = await db + .select() + .from(passkeys) + .where(eq(passkeys.userId, user.userId)); + + return response(res, { + data: userPasskeys, + success: true, + error: false, + message: "Passkeys retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to retrieve passkeys" + ) + ); + } +} + +export async function deletePasskey( + req: Request, + res: Response, + next: NextFunction +): Promise { + const { credentialId: encodedCredentialId } = req.params; + const credentialId = decodeURIComponent(encodedCredentialId); + const user = req.user as User; + + // Only allow internal users to use passkeys + if (user.type !== UserType.Internal) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Passkeys are only available for internal users" + ) + ); + } + + try { + await db + .delete(passkeys) + .where(and( + eq(passkeys.credentialId, credentialId), + eq(passkeys.userId, user.userId) + )); + + return response(res, { + data: null, + success: true, + error: false, + message: "Passkey deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to delete passkey" + ) + ); + } +} + +export async function startAuthentication( + req: Request, + res: Response, + next: NextFunction +): Promise { + const parsedBody = startAuthenticationBody.safeParse(req.body); + + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { email } = parsedBody.data; + + try { + let allowCredentials: Array<{ + id: Buffer; + type: 'public-key'; + transports?: string[]; + }> = []; + let userId; + + // If email is provided, get passkeys for that specific user + if (email) { + const [user] = await db + .select() + .from(users) + .where(eq(users.email, email)) + .limit(1); + + if (!user || user.type !== UserType.Internal) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "No passkeys available for this user" + ) + ); + } + + userId = user.userId; + + const userPasskeys = await db + .select() + .from(passkeys) + .where(eq(passkeys.userId, user.userId)); + + if (userPasskeys.length === 0) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "No passkeys registered for this user" + ) + ); + } + + allowCredentials = userPasskeys.map(key => ({ + id: Buffer.from(key.credentialId, 'base64'), + type: 'public-key' as const, + transports: key.transports ? JSON.parse(key.transports) : undefined + })); + } else { + // If no email provided, allow any passkey (for resident key authentication) + allowCredentials = []; + } + + const options: GenerateAuthenticationOptionsOpts = { + rpID, + allowCredentials, + userVerification: 'preferred', + }; + + const authenticationOptions = await generateAuthenticationOptions(options); + + // Generate a temporary session ID for unauthenticated users + const tempSessionId = email ? `temp_${email}_${Date.now()}` : `temp_${Date.now()}`; + + // Store challenge in database + await storeChallenge(tempSessionId, authenticationOptions.challenge, undefined, userId); + + return response(res, { + data: { ...authenticationOptions, tempSessionId }, + success: true, + error: false, + message: "Authentication options generated", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to generate authentication options" + ) + ); + } +} + +export async function verifyAuthentication( + req: Request, + res: Response, + next: NextFunction +): Promise { + const parsedBody = verifyAuthenticationBody.safeParse(req.body); + + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { credential } = parsedBody.data; + const tempSessionId = req.headers['x-temp-session-id'] as string; + + if (!tempSessionId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Missing temp session ID" + ) + ); + } + + try { + // Get challenge from database + const challengeData = await getChallenge(tempSessionId); + + if (!challengeData) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "No challenge found or challenge expired" + ) + ); + } + + // Find the passkey in database + const credentialId = Buffer.from(credential.id, 'base64').toString('base64'); + const [passkey] = await db + .select() + .from(passkeys) + .where(eq(passkeys.credentialId, credentialId)) + .limit(1); + + if (!passkey) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Passkey not found" + ) + ); + } + + // Get the user + const [user] = await db + .select() + .from(users) + .where(eq(users.userId, passkey.userId)) + .limit(1); + + if (!user || user.type !== UserType.Internal) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "User not found or not authorized for passkey authentication" + ) + ); + } + + const verification = await verifyAuthenticationResponse({ + response: credential, + expectedChallenge: challengeData.challenge, + expectedOrigin: origin, + expectedRPID: rpID, + authenticator: { + credentialID: Buffer.from(passkey.credentialId, 'base64'), + credentialPublicKey: Buffer.from(passkey.publicKey, 'base64'), + counter: passkey.signCount, + transports: passkey.transports ? JSON.parse(passkey.transports) : undefined + }, + requireUserVerification: false + }); + + const { verified, authenticationInfo } = verification; + + if (!verified) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Authentication failed" + ) + ); + } + + // Update sign count + await db + .update(passkeys) + .set({ + signCount: authenticationInfo.newCounter, + lastUsed: new Date().toISOString() + }) + .where(eq(passkeys.credentialId, credentialId)); + + // Create session for the user + const { createSession, generateSessionToken, serializeSessionCookie } = await import("@server/auth/sessions/app"); + const token = generateSessionToken(); + const session = await createSession(token, user.userId); + const isSecure = req.protocol === "https"; + const cookie = serializeSessionCookie( + token, + isSecure, + new Date(session.expiresAt) + ); + + res.setHeader("Set-Cookie", cookie); + + // Clear challenge data + await clearChallenge(tempSessionId); + + return response(res, { + data: null, + success: true, + error: false, + message: "Authentication successful", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to verify authentication" + ) + ); + } +} \ No newline at end of file diff --git a/server/routers/external.ts b/server/routers/external.ts index 8cb3a19d..1eebb531 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -788,3 +788,36 @@ authRouter.post("/idp/:idpId/oidc/validate-callback", idp.validateOidcCallback); authRouter.put("/set-server-admin", auth.setServerAdmin); authRouter.get("/initial-setup-complete", auth.initialSetupComplete); + +// Passkey routes +authRouter.post( + "/passkey/register/start", + rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 5, // Allow 5 passkey registrations per 15 minutes per IP + keyGenerator: (req) => `passkeyRegister:${req.ip}:${req.user?.userId}`, + handler: (req, res, next) => { + const message = `You can only register ${5} passkeys every ${15} minutes. Please try again later.`; + return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); + } + }), + verifySessionUserMiddleware, + auth.startRegistration +); +authRouter.post("/passkey/register/verify", verifySessionUserMiddleware, auth.verifyRegistration); +authRouter.post( + "/passkey/authenticate/start", + rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 10, // Allow 10 authentication attempts per 15 minutes per IP + keyGenerator: (req) => `passkeyAuth:${req.ip}`, + handler: (req, res, next) => { + const message = `You can only attempt passkey authentication ${10} times every ${15} minutes. Please try again later.`; + return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); + } + }), + auth.startAuthentication +); +authRouter.post("/passkey/authenticate/verify", auth.verifyAuthentication); +authRouter.get("/passkey/list", verifySessionUserMiddleware, auth.listPasskeys); +authRouter.delete("/passkey/:credentialId", verifySessionUserMiddleware, auth.deletePasskey); diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts index 1e279bae..aa6649ea 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -22,6 +22,8 @@ import m18 from "./scriptsSqlite/1.2.0"; import m19 from "./scriptsSqlite/1.3.0"; import m20 from "./scriptsSqlite/1.5.0"; import m21 from "./scriptsSqlite/1.6.0"; +import m22 from "./scriptsSqlite/1.7.0"; +import m23 from "./scriptsSqlite/1.8.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -43,7 +45,9 @@ const migrations = [ { version: "1.2.0", run: m18 }, { version: "1.3.0", run: m19 }, { version: "1.5.0", run: m20 }, - { version: "1.6.0", run: m21 } + { version: "1.6.0", run: m21 }, + { version: "1.7.0", run: m22 }, + { version: "1.8.0", run: m23 } // Add new migrations here as they are created ] as const; @@ -79,17 +83,21 @@ export async function runMigrations() { try { const appVersion = APP_VERSION; - if (exists) { + // Check if the database file exists and has tables + const hasTables = await db.select().from(versionMigrations).limit(1).catch(() => false); + + if (hasTables) { await executeScripts(); } else { - console.log("Running migrations..."); + console.log("Running initial migrations..."); try { migrate(db, { - migrationsFolder: path.join(__DIRNAME, "init") // put here during the docker build + migrationsFolder: path.join(APP_PATH, "server", "migrations") }); - console.log("Migrations completed successfully."); + console.log("Initial migrations completed successfully."); } catch (error) { - console.error("Error running migrations:", error); + console.error("Error running initial migrations:", error); + throw error; } await db diff --git a/server/setup/scriptsSqlite/1.4.0.ts b/server/setup/scriptsSqlite/1.4.0.ts new file mode 100644 index 00000000..e961b287 --- /dev/null +++ b/server/setup/scriptsSqlite/1.4.0.ts @@ -0,0 +1,31 @@ +import { db } from "../../db/sqlite"; +import { sql } from "drizzle-orm"; + +const version = "1.4.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + try { + db.transaction((trx) => { + trx.run(sql`CREATE TABLE 'passkey' ( + 'credentialId' text PRIMARY KEY NOT NULL, + 'userId' text NOT NULL, + 'publicKey' text NOT NULL, + 'signCount' integer NOT NULL, + 'transports' text, + 'name' text, + 'lastUsed' text NOT NULL, + 'dateCreated' text NOT NULL, + FOREIGN KEY ('userId') REFERENCES 'user'('id') ON DELETE CASCADE + );`); + }); + + console.log(`Migrated database schema`); + } catch (e) { + console.log("Unable to migrate database schema"); + throw e; + } + + console.log(`${version} migration complete`); +} \ No newline at end of file diff --git a/server/setup/scriptsSqlite/1.7.0.ts b/server/setup/scriptsSqlite/1.7.0.ts new file mode 100644 index 00000000..153d3724 --- /dev/null +++ b/server/setup/scriptsSqlite/1.7.0.ts @@ -0,0 +1,39 @@ +import { APP_PATH } from "@server/lib/consts"; +import Database from "better-sqlite3"; +import path from "path"; + +const version = "1.7.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + const location = path.join(APP_PATH, "db", "db.sqlite"); + const db = new Database(location); + + try { + db.pragma("foreign_keys = OFF"); + db.transaction(() => { + db.exec(` + CREATE TABLE IF NOT EXISTS passkey ( + credentialId TEXT PRIMARY KEY, + userId TEXT NOT NULL, + publicKey TEXT NOT NULL, + signCount INTEGER NOT NULL, + transports TEXT, + name TEXT, + lastUsed TEXT NOT NULL, + dateCreated TEXT NOT NULL, + FOREIGN KEY (userId) REFERENCES user(id) ON DELETE CASCADE + ); + `); + })(); // executes the transaction immediately + db.pragma("foreign_keys = ON"); + console.log(`Created passkey table`); + } catch (e) { + console.error("Unable to create passkey table"); + console.error(e); + throw e; + } + + console.log(`${version} migration complete`); +} \ No newline at end of file diff --git a/server/setup/scriptsSqlite/1.8.0.ts b/server/setup/scriptsSqlite/1.8.0.ts new file mode 100644 index 00000000..19ff4052 --- /dev/null +++ b/server/setup/scriptsSqlite/1.8.0.ts @@ -0,0 +1,38 @@ +import { APP_PATH } from "@server/lib/consts"; +import Database from "better-sqlite3"; +import path from "path"; + +const version = "1.8.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + const location = path.join(APP_PATH, "db", "db.sqlite"); + const db = new Database(location); + + try { + db.pragma("foreign_keys = OFF"); + db.transaction(() => { + db.exec(` + CREATE TABLE IF NOT EXISTS passkeyChallenge ( + sessionId TEXT PRIMARY KEY, + challenge TEXT NOT NULL, + passkeyName TEXT, + userId TEXT, + expiresAt INTEGER NOT NULL, + FOREIGN KEY (userId) REFERENCES user(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_passkeyChallenge_expiresAt ON passkeyChallenge(expiresAt); + `); + })(); // executes the transaction immediately + db.pragma("foreign_keys = ON"); + console.log(`Created passkeyChallenge table`); + } catch (e) { + console.error("Unable to create passkeyChallenge table"); + console.error(e); + throw e; + } + + console.log(`${version} migration complete`); +} \ No newline at end of file diff --git a/server/setup/scriptsSqlite/1.8.1.ts b/server/setup/scriptsSqlite/1.8.1.ts new file mode 100644 index 00000000..473a354c --- /dev/null +++ b/server/setup/scriptsSqlite/1.8.1.ts @@ -0,0 +1,27 @@ +import { db } from "@server/db"; + +export default async function migrate() { + try { + console.log("Starting table rename migration..."); + + // Rename the table + await db.run(` + ALTER TABLE passkeyChallenge RENAME TO webauthnChallenge; + `); + console.log("Successfully renamed table"); + + // Rename the index + await db.run(` + DROP INDEX IF EXISTS idx_passkeyChallenge_expiresAt; + CREATE INDEX IF NOT EXISTS idx_webauthnChallenge_expiresAt ON webauthnChallenge(expiresAt); + `); + console.log("Successfully updated index"); + + console.log(`Renamed passkeyChallenge table to webauthnChallenge`); + return true; + } catch (error: any) { + console.error("Unable to rename passkeyChallenge table:", error); + console.error("Error details:", error.message); + return false; + } +} \ No newline at end of file diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index 14189c37..d2650f43 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -26,7 +26,7 @@ import { LoginResponse } from "@server/routers/auth"; import { useRouter } from "next/navigation"; import { AxiosResponse } from "axios"; import { formatAxiosError } from "@app/lib/api"; -import { LockIcon } from "lucide-react"; +import { LockIcon, FingerprintIcon } from "lucide-react"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { @@ -41,6 +41,7 @@ import Image from "next/image"; import { GenerateOidcUrlResponse } from "@server/routers/idp"; import { Separator } from "./ui/separator"; import { useTranslations } from "next-intl"; +import { startAuthentication } from "@simplewebauthn/browser"; export type LoginFormIDP = { idpId: number; @@ -165,6 +166,52 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { } } + async function loginWithPasskey() { + try { + setLoading(true); + setError(null); + + const email = form.getValues().email; + + // Start passkey authentication + const startRes = await api.post("/auth/passkey/authenticate/start", { + email: email || undefined + }); + + if (!startRes) { + setError(t('passkeyAuthError')); + return; + } + + const { tempSessionId, ...options } = startRes.data.data; + + // Perform passkey authentication + const credential = await startAuthentication(options); + + // Verify authentication + const verifyRes = await api.post( + "/auth/passkey/authenticate/verify", + { credential }, + { + headers: { + 'X-Temp-Session-Id': tempSessionId + } + } + ); + + if (verifyRes) { + if (onLogin) { + await onLogin(); + } + } + } catch (e) { + console.error(e); + setError(formatAxiosError(e, t('passkeyAuthError'))); + } finally { + setLoading(false); + } + } + return (
{!mfaRequested && ( @@ -321,6 +368,18 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { {t('login')} + + {hasIdp && ( <>
diff --git a/src/components/PasskeyForm.tsx b/src/components/PasskeyForm.tsx new file mode 100644 index 00000000..66abc844 --- /dev/null +++ b/src/components/PasskeyForm.tsx @@ -0,0 +1,234 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useUserContext } from "@app/hooks/useUserContext"; +import { useTranslations } from "next-intl"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { formatAxiosError } from "@app/lib/api"; +import { startRegistration } from "@simplewebauthn/browser"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; + +type PasskeyFormProps = { + open: boolean; + setOpen: (val: boolean) => void; +}; + +type Passkey = { + credentialId: string; + name: string; + dateCreated: string; + lastUsed: string; +}; + +export default function PasskeyForm({ open, setOpen }: PasskeyFormProps) { + const [loading, setLoading] = useState(false); + const [passkeys, setPasskeys] = useState([]); + const { user } = useUserContext(); + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + + const registerSchema = z.object({ + name: z.string().min(1, { message: t('passkeyNameRequired') }) + }); + + const form = useForm>({ + resolver: zodResolver(registerSchema), + defaultValues: { + name: "" + } + }); + + useEffect(() => { + if (open) { + loadPasskeys(); + } + }, [open]); + + const loadPasskeys = async () => { + try { + const response = await api.get("/auth/passkey/list"); + setPasskeys(response.data.data); + } catch (error) { + toast({ + title: "Error", + description: formatAxiosError(error, t('passkeyLoadError')), + variant: "destructive" + }); + } + }; + + const handleRegisterPasskey = async (values: z.infer) => { + try { + setLoading(true); + + // Start registration + const startRes = await api.post("/auth/passkey/register/start", { + name: values.name + }); + const options = startRes.data.data; + + // Create passkey + const credential = await startRegistration(options); + + // Verify registration + await api.post("/auth/passkey/register/verify", { + credential + }); + + toast({ + title: "Success", + description: t('passkeyRegisterSuccess') + }); + + // Reload passkeys + await loadPasskeys(); + form.reset(); + } catch (error) { + toast({ + title: "Error", + description: formatAxiosError(error, t('passkeyRegisterError')), + variant: "destructive" + }); + } finally { + setLoading(false); + } + }; + + const handleDeletePasskey = async (credentialId: string) => { + try { + setLoading(true); + const encodedCredentialId = encodeURIComponent(credentialId); + await api.delete(`/auth/passkey/${encodedCredentialId}`); + + toast({ + title: "Success", + description: t('passkeyRemoveSuccess') + }); + + // Reload passkeys + await loadPasskeys(); + } catch (error) { + toast({ + title: "Error", + description: formatAxiosError(error, t('passkeyRemoveError')), + variant: "destructive" + }); + } finally { + setLoading(false); + } + }; + + return ( + + + + {t('passkeyManage')} + + {t('passkeyDescription')} + + + +
+
+

{t('passkeyList')}

+ {passkeys.length === 0 ? ( +

+ {t('passkeyNone')} +

+ ) : ( +
+ {passkeys.map((passkey) => ( +
+
+

{passkey.name}

+

+ {t('passkeyLastUsed', { + date: new Date(passkey.lastUsed).toLocaleDateString() + })} +

+
+ +
+ ))} +
+ )} +
+ +
+

{t('passkeyRegister')}

+
+ + ( + + {t('passkeyNameLabel')} + + + + + + )} + /> + + + +
+
+
+ + + + + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/ProfileIcon.tsx b/src/components/ProfileIcon.tsx index eab2f51d..cbed2c96 100644 --- a/src/components/ProfileIcon.tsx +++ b/src/components/ProfileIcon.tsx @@ -21,12 +21,12 @@ import { useState } from "react"; import { useUserContext } from "@app/hooks/useUserContext"; import Disable2FaForm from "./Disable2FaForm"; import Enable2FaForm from "./Enable2FaForm"; +import PasskeyForm from "./PasskeyForm"; import SupporterStatus from "./SupporterStatus"; import { UserType } from "@server/types/UserTypes"; import LocaleSwitcher from '@app/components/LocaleSwitcher'; import { useTranslations } from "next-intl"; - export default function ProfileIcon() { const { setTheme, theme } = useTheme(); const { env } = useEnvContext(); @@ -40,6 +40,7 @@ export default function ProfileIcon() { const [openEnable2fa, setOpenEnable2fa] = useState(false); const [openDisable2fa, setOpenDisable2fa] = useState(false); + const [openPasskey, setOpenPasskey] = useState(false); const t = useTranslations(); @@ -73,6 +74,7 @@ export default function ProfileIcon() { <> +
@@ -130,6 +132,11 @@ export default function ProfileIcon() { {t('otpDisable')} )} + setOpenPasskey(true)} + > + {t('passkeyManage')} + )} From f31717145f54bbcbf86f52ab9215ecfcdd766db7 Mon Sep 17 00:00:00 2001 From: Adrian Astles Date: Thu, 3 Jul 2025 22:57:29 +0800 Subject: [PATCH 02/12] feat(passkeys): Add password verification for passkey management - Add password verification requirement when registering passkeys - Add password verification requirement when deleting passkeys - Add support for 2FA verification if enabled - Add new delete confirmation dialog with password field - Add recommendation message when only one passkey is registered - Improve dialog styling and user experience - Fix type issues with WebAuthn credential descriptors Security: This change ensures that sensitive passkey operations require password verification, similar to 2FA management, preventing unauthorized modifications to authentication methods. --- messages/en-US.json | 3 +- server/routers/auth/passkey.ts | 78 +++++++- src/components/PasskeyForm.tsx | 336 +++++++++++++++++++++++++-------- 3 files changed, 328 insertions(+), 89 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 36927b51..9e53388d 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1149,5 +1149,6 @@ "passkeyRemoveError": "Failed to remove passkey", "passkeyLoadError": "Failed to load passkeys", "passkeyLogin": "Login with Passkey", - "passkeyAuthError": "Failed to authenticate with passkey" + "passkeyAuthError": "Failed to authenticate with passkey", + "passkeyRecommendation": "Consider registering another passkey on a different device to ensure you don't get locked out of your account." } diff --git a/server/routers/auth/passkey.ts b/server/routers/auth/passkey.ts index ebe1a4e5..07080fc6 100644 --- a/server/routers/auth/passkey.ts +++ b/server/routers/auth/passkey.ts @@ -22,8 +22,14 @@ import type { VerifiedRegistrationResponse, VerifiedAuthenticationResponse } from "@simplewebauthn/server"; +import type { + AuthenticatorTransport, + PublicKeyCredentialDescriptorJSON +} from "@simplewebauthn/types"; import config from "@server/lib/config"; import { UserType } from "@server/types/UserTypes"; +import { verifyPassword } from "@server/auth/password"; +import { unauthorized } from "@server/auth/unauthorizedResponse"; // The RP ID is the domain name of your application const rpID = new URL(config.getRawConfig().app.dashboard_url).hostname; @@ -89,7 +95,8 @@ async function clearChallenge(sessionId: string) { } export const registerPasskeyBody = z.object({ - name: z.string().min(1) + name: z.string().min(1), + password: z.string().min(1) }).strict(); export const verifyRegistrationBody = z.object({ @@ -104,6 +111,10 @@ export const verifyAuthenticationBody = z.object({ credential: z.any() }).strict(); +export const deletePasskeyBody = z.object({ + password: z.string().min(1) +}).strict(); + export async function startRegistration( req: Request, res: Response, @@ -120,7 +131,7 @@ export async function startRegistration( ); } - const { name } = parsedBody.data; + const { name, password } = parsedBody.data; const user = req.user as User; // Only allow internal users to use passkeys @@ -134,6 +145,23 @@ export async function startRegistration( } try { + // Verify password + const validPassword = await verifyPassword(password, user.passwordHash!); + if (!validPassword) { + return next(unauthorized()); + } + + // If user has 2FA enabled, require a code + if (user.twoFactorEnabled) { + return response<{ codeRequested: boolean }>(res, { + data: { codeRequested: true }, + success: true, + error: false, + message: "Two-factor authentication required", + status: HttpCode.ACCEPTED + }); + } + // Get existing passkeys for user const existingPasskeys = await db .select() @@ -141,10 +169,10 @@ export async function startRegistration( .where(eq(passkeys.userId, user.userId)); const excludeCredentials = existingPasskeys.map(key => ({ - id: Buffer.from(key.credentialId, 'base64'), + id: Buffer.from(key.credentialId, 'base64').toString('base64url'), type: 'public-key' as const, - transports: key.transports ? JSON.parse(key.transports) : undefined - })); + transports: key.transports ? JSON.parse(key.transports) as AuthenticatorTransport[] : undefined + } satisfies PublicKeyCredentialDescriptorJSON)); const options: GenerateRegistrationOptionsOpts = { rpName, @@ -164,11 +192,11 @@ export async function startRegistration( // Store challenge in database await storeChallenge(req.session.sessionId, registrationOptions.challenge, name, user.userId); - return response(res, { + return response(res, { data: registrationOptions, success: true, error: false, - message: "Registration options generated", + message: "Registration options generated successfully", status: HttpCode.OK }); } catch (error) { @@ -176,7 +204,7 @@ export async function startRegistration( return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, - "Failed to generate registration options" + "Failed to start registration" ) ); } @@ -326,6 +354,19 @@ export async function deletePasskey( const credentialId = decodeURIComponent(encodedCredentialId); const user = req.user as User; + const parsedBody = deletePasskeyBody.safeParse(req.body); + + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { password } = parsedBody.data; + // Only allow internal users to use passkeys if (user.type !== UserType.Internal) { return next( @@ -337,6 +378,23 @@ export async function deletePasskey( } try { + // Verify password + const validPassword = await verifyPassword(password, user.passwordHash!); + if (!validPassword) { + return next(unauthorized()); + } + + // If user has 2FA enabled, require a code + if (user.twoFactorEnabled) { + return response<{ codeRequested: boolean }>(res, { + data: { codeRequested: true }, + success: true, + error: false, + message: "Two-factor authentication required", + status: HttpCode.ACCEPTED + }); + } + await db .delete(passkeys) .where(and( @@ -424,7 +482,7 @@ export async function startAuthentication( allowCredentials = userPasskeys.map(key => ({ id: Buffer.from(key.credentialId, 'base64'), type: 'public-key' as const, - transports: key.transports ? JSON.parse(key.transports) : undefined + transports: key.transports ? JSON.parse(key.transports) as AuthenticatorTransport[] : undefined })); } else { // If no email provided, allow any passkey (for resident key authentication) @@ -546,7 +604,7 @@ export async function verifyAuthentication( credentialID: Buffer.from(passkey.credentialId, 'base64'), credentialPublicKey: Buffer.from(passkey.publicKey, 'base64'), counter: passkey.signCount, - transports: passkey.transports ? JSON.parse(passkey.transports) : undefined + transports: passkey.transports ? JSON.parse(passkey.transports) as AuthenticatorTransport[] : undefined }, requireUserVerification: false }); diff --git a/src/components/PasskeyForm.tsx b/src/components/PasskeyForm.tsx index 66abc844..14c3f393 100644 --- a/src/components/PasskeyForm.tsx +++ b/src/components/PasskeyForm.tsx @@ -44,21 +44,41 @@ type Passkey = { lastUsed: string; }; +type DeletePasskeyData = { + credentialId: string; + name: string; +}; + export default function PasskeyForm({ open, setOpen }: PasskeyFormProps) { const [loading, setLoading] = useState(false); const [passkeys, setPasskeys] = useState([]); + const [step, setStep] = useState<"list" | "register" | "delete">("list"); + const [selectedPasskey, setSelectedPasskey] = useState(null); const { user } = useUserContext(); const t = useTranslations(); const api = createApiClient(useEnvContext()); const registerSchema = z.object({ - name: z.string().min(1, { message: t('passkeyNameRequired') }) + name: z.string().min(1, { message: t('passkeyNameRequired') }), + password: z.string().min(1, { message: t('passwordRequired') }) }); - const form = useForm>({ + const deleteSchema = z.object({ + password: z.string().min(1, { message: t('passwordRequired') }) + }); + + const registerForm = useForm>({ resolver: zodResolver(registerSchema), defaultValues: { - name: "" + name: "", + password: "" + } + }); + + const deleteForm = useForm>({ + resolver: zodResolver(deleteSchema), + defaultValues: { + password: "" } }); @@ -87,8 +107,21 @@ export default function PasskeyForm({ open, setOpen }: PasskeyFormProps) { // Start registration const startRes = await api.post("/auth/passkey/register/start", { - name: values.name + name: values.name, + password: values.password }); + + // Handle 2FA if required + if (startRes.data.data.codeRequested) { + // TODO: Handle 2FA verification + toast({ + title: "2FA Required", + description: "Two-factor authentication is required to register a passkey.", + variant: "destructive" + }); + return; + } + const options = startRes.data.data; // Create passkey @@ -104,9 +137,12 @@ export default function PasskeyForm({ open, setOpen }: PasskeyFormProps) { description: t('passkeyRegisterSuccess') }); + // Reset form and go back to list + registerForm.reset(); + setStep("list"); + // Reload passkeys await loadPasskeys(); - form.reset(); } catch (error) { toast({ title: "Error", @@ -118,17 +154,26 @@ export default function PasskeyForm({ open, setOpen }: PasskeyFormProps) { } }; - const handleDeletePasskey = async (credentialId: string) => { + const handleDeletePasskey = async (values: z.infer) => { + if (!selectedPasskey) return; + try { setLoading(true); - const encodedCredentialId = encodeURIComponent(credentialId); - await api.delete(`/auth/passkey/${encodedCredentialId}`); + const encodedCredentialId = encodeURIComponent(selectedPasskey.credentialId); + await api.delete(`/auth/passkey/${encodedCredentialId}`, { + data: { password: values.password } + }); toast({ title: "Success", description: t('passkeyRemoveSuccess') }); + // Reset form and go back to list + deleteForm.reset(); + setStep("list"); + setSelectedPasskey(null); + // Reload passkeys await loadPasskeys(); } catch (error) { @@ -142,90 +187,225 @@ export default function PasskeyForm({ open, setOpen }: PasskeyFormProps) { } }; + function reset() { + registerForm.reset(); + deleteForm.reset(); + setStep("list"); + setSelectedPasskey(null); + setLoading(false); + } + return ( - - - - {t('passkeyManage')} - + { + setOpen(val); + if (!val) reset(); + }} + > + + + {t('passkeyManage')} + {t('passkeyDescription')} - -
-
-

{t('passkeyList')}

- {passkeys.length === 0 ? ( -

- {t('passkeyNone')} -

- ) : ( -
- {passkeys.map((passkey) => ( -
-
-

{passkey.name}

-

- {t('passkeyLastUsed', { - date: new Date(passkey.lastUsed).toLocaleDateString() - })} -

-
+ +
+ {step === "list" && ( + <> +
+

{t('passkeyList')}

+ {passkeys.length === 0 ? ( +
+

+ {t('passkeyNone')} +

+
+ ) : ( +
+ {passkeys.map((passkey) => ( +
+
+

{passkey.name}

+

+ {t('passkeyLastUsed', { + date: new Date(passkey.lastUsed).toLocaleDateString() + })} +

+
+ +
+ ))} + {passkeys.length === 1 && ( +
+ {t('passkeyRecommendation')} +
+ )} +
+ )} +
+ +
+ +
+ + )} + + {step === "register" && ( +
+

{t('passkeyRegister')}

+
+ + ( + + {t('passkeyNameLabel')} + + + + + + )} + /> + + ( + + {t('password')} + + + + + + )} + /> + +
+ +
+ + +
+ )} + + {step === "delete" && selectedPasskey && ( +
+
+

Remove Passkey

+

+ Enter your password to remove the passkey "{selectedPasskey.name}" +

+
+ +
+ + ( + + {t('password')} + + + + + + )} + /> + +
+ +
- ))} -
- )} -
- -
-

{t('passkeyRegister')}

- - - ( - - {t('passkeyNameLabel')} - - - - - - )} - /> - - - -
+ + +
+ )}
- + - + From 1559a2a943f660773427e4bbfbea89656cd75e03 Mon Sep 17 00:00:00 2001 From: Adrian Astles Date: Sat, 5 Jul 2025 16:32:04 +0800 Subject: [PATCH 03/12] Fixed database dependencies. --- package-lock.json | 16 ++++++++-------- package.json | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index e967c70a..ece74553 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0", "license": "SEE LICENSE IN LICENSE AND README.md", "dependencies": { - "@asteasolutions/zod-to-openapi": "^7.3.2", + "@asteasolutions/zod-to-openapi": "^7.3.4", "@hookform/resolvers": "3.9.1", "@node-rs/argon2": "^2.0.2", "@oslojs/crypto": "1.0.1", @@ -63,7 +63,7 @@ "jmespath": "^0.16.0", "js-yaml": "4.1.0", "jsonwebtoken": "^9.0.2", - "lucide-react": "0.511.0", + "lucide-react": "^0.525.0", "moment": "2.30.1", "next": "15.3.3", "next-intl": "^4.1.0", @@ -10068,9 +10068,9 @@ } }, "node_modules/lucide-react": { - "version": "0.511.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.511.0.tgz", - "integrity": "sha512-VK5a2ydJ7xm8GvBeKLS9mu1pVK6ucef9780JVUjw6bAjJL/QXnd4Y0p7SPeOUMC27YhzNCZvm5d/QX0Tp3rc0w==", + "version": "0.525.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.525.0.tgz", + "integrity": "sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -16006,9 +16006,9 @@ } }, "node_modules/tw-animate-css": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.3.4.tgz", - "integrity": "sha512-dd1Ht6/YQHcNbq0znIT6dG8uhO7Ce+VIIhZUhjsryXsMPJQz3bZg7Q2eNzLwipb25bRZslGb2myio5mScd1TFg==", + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.3.5.tgz", + "integrity": "sha512-t3u+0YNoloIhj1mMXs779P6MO9q3p3mvGn4k1n3nJPqJw/glZcuijG2qTSN4z4mgNRfW5ZC3aXJFLwDtiipZXA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/Wombosvideo" diff --git a/package.json b/package.json index 229e47ab..69a20917 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "build:cli": "node esbuild.mjs -e cli/index.ts -o dist/cli.mjs" }, "dependencies": { - "@asteasolutions/zod-to-openapi": "^7.3.2", + "@asteasolutions/zod-to-openapi": "^7.3.4", "@hookform/resolvers": "3.9.1", "@node-rs/argon2": "^2.0.2", "@oslojs/crypto": "1.0.1", @@ -81,7 +81,7 @@ "jmespath": "^0.16.0", "js-yaml": "4.1.0", "jsonwebtoken": "^9.0.2", - "lucide-react": "0.511.0", + "lucide-react": "^0.525.0", "moment": "2.30.1", "next": "15.3.3", "next-intl": "^4.1.0", From bf8078ed660db6a761c493f2a40f214a094ddf0b Mon Sep 17 00:00:00 2001 From: Adrian Astles Date: Sat, 5 Jul 2025 16:48:37 +0800 Subject: [PATCH 04/12] enhance WebAuthn implementation and error handling. --- server/routers/auth/passkey.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/routers/auth/passkey.ts b/server/routers/auth/passkey.ts index 07080fc6..bf9d7c8c 100644 --- a/server/routers/auth/passkey.ts +++ b/server/routers/auth/passkey.ts @@ -55,7 +55,7 @@ setInterval(async () => { // Helper functions for challenge management async function storeChallenge(sessionId: string, challenge: string, passkeyName?: string, userId?: string) { - const expiresAt = Date.now() + (10 * 60 * 1000); // 10 minutes + const expiresAt = Date.now() + (5 * 60 * 1000); // 5 minutes // Delete any existing challenge for this session await db.delete(webauthnChallenge).where(eq(webauthnChallenge.sessionId, sessionId)); @@ -458,7 +458,7 @@ export async function startAuthentication( return next( createHttpError( HttpCode.BAD_REQUEST, - "No passkeys available for this user" + "Invalid credentials" ) ); } @@ -574,7 +574,7 @@ export async function verifyAuthentication( return next( createHttpError( HttpCode.BAD_REQUEST, - "Passkey not found" + "We couldn't find this security key. Please make sure you're using a security key that was previously registered with this account. If you're having trouble, try registering a new security key or contact support." ) ); } From d5e67835aa4f695d8980dad6a59de156e156fb0c Mon Sep 17 00:00:00 2001 From: Adrian Astles Date: Sat, 5 Jul 2025 16:52:56 +0800 Subject: [PATCH 05/12] improved WebAuthn error messages and session handling. Compatibility guidance in error states, and Improve user guidance for common authentication issues. --- server/routers/auth/passkey.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/routers/auth/passkey.ts b/server/routers/auth/passkey.ts index bf9d7c8c..835b9c8b 100644 --- a/server/routers/auth/passkey.ts +++ b/server/routers/auth/passkey.ts @@ -544,7 +544,7 @@ export async function verifyAuthentication( return next( createHttpError( HttpCode.BAD_REQUEST, - "Missing temp session ID" + "Your session information is missing. This might happen if you've been inactive for too long or if your browser cleared temporary data. Please start the sign-in process again." ) ); } @@ -557,7 +557,7 @@ export async function verifyAuthentication( return next( createHttpError( HttpCode.BAD_REQUEST, - "No challenge found or challenge expired" + "Your sign-in session has expired. For security reasons, you have 5 minutes to complete the authentication process. Please try signing in again." ) ); } @@ -574,7 +574,7 @@ export async function verifyAuthentication( return next( createHttpError( HttpCode.BAD_REQUEST, - "We couldn't find this security key. Please make sure you're using a security key that was previously registered with this account. If you're having trouble, try registering a new security key or contact support." + "We couldn't verify your security key. This might happen if your device isn't compatible or if the security key was removed too quickly. Please try again and keep your security key connected until the process completes." ) ); } @@ -615,7 +615,7 @@ export async function verifyAuthentication( return next( createHttpError( HttpCode.BAD_REQUEST, - "Authentication failed" + "Authentication failed. This could happen if your security key wasn't recognized or was removed too early. Please ensure your security key is properly connected and try again." ) ); } From 5130071a60f4759544beb946a99fd2607f003411 Mon Sep 17 00:00:00 2001 From: Adrian Astles Date: Sat, 5 Jul 2025 18:27:04 +0800 Subject: [PATCH 06/12] improved security key management interface, also updated locales --- messages/de-DE.json | 20 +- messages/en-US.json | 38 +-- messages/es-ES.json | 20 +- messages/fr-FR.json | 22 +- messages/it-IT.json | 20 +- messages/nl-NL.json | 20 +- messages/pl-PL.json | 20 +- messages/pt-PT.json | 20 +- messages/tr-TR.json | 20 +- messages/zh-CN.json | 26 +- package-lock.json | 26 +- package.json | 8 +- server/routers/auth/login.ts | 18 +- src/components/LoginForm.tsx | 106 +++++--- src/components/PasskeyForm.tsx | 414 ----------------------------- src/components/ProfileIcon.tsx | 10 +- src/components/SecurityKeyForm.tsx | 409 ++++++++++++++++++++++++++++ 17 files changed, 712 insertions(+), 505 deletions(-) delete mode 100644 src/components/PasskeyForm.tsx create mode 100644 src/components/SecurityKeyForm.tsx diff --git a/messages/de-DE.json b/messages/de-DE.json index 09276f72..377ec94b 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -1132,5 +1132,23 @@ "initialSetupTitle": "Initial Einrichtung des Servers", "initialSetupDescription": "Erstellen Sie das initiale Server-Admin-Konto. Es kann nur einen Server-Admin geben. Sie können diese Anmeldedaten später immer ändern.", "createAdminAccount": "Admin-Konto erstellen", - "setupErrorCreateAdmin": "Beim Erstellen des Server-Admin-Kontos ist ein Fehler aufgetreten." + "setupErrorCreateAdmin": "Beim Erstellen des Server-Admin-Kontos ist ein Fehler aufgetreten.", + "securityKeyManage": "Sicherheitsschlüssel verwalten", + "securityKeyDescription": "Sicherheitsschlüssel für passwortlose Authentifizierung hinzufügen oder entfernen", + "securityKeyRegister": "Neuen Sicherheitsschlüssel registrieren", + "securityKeyList": "Ihre Sicherheitsschlüssel", + "securityKeyNone": "Noch keine Sicherheitsschlüssel registriert", + "securityKeyNameRequired": "Name ist erforderlich", + "securityKeyRemove": "Entfernen", + "securityKeyLastUsed": "Zuletzt verwendet: {date}", + "securityKeyNameLabel": "Name", + "securityKeyNamePlaceholder": "Geben Sie einen Namen für diesen Sicherheitsschlüssel ein", + "securityKeyRegisterSuccess": "Sicherheitsschlüssel erfolgreich registriert", + "securityKeyRegisterError": "Fehler beim Registrieren des Sicherheitsschlüssels", + "securityKeyRemoveSuccess": "Sicherheitsschlüssel erfolgreich entfernt", + "securityKeyRemoveError": "Fehler beim Entfernen des Sicherheitsschlüssels", + "securityKeyLoadError": "Fehler beim Laden der Sicherheitsschlüssel", + "securityKeyLogin": "Mit Sicherheitsschlüssel anmelden", + "securityKeyAuthError": "Fehler bei der Authentifizierung mit Sicherheitsschlüssel", + "securityKeyRecommendation": "Erwägen Sie die Registrierung eines weiteren Sicherheitsschlüssels auf einem anderen Gerät, um sicherzustellen, dass Sie sich nicht aus Ihrem Konto aussperren." } diff --git a/messages/en-US.json b/messages/en-US.json index 9e53388d..fda1a590 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1133,22 +1133,24 @@ "initialSetupDescription": "Create the intial server admin account. Only one server admin can exist. You can always change these credentials later.", "createAdminAccount": "Create Admin Account", "setupErrorCreateAdmin": "An error occurred while creating the server admin account.", - "passkeyManage": "Manage Passkeys", - "passkeyDescription": "Add or remove passkeys for passwordless authentication", - "passkeyRegister": "Register New Passkey", - "passkeyList": "Your Passkeys", - "passkeyNone": "No passkeys registered yet", - "passkeyNameRequired": "Name is required", - "passkeyRemove": "Remove", - "passkeyLastUsed": "Last used: {date}", - "passkeyNameLabel": "Name", - "passkeyNamePlaceholder": "Enter a name for this passkey", - "passkeyRegisterSuccess": "Passkey registered successfully", - "passkeyRegisterError": "Failed to register passkey", - "passkeyRemoveSuccess": "Passkey removed successfully", - "passkeyRemoveError": "Failed to remove passkey", - "passkeyLoadError": "Failed to load passkeys", - "passkeyLogin": "Login with Passkey", - "passkeyAuthError": "Failed to authenticate with passkey", - "passkeyRecommendation": "Consider registering another passkey on a different device to ensure you don't get locked out of your account." + "securityKeyManage": "Manage Security Keys", + "securityKeyDescription": "Add or remove security keys for passwordless authentication", + "securityKeyRegister": "Register New Security Key", + "securityKeyList": "Your Security Keys", + "securityKeyNone": "No security keys registered yet", + "securityKeyNameRequired": "Name is required", + "securityKeyRemove": "Remove", + "securityKeyLastUsed": "Last used: {date}", + "securityKeyNameLabel": "Name", + "securityKeyNamePlaceholder": "Enter a name for this security key", + "securityKeyRegisterSuccess": "Security key registered successfully", + "securityKeyRegisterError": "Failed to register security key", + "securityKeyRemoveSuccess": "Security key removed successfully", + "securityKeyRemoveError": "Failed to remove security key", + "securityKeyLoadError": "Failed to load security keys", + "securityKeyLogin": "Sign in with security key", + "securityKeyAuthError": "Failed to authenticate with security key", + "securityKeyRecommendation": "Consider registering another security key on a different device to ensure you don't get locked out of your account.", + "registering": "Registering...", + "securityKeyPrompt": "Please verify your identity using your security key. Make sure your security key is connected and ready." } diff --git a/messages/es-ES.json b/messages/es-ES.json index 60856fe8..226c02b6 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -1132,5 +1132,23 @@ "initialSetupTitle": "Configuración inicial del servidor", "initialSetupDescription": "Cree la cuenta de administrador del servidor inicial. Solo puede existir un administrador del servidor. Siempre puede cambiar estas credenciales más tarde.", "createAdminAccount": "Crear cuenta de administrador", - "setupErrorCreateAdmin": "Se produjo un error al crear la cuenta de administrador del servidor." + "setupErrorCreateAdmin": "Se produjo un error al crear la cuenta de administrador del servidor.", + "securityKeyManage": "Gestionar llaves de seguridad", + "securityKeyDescription": "Agregar o eliminar llaves de seguridad para autenticación sin contraseña", + "securityKeyRegister": "Registrar nueva llave de seguridad", + "securityKeyList": "Tus llaves de seguridad", + "securityKeyNone": "No hay llaves de seguridad registradas", + "securityKeyNameRequired": "El nombre es requerido", + "securityKeyRemove": "Eliminar", + "securityKeyLastUsed": "Último uso: {date}", + "securityKeyNameLabel": "Nombre", + "securityKeyNamePlaceholder": "Ingrese un nombre para esta llave de seguridad", + "securityKeyRegisterSuccess": "Llave de seguridad registrada exitosamente", + "securityKeyRegisterError": "Error al registrar la llave de seguridad", + "securityKeyRemoveSuccess": "Llave de seguridad eliminada exitosamente", + "securityKeyRemoveError": "Error al eliminar la llave de seguridad", + "securityKeyLoadError": "Error al cargar las llaves de seguridad", + "securityKeyLogin": "Iniciar sesión con llave de seguridad", + "securityKeyAuthError": "Error al autenticar con llave de seguridad", + "securityKeyRecommendation": "Considere registrar otra llave de seguridad en un dispositivo diferente para asegurarse de no quedar bloqueado de su cuenta." } diff --git a/messages/fr-FR.json b/messages/fr-FR.json index a7a237bf..4681f0cc 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -184,7 +184,7 @@ "cancel": "Abandonner", "resourceConfig": "Snippets de configuration", "resourceConfigDescription": "Copiez et collez ces modules de configuration pour configurer votre ressource TCP/UDP", - "resourceAddEntrypoints": "Traefik: Ajouter des points d’entrée", + "resourceAddEntrypoints": "Traefik: Ajouter des points d'entrée", "resourceExposePorts": "Gerbil: Exposer des ports dans Docker Compose", "resourceLearnRaw": "Apprenez à configurer les ressources TCP/UDP", "resourceBack": "Retour aux ressources", @@ -1132,5 +1132,23 @@ "initialSetupTitle": "Configuration initiale du serveur", "initialSetupDescription": "Créer le compte administrateur du serveur initial. Un seul administrateur serveur peut exister. Vous pouvez toujours changer ces informations d'identification plus tard.", "createAdminAccount": "Créer un compte administrateur", - "setupErrorCreateAdmin": "Une erreur s'est produite lors de la création du compte administrateur du serveur." + "setupErrorCreateAdmin": "Une erreur s'est produite lors de la création du compte administrateur du serveur.", + "securityKeyManage": "Gérer les clés de sécurité", + "securityKeyDescription": "Ajouter ou supprimer des clés de sécurité pour l'authentification sans mot de passe", + "securityKeyRegister": "Enregistrer une nouvelle clé de sécurité", + "securityKeyList": "Vos clés de sécurité", + "securityKeyNone": "Aucune clé de sécurité enregistrée", + "securityKeyNameRequired": "Le nom est requis", + "securityKeyRemove": "Supprimer", + "securityKeyLastUsed": "Dernière utilisation : {date}", + "securityKeyNameLabel": "Nom", + "securityKeyNamePlaceholder": "Entrez un nom pour cette clé de sécurité", + "securityKeyRegisterSuccess": "Clé de sécurité enregistrée avec succès", + "securityKeyRegisterError": "Échec de l'enregistrement de la clé de sécurité", + "securityKeyRemoveSuccess": "Clé de sécurité supprimée avec succès", + "securityKeyRemoveError": "Échec de la suppression de la clé de sécurité", + "securityKeyLoadError": "Échec du chargement des clés de sécurité", + "securityKeyLogin": "Se connecter avec une clé de sécurité", + "securityKeyAuthError": "Échec de l'authentification avec la clé de sécurité", + "securityKeyRecommendation": "Envisagez d'enregistrer une autre clé de sécurité sur un appareil différent pour vous assurer de ne pas être bloqué de votre compte." } diff --git a/messages/it-IT.json b/messages/it-IT.json index cfe983d2..0af5e8e4 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -1132,5 +1132,23 @@ "initialSetupTitle": "Impostazione Iniziale del Server", "initialSetupDescription": "Crea l'account amministratore del server iniziale. Può esistere solo un amministratore del server. È sempre possibile modificare queste credenziali in seguito.", "createAdminAccount": "Crea Account Admin", - "setupErrorCreateAdmin": "Si è verificato un errore durante la creazione dell'account amministratore del server." + "setupErrorCreateAdmin": "Si è verificato un errore durante la creazione dell'account amministratore del server.", + "securityKeyManage": "Gestisci chiavi di sicurezza", + "securityKeyDescription": "Aggiungi o rimuovi chiavi di sicurezza per l'autenticazione senza password", + "securityKeyRegister": "Registra nuova chiave di sicurezza", + "securityKeyList": "Le tue chiavi di sicurezza", + "securityKeyNone": "Nessuna chiave di sicurezza registrata", + "securityKeyNameRequired": "Il nome è obbligatorio", + "securityKeyRemove": "Rimuovi", + "securityKeyLastUsed": "Ultimo utilizzo: {date}", + "securityKeyNameLabel": "Nome", + "securityKeyNamePlaceholder": "Inserisci un nome per questa chiave di sicurezza", + "securityKeyRegisterSuccess": "Chiave di sicurezza registrata con successo", + "securityKeyRegisterError": "Errore durante la registrazione della chiave di sicurezza", + "securityKeyRemoveSuccess": "Chiave di sicurezza rimossa con successo", + "securityKeyRemoveError": "Errore durante la rimozione della chiave di sicurezza", + "securityKeyLoadError": "Errore durante il caricamento delle chiavi di sicurezza", + "securityKeyLogin": "Accedi con chiave di sicurezza", + "securityKeyAuthError": "Errore durante l'autenticazione con chiave di sicurezza", + "securityKeyRecommendation": "Considera di registrare un'altra chiave di sicurezza su un dispositivo diverso per assicurarti di non rimanere bloccato fuori dal tuo account." } diff --git a/messages/nl-NL.json b/messages/nl-NL.json index 7e625b00..39aaa9b6 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -1132,5 +1132,23 @@ "initialSetupTitle": "Initiële serverconfiguratie", "initialSetupDescription": "Maak het eerste serverbeheeraccount aan. Er kan slechts één serverbeheerder bestaan. U kunt deze inloggegevens later altijd wijzigen.", "createAdminAccount": "Maak een beheeraccount aan", - "setupErrorCreateAdmin": "Er is een fout opgetreden bij het maken van het serverbeheerdersaccount." + "setupErrorCreateAdmin": "Er is een fout opgetreden bij het maken van het serverbeheerdersaccount.", + "securityKeyManage": "Beveiligingssleutels beheren", + "securityKeyDescription": "Voeg beveiligingssleutels toe of verwijder ze voor wachtwoordloze authenticatie", + "securityKeyRegister": "Nieuwe beveiligingssleutel registreren", + "securityKeyList": "Uw beveiligingssleutels", + "securityKeyNone": "Nog geen beveiligingssleutels geregistreerd", + "securityKeyNameRequired": "Naam is verplicht", + "securityKeyRemove": "Verwijderen", + "securityKeyLastUsed": "Laatst gebruikt: {date}", + "securityKeyNameLabel": "Naam", + "securityKeyNamePlaceholder": "Voer een naam in voor deze beveiligingssleutel", + "securityKeyRegisterSuccess": "Beveiligingssleutel succesvol geregistreerd", + "securityKeyRegisterError": "Fout bij registreren van beveiligingssleutel", + "securityKeyRemoveSuccess": "Beveiligingssleutel succesvol verwijderd", + "securityKeyRemoveError": "Fout bij verwijderen van beveiligingssleutel", + "securityKeyLoadError": "Fout bij laden van beveiligingssleutels", + "securityKeyLogin": "Inloggen met beveiligingssleutel", + "securityKeyAuthError": "Fout bij authenticatie met beveiligingssleutel", + "securityKeyRecommendation": "Overweeg om een andere beveiligingssleutel te registreren op een ander apparaat om ervoor te zorgen dat u niet buitengesloten raakt van uw account." } diff --git a/messages/pl-PL.json b/messages/pl-PL.json index 25cf2e6a..e21902ea 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -1132,5 +1132,23 @@ "initialSetupTitle": "Wstępna konfiguracja serwera", "initialSetupDescription": "Utwórz początkowe konto administratora serwera. Może istnieć tylko jeden administrator serwera. Zawsze można zmienić te dane uwierzytelniające.", "createAdminAccount": "Utwórz konto administratora", - "setupErrorCreateAdmin": "Wystąpił błąd podczas tworzenia konta administratora serwera." + "setupErrorCreateAdmin": "Wystąpił błąd podczas tworzenia konta administratora serwera.", + "securityKeyManage": "Zarządzaj kluczami bezpieczeństwa", + "securityKeyDescription": "Dodaj lub usuń klucze bezpieczeństwa do uwierzytelniania bez hasła", + "securityKeyRegister": "Zarejestruj nowy klucz bezpieczeństwa", + "securityKeyList": "Twoje klucze bezpieczeństwa", + "securityKeyNone": "Brak zarejestrowanych kluczy bezpieczeństwa", + "securityKeyNameRequired": "Nazwa jest wymagana", + "securityKeyRemove": "Usuń", + "securityKeyLastUsed": "Ostatnio używany: {date}", + "securityKeyNameLabel": "Nazwa", + "securityKeyNamePlaceholder": "Wprowadź nazwę dla tego klucza bezpieczeństwa", + "securityKeyRegisterSuccess": "Klucz bezpieczeństwa został pomyślnie zarejestrowany", + "securityKeyRegisterError": "Błąd podczas rejestracji klucza bezpieczeństwa", + "securityKeyRemoveSuccess": "Klucz bezpieczeństwa został pomyślnie usunięty", + "securityKeyRemoveError": "Błąd podczas usuwania klucza bezpieczeństwa", + "securityKeyLoadError": "Błąd podczas ładowania kluczy bezpieczeństwa", + "securityKeyLogin": "Zaloguj się kluczem bezpieczeństwa", + "securityKeyAuthError": "Błąd podczas uwierzytelniania kluczem bezpieczeństwa", + "securityKeyRecommendation": "Rozważ zarejestrowanie innego klucza bezpieczeństwa na innym urządzeniu, aby upewnić się, że nie zostaniesz zablokowany z dostępu do swojego konta." } diff --git a/messages/pt-PT.json b/messages/pt-PT.json index 69e650d5..1d1b9ba1 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -1132,5 +1132,23 @@ "initialSetupTitle": "Configuração Inicial do Servidor", "initialSetupDescription": "Crie a conta de administrador inicial do servidor. Apenas um administrador do servidor pode existir. Você sempre pode alterar essas credenciais posteriormente.", "createAdminAccount": "Criar Conta de Administrador", - "setupErrorCreateAdmin": "Ocorreu um erro ao criar a conta de administrador do servidor." + "setupErrorCreateAdmin": "Ocorreu um erro ao criar a conta de administrador do servidor.", + "securityKeyManage": "Gerenciar chaves de segurança", + "securityKeyDescription": "Adicionar ou remover chaves de segurança para autenticação sem senha", + "securityKeyRegister": "Registrar nova chave de segurança", + "securityKeyList": "Suas chaves de segurança", + "securityKeyNone": "Nenhuma chave de segurança registrada", + "securityKeyNameRequired": "Nome é obrigatório", + "securityKeyRemove": "Remover", + "securityKeyLastUsed": "Último uso: {date}", + "securityKeyNameLabel": "Nome", + "securityKeyNamePlaceholder": "Digite um nome para esta chave de segurança", + "securityKeyRegisterSuccess": "Chave de segurança registrada com sucesso", + "securityKeyRegisterError": "Erro ao registrar chave de segurança", + "securityKeyRemoveSuccess": "Chave de segurança removida com sucesso", + "securityKeyRemoveError": "Erro ao remover chave de segurança", + "securityKeyLoadError": "Erro ao carregar chaves de segurança", + "securityKeyLogin": "Entrar com chave de segurança", + "securityKeyAuthError": "Erro ao autenticar com chave de segurança", + "securityKeyRecommendation": "Considere registrar outra chave de segurança em um dispositivo diferente para garantir que você não fique bloqueado da sua conta." } diff --git a/messages/tr-TR.json b/messages/tr-TR.json index ad6a0fe3..085505b4 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -1132,5 +1132,23 @@ "initialSetupTitle": "İlk Sunucu Kurulumu", "initialSetupDescription": "İlk sunucu yönetici hesabını oluşturun. Yalnızca bir sunucu yöneticisi olabilir. Bu kimlik bilgilerini daha sonra her zaman değiştirebilirsiniz.", "createAdminAccount": "Yönetici Hesabı Oluştur", - "setupErrorCreateAdmin": "Sunucu yönetici hesabı oluşturulurken bir hata oluştu." + "setupErrorCreateAdmin": "Sunucu yönetici hesabı oluşturulurken bir hata oluştu.", + "securityKeyManage": "Güvenlik Anahtarlarını Yönet", + "securityKeyDescription": "Şifresiz kimlik doğrulama için güvenlik anahtarları ekleyin veya kaldırın", + "securityKeyRegister": "Yeni Güvenlik Anahtarı Kaydet", + "securityKeyList": "Güvenlik Anahtarlarınız", + "securityKeyNone": "Henüz kayıtlı güvenlik anahtarı yok", + "securityKeyNameRequired": "İsim gerekli", + "securityKeyRemove": "Kaldır", + "securityKeyLastUsed": "Son kullanım: {date}", + "securityKeyNameLabel": "İsim", + "securityKeyNamePlaceholder": "Bu güvenlik anahtarı için bir isim girin", + "securityKeyRegisterSuccess": "Güvenlik anahtarı başarıyla kaydedildi", + "securityKeyRegisterError": "Güvenlik anahtarı kaydedilirken hata oluştu", + "securityKeyRemoveSuccess": "Güvenlik anahtarı başarıyla kaldırıldı", + "securityKeyRemoveError": "Güvenlik anahtarı kaldırılırken hata oluştu", + "securityKeyLoadError": "Güvenlik anahtarları yüklenirken hata oluştu", + "securityKeyLogin": "Güvenlik anahtarı ile giriş yap", + "securityKeyAuthError": "Güvenlik anahtarı ile kimlik doğrulama başarısız oldu", + "securityKeyRecommendation": "Hesabınızdan kilitlenmediğinizden emin olmak için farklı bir cihazda başka bir güvenlik anahtarı kaydetmeyi düşünün." } diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 676d5f56..8076ebda 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -212,7 +212,7 @@ "orgDeleteConfirm": "确认删除组织", "orgMessageRemove": "此操作不可逆,这将删除所有相关数据。", "orgMessageConfirm": "要确认,请在下面输入组织名称。", - "orgQuestionRemove": "你确定要删除 “{selectedOrg}” 组织吗?", + "orgQuestionRemove": "你确定要删除 \"{selectedOrg}\" 组织吗?", "orgUpdated": "组织已更新", "orgUpdatedDescription": "组织已更新。", "orgErrorUpdate": "更新组织失败", @@ -279,7 +279,7 @@ "apiKeysAdd": "生成 API 密钥", "apiKeysErrorDelete": "删除 API 密钥出错", "apiKeysErrorDeleteMessage": "删除 API 密钥出错", - "apiKeysQuestionRemove": "您确定要从组织中删除 “{selectedApiKey}” API密钥吗?", + "apiKeysQuestionRemove": "您确定要从组织中删除 \"{selectedApiKey}\" API密钥吗?", "apiKeysMessageRemove": "一旦删除,此API密钥将无法被使用。", "apiKeysMessageConfirm": "要确认,请在下方输入API密钥名称。", "apiKeysDeleteConfirm": "确认删除 API 密钥", @@ -715,7 +715,7 @@ "idpManageDescription": "查看和管理系统中的身份提供商", "idpDeletedDescription": "身份提供商删除成功", "idpOidc": "OAuth2/OIDC", - "idpQuestionRemove": "你确定要永久删除 “{name}” 这个身份提供商吗?", + "idpQuestionRemove": "你确定要永久删除 \"{name}\" 这个身份提供商吗?", "idpMessageRemove": "这将删除身份提供者和所有相关的配置。通过此提供者进行身份验证的用户将无法登录。", "idpMessageConfirm": "要确认,请在下面输入身份提供者的名称。", "idpConfirmDelete": "确认删除身份提供商", @@ -1132,5 +1132,23 @@ "initialSetupTitle": "初始服务器设置", "initialSetupDescription": "创建初始服务器管理员帐户。 只能存在一个服务器管理员。 您可以随时更改这些凭据。", "createAdminAccount": "创建管理员帐户", - "setupErrorCreateAdmin": "创建服务器管理员帐户时出错。" + "setupErrorCreateAdmin": "创建服务器管理员账户时发生错误。", + "securityKeyManage": "管理安全密钥", + "securityKeyDescription": "添加或删除用于无密码认证的安全密钥", + "securityKeyRegister": "注册新的安全密钥", + "securityKeyList": "您的安全密钥", + "securityKeyNone": "尚未注册安全密钥", + "securityKeyNameRequired": "名称为必填项", + "securityKeyRemove": "删除", + "securityKeyLastUsed": "上次使用:{date}", + "securityKeyNameLabel": "名称", + "securityKeyNamePlaceholder": "为此安全密钥输入名称", + "securityKeyRegisterSuccess": "安全密钥注册成功", + "securityKeyRegisterError": "注册安全密钥失败", + "securityKeyRemoveSuccess": "安全密钥删除成功", + "securityKeyRemoveError": "删除安全密钥失败", + "securityKeyLoadError": "加载安全密钥失败", + "securityKeyLogin": "使用安全密钥登录", + "securityKeyAuthError": "使用安全密钥认证失败", + "securityKeyRecommendation": "考虑在其他设备上注册另一个安全密钥,以确保不会被锁定在您的账户之外。" } diff --git a/package-lock.json b/package-lock.json index ece74553..031c57b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "SEE LICENSE IN LICENSE AND README.md", "dependencies": { "@asteasolutions/zod-to-openapi": "^7.3.4", - "@hookform/resolvers": "3.9.1", + "@hookform/resolvers": "^3.10.0", "@node-rs/argon2": "^2.0.2", "@oslojs/crypto": "1.0.1", "@oslojs/encoding": "1.1.0", @@ -42,7 +42,7 @@ "axios": "1.9.0", "better-sqlite3": "11.7.0", "canvas-confetti": "1.9.3", - "class-variance-authority": "0.7.1", + "class-variance-authority": "^0.7.1", "clsx": "2.1.1", "cmdk": "1.1.1", "cookie": "^1.0.2", @@ -78,7 +78,7 @@ "react": "19.1.0", "react-dom": "19.1.0", "react-easy-sort": "^1.6.0", - "react-hook-form": "7.56.4", + "react-hook-form": "^7.60.0", "react-icons": "^5.5.0", "rebuild": "0.1.2", "semver": "^7.7.2", @@ -91,7 +91,7 @@ "winston-daily-rotate-file": "5.0.0", "ws": "8.18.2", "yargs": "18.0.0", - "zod": "3.25.56", + "zod": "^3.25.74", "zod-validation-error": "3.4.1" }, "devDependencies": { @@ -1515,9 +1515,9 @@ "license": "MIT" }, "node_modules/@hookform/resolvers": { - "version": "3.9.1", - "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.9.1.tgz", - "integrity": "sha512-ud2HqmGBM0P0IABqoskKWI6PEf6ZDDBZkFqe2Vnl+mTHCEHzr3ISjjZyCwTjC/qpL25JC9aIDkloQejvMeq0ug==", + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz", + "integrity": "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==", "license": "MIT", "peerDependencies": { "react-hook-form": "^7.0.0" @@ -14418,9 +14418,9 @@ } }, "node_modules/react-hook-form": { - "version": "7.56.4", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.56.4.tgz", - "integrity": "sha512-Rob7Ftz2vyZ/ZGsQZPaRdIefkgOSrQSPXfqBdvOPwJfoGnjwRJUs7EM7Kc1mcoDv3NOtqBzPGbcMB8CGn9CKgw==", + "version": "7.60.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.60.0.tgz", + "integrity": "sha512-SBrYOvMbDB7cV8ZfNpaiLcgjH/a1c7aK0lK+aNigpf4xWLO8q+o4tcvVurv3c4EOyzn/3dCsYt4GKD42VvJ/+A==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -16770,9 +16770,9 @@ } }, "node_modules/zod": { - "version": "3.25.56", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.56.tgz", - "integrity": "sha512-rd6eEF3BTNvQnR2e2wwolfTmUTnp70aUTqr0oaGbHifzC3BKJsoV+Gat8vxUMR1hwOKBs6El+qWehrHbCpW6SQ==", + "version": "3.25.74", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.74.tgz", + "integrity": "sha512-J8poo92VuhKjNknViHRAIuuN6li/EwFbAC8OedzI8uxpEPGiXHGQu9wemIAioIpqgfB4SySaJhdk0mH5Y4ICBg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 69a20917..c807d047 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ }, "dependencies": { "@asteasolutions/zod-to-openapi": "^7.3.4", - "@hookform/resolvers": "3.9.1", + "@hookform/resolvers": "^3.10.0", "@node-rs/argon2": "^2.0.2", "@oslojs/crypto": "1.0.1", "@oslojs/encoding": "1.1.0", @@ -60,7 +60,7 @@ "axios": "1.9.0", "better-sqlite3": "11.7.0", "canvas-confetti": "1.9.3", - "class-variance-authority": "0.7.1", + "class-variance-authority": "^0.7.1", "clsx": "2.1.1", "cmdk": "1.1.1", "cookie": "^1.0.2", @@ -96,7 +96,7 @@ "react": "19.1.0", "react-dom": "19.1.0", "react-easy-sort": "^1.6.0", - "react-hook-form": "7.56.4", + "react-hook-form": "^7.60.0", "react-icons": "^5.5.0", "rebuild": "0.1.2", "semver": "^7.7.2", @@ -109,7 +109,7 @@ "winston-daily-rotate-file": "5.0.0", "ws": "8.18.2", "yargs": "18.0.0", - "zod": "3.25.56", + "zod": "^3.25.74", "zod-validation-error": "3.4.1" }, "devDependencies": { diff --git a/server/routers/auth/login.ts b/server/routers/auth/login.ts index f5f7ff77..72040a03 100644 --- a/server/routers/auth/login.ts +++ b/server/routers/auth/login.ts @@ -4,7 +4,7 @@ import { serializeSessionCookie } from "@server/auth/sessions/app"; import { db } from "@server/db"; -import { users } from "@server/db"; +import { users, passkeys } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; import { eq, and } from "drizzle-orm"; @@ -91,6 +91,22 @@ export async function login( const existingUser = existingUserRes[0]; + // Check if user has passkeys registered + const userPasskeys = await db + .select() + .from(passkeys) + .where(eq(passkeys.userId, existingUser.userId)); + + if (userPasskeys.length > 0) { + return response<{ usePasskey: boolean }>(res, { + data: { usePasskey: true }, + success: true, + error: false, + message: "Please use your security key to sign in", + status: HttpCode.UNAUTHORIZED + }); + } + const validPassword = await verifyPassword( password, existingUser.passwordHash! diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index d2650f43..f806f77d 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -66,6 +66,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { const hasIdp = idps && idps.length > 0; const [mfaRequested, setMfaRequested] = useState(false); + const [showSecurityKeyPrompt, setShowSecurityKeyPrompt] = useState(false); const t = useTranslations(); @@ -95,49 +96,63 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { } }); + async function initiateSecurityKeyAuth() { + setShowSecurityKeyPrompt(true); + setError(null); + await loginWithSecurityKey(); + setShowSecurityKeyPrompt(false); + } + async function onSubmit(values: any) { const { email, password } = form.getValues(); const { code } = mfaForm.getValues(); setLoading(true); - const res = await api - .post>("/auth/login", { + try { + const res = await api.post>("/auth/login", { email, password, code - }) - .catch((e) => { - console.error(e); - setError( - formatAxiosError(e, t('loginError')) - ); }); - if (res) { - setError(null); + if (res) { + setError(null); + const data = res.data.data; - const data = res.data.data; - - if (data?.codeRequested) { - setMfaRequested(true); - setLoading(false); - mfaForm.reset(); - return; - } - - if (data?.emailVerificationRequired) { - if (redirect) { - router.push(`/auth/verify-email?redirect=${redirect}`); - } else { - router.push("/auth/verify-email"); + if (data?.usePasskey) { + await initiateSecurityKeyAuth(); + return; } + + if (data?.codeRequested) { + setMfaRequested(true); + setLoading(false); + mfaForm.reset(); + return; + } + + if (data?.emailVerificationRequired) { + if (redirect) { + router.push(`/auth/verify-email?redirect=${redirect}`); + } else { + router.push("/auth/verify-email"); + } + return; + } + + if (onLogin) { + await onLogin(); + } + } + } catch (e) { + console.error(e); + const errorMessage = formatAxiosError(e, t('loginError')); + if (errorMessage.includes("Please use your security key")) { + await initiateSecurityKeyAuth(); return; } - - if (onLogin) { - await onLogin(); - } + setError(errorMessage); } setLoading(false); @@ -166,26 +181,28 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { } } - async function loginWithPasskey() { + async function loginWithSecurityKey() { try { setLoading(true); setError(null); const email = form.getValues().email; - // Start passkey authentication + // Start WebAuthn authentication const startRes = await api.post("/auth/passkey/authenticate/start", { email: email || undefined }); if (!startRes) { - setError(t('passkeyAuthError')); + setError(t('securityKeyAuthError', { + defaultValue: "Failed to start security key authentication" + })); return; } const { tempSessionId, ...options } = startRes.data.data; - // Perform passkey authentication + // Perform WebAuthn authentication const credential = await startAuthentication(options); // Verify authentication @@ -206,7 +223,9 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { } } catch (e) { console.error(e); - setError(formatAxiosError(e, t('passkeyAuthError'))); + setError(formatAxiosError(e, t('securityKeyAuthError', { + defaultValue: "Security key authentication failed" + }))); } finally { setLoading(false); } @@ -214,6 +233,17 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { return (
+ {showSecurityKeyPrompt && ( + + + + {t('securityKeyPrompt', { + defaultValue: "Please verify your identity using your security key. Make sure your security key is connected and ready." + })} + + + )} + {!mfaRequested && ( <>
@@ -362,7 +392,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { form="form" className="w-full" loading={loading} - disabled={loading} + disabled={loading || showSecurityKeyPrompt} > {t('login')} @@ -372,12 +402,14 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { type="button" variant="outline" className="w-full" - onClick={loginWithPasskey} + onClick={initiateSecurityKeyAuth} loading={loading} - disabled={loading} + disabled={loading || showSecurityKeyPrompt} > - {t('passkeyLogin')} + {t('securityKeyLogin', { + defaultValue: "Sign in with security key" + })} {hasIdp && ( diff --git a/src/components/PasskeyForm.tsx b/src/components/PasskeyForm.tsx deleted file mode 100644 index 14c3f393..00000000 --- a/src/components/PasskeyForm.tsx +++ /dev/null @@ -1,414 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { useUserContext } from "@app/hooks/useUserContext"; -import { useTranslations } from "next-intl"; -import { createApiClient } from "@app/lib/api"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { toast } from "@app/hooks/useToast"; -import { formatAxiosError } from "@app/lib/api"; -import { startRegistration } from "@simplewebauthn/browser"; -import { - Credenza, - CredenzaBody, - CredenzaClose, - CredenzaContent, - CredenzaDescription, - CredenzaFooter, - CredenzaHeader, - CredenzaTitle -} from "@app/components/Credenza"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@app/components/ui/form"; -import { z } from "zod"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; - -type PasskeyFormProps = { - open: boolean; - setOpen: (val: boolean) => void; -}; - -type Passkey = { - credentialId: string; - name: string; - dateCreated: string; - lastUsed: string; -}; - -type DeletePasskeyData = { - credentialId: string; - name: string; -}; - -export default function PasskeyForm({ open, setOpen }: PasskeyFormProps) { - const [loading, setLoading] = useState(false); - const [passkeys, setPasskeys] = useState([]); - const [step, setStep] = useState<"list" | "register" | "delete">("list"); - const [selectedPasskey, setSelectedPasskey] = useState(null); - const { user } = useUserContext(); - const t = useTranslations(); - const api = createApiClient(useEnvContext()); - - const registerSchema = z.object({ - name: z.string().min(1, { message: t('passkeyNameRequired') }), - password: z.string().min(1, { message: t('passwordRequired') }) - }); - - const deleteSchema = z.object({ - password: z.string().min(1, { message: t('passwordRequired') }) - }); - - const registerForm = useForm>({ - resolver: zodResolver(registerSchema), - defaultValues: { - name: "", - password: "" - } - }); - - const deleteForm = useForm>({ - resolver: zodResolver(deleteSchema), - defaultValues: { - password: "" - } - }); - - useEffect(() => { - if (open) { - loadPasskeys(); - } - }, [open]); - - const loadPasskeys = async () => { - try { - const response = await api.get("/auth/passkey/list"); - setPasskeys(response.data.data); - } catch (error) { - toast({ - title: "Error", - description: formatAxiosError(error, t('passkeyLoadError')), - variant: "destructive" - }); - } - }; - - const handleRegisterPasskey = async (values: z.infer) => { - try { - setLoading(true); - - // Start registration - const startRes = await api.post("/auth/passkey/register/start", { - name: values.name, - password: values.password - }); - - // Handle 2FA if required - if (startRes.data.data.codeRequested) { - // TODO: Handle 2FA verification - toast({ - title: "2FA Required", - description: "Two-factor authentication is required to register a passkey.", - variant: "destructive" - }); - return; - } - - const options = startRes.data.data; - - // Create passkey - const credential = await startRegistration(options); - - // Verify registration - await api.post("/auth/passkey/register/verify", { - credential - }); - - toast({ - title: "Success", - description: t('passkeyRegisterSuccess') - }); - - // Reset form and go back to list - registerForm.reset(); - setStep("list"); - - // Reload passkeys - await loadPasskeys(); - } catch (error) { - toast({ - title: "Error", - description: formatAxiosError(error, t('passkeyRegisterError')), - variant: "destructive" - }); - } finally { - setLoading(false); - } - }; - - const handleDeletePasskey = async (values: z.infer) => { - if (!selectedPasskey) return; - - try { - setLoading(true); - const encodedCredentialId = encodeURIComponent(selectedPasskey.credentialId); - await api.delete(`/auth/passkey/${encodedCredentialId}`, { - data: { password: values.password } - }); - - toast({ - title: "Success", - description: t('passkeyRemoveSuccess') - }); - - // Reset form and go back to list - deleteForm.reset(); - setStep("list"); - setSelectedPasskey(null); - - // Reload passkeys - await loadPasskeys(); - } catch (error) { - toast({ - title: "Error", - description: formatAxiosError(error, t('passkeyRemoveError')), - variant: "destructive" - }); - } finally { - setLoading(false); - } - }; - - function reset() { - registerForm.reset(); - deleteForm.reset(); - setStep("list"); - setSelectedPasskey(null); - setLoading(false); - } - - return ( - { - setOpen(val); - if (!val) reset(); - }} - > - - - {t('passkeyManage')} - - {t('passkeyDescription')} - - - -
- {step === "list" && ( - <> -
-

{t('passkeyList')}

- {passkeys.length === 0 ? ( -
-

- {t('passkeyNone')} -

-
- ) : ( -
- {passkeys.map((passkey) => ( -
-
-

{passkey.name}

-

- {t('passkeyLastUsed', { - date: new Date(passkey.lastUsed).toLocaleDateString() - })} -

-
- -
- ))} - {passkeys.length === 1 && ( -
- {t('passkeyRecommendation')} -
- )} -
- )} -
- -
- -
- - )} - - {step === "register" && ( -
-

{t('passkeyRegister')}

- - - ( - - {t('passkeyNameLabel')} - - - - - - )} - /> - - ( - - {t('password')} - - - - - - )} - /> - -
- - -
- - -
- )} - - {step === "delete" && selectedPasskey && ( -
-
-

Remove Passkey

-

- Enter your password to remove the passkey "{selectedPasskey.name}" -

-
- -
- - ( - - {t('password')} - - - - - - )} - /> - -
- - -
- - -
- )} -
-
- - - - - -
-
- ); -} \ No newline at end of file diff --git a/src/components/ProfileIcon.tsx b/src/components/ProfileIcon.tsx index cbed2c96..34a3799a 100644 --- a/src/components/ProfileIcon.tsx +++ b/src/components/ProfileIcon.tsx @@ -21,7 +21,7 @@ import { useState } from "react"; import { useUserContext } from "@app/hooks/useUserContext"; import Disable2FaForm from "./Disable2FaForm"; import Enable2FaForm from "./Enable2FaForm"; -import PasskeyForm from "./PasskeyForm"; +import SecurityKeyForm from "./SecurityKeyForm"; import SupporterStatus from "./SupporterStatus"; import { UserType } from "@server/types/UserTypes"; import LocaleSwitcher from '@app/components/LocaleSwitcher'; @@ -40,7 +40,7 @@ export default function ProfileIcon() { const [openEnable2fa, setOpenEnable2fa] = useState(false); const [openDisable2fa, setOpenDisable2fa] = useState(false); - const [openPasskey, setOpenPasskey] = useState(false); + const [openSecurityKey, setOpenSecurityKey] = useState(false); const t = useTranslations(); @@ -74,7 +74,7 @@ export default function ProfileIcon() { <> - +
@@ -133,9 +133,9 @@ export default function ProfileIcon() { )} setOpenPasskey(true)} + onClick={() => setOpenSecurityKey(true)} > - {t('passkeyManage')} + {t('securityKeyManage')} diff --git a/src/components/SecurityKeyForm.tsx b/src/components/SecurityKeyForm.tsx new file mode 100644 index 00000000..73b82c3a --- /dev/null +++ b/src/components/SecurityKeyForm.tsx @@ -0,0 +1,409 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useTranslations } from "next-intl"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { createApiClient } from "@app/lib/api"; +import { formatAxiosError } from "@app/lib/api"; +import { toast } from "@app/hooks/useToast"; +import { Button } from "@app/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@app/components/ui/dialog"; +import { startRegistration } from "@simplewebauthn/browser"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { Card, CardContent } from "@app/components/ui/card"; +import { Badge } from "@app/components/ui/badge"; +import { Loader2, KeyRound, Trash2, Plus, Shield } from "lucide-react"; +import { cn } from "@app/lib/cn"; + +type SecurityKeyFormProps = { + open: boolean; + setOpen: (open: boolean) => void; +}; + +type SecurityKey = { + credentialId: string; + name: string; + lastUsed: string; +}; + +type DeleteSecurityKeyData = { + credentialId: string; + name: string; +}; + +type RegisterFormValues = { + name: string; + password: string; +}; + +type DeleteFormValues = { + password: string; +}; + +type FieldProps = { + field: { + value: string; + onChange: (event: React.ChangeEvent) => void; + onBlur: () => void; + name: string; + ref: React.Ref; + }; +}; + +export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps) { + const t = useTranslations(); + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const [securityKeys, setSecurityKeys] = useState([]); + const [isRegistering, setIsRegistering] = useState(false); + const [showRegisterDialog, setShowRegisterDialog] = useState(false); + const [selectedSecurityKey, setSelectedSecurityKey] = useState(null); + + useEffect(() => { + loadSecurityKeys(); + }, []); + + const registerSchema = z.object({ + name: z.string().min(1, { message: t('securityKeyNameRequired') }), + password: z.string().min(1, { message: t('passwordRequired') }), + }); + + const deleteSchema = z.object({ + password: z.string().min(1, { message: t('passwordRequired') }), + }); + + const registerForm = useForm({ + resolver: zodResolver(registerSchema), + defaultValues: { + name: "", + password: "", + }, + }); + + const deleteForm = useForm({ + resolver: zodResolver(deleteSchema), + defaultValues: { + password: "", + }, + }); + + const loadSecurityKeys = async () => { + try { + const response = await api.get("/auth/passkey/list"); + setSecurityKeys(response.data.data); + } catch (error) { + toast({ + variant: "destructive", + description: formatAxiosError(error, t('securityKeyLoadError')), + }); + } + }; + + const handleRegisterSecurityKey = async (values: RegisterFormValues) => { + try { + setIsRegistering(true); + const startRes = await api.post("/auth/passkey/register/start", { + name: values.name, + password: values.password, + }); + + if (startRes.status === 202) { + toast({ + variant: "destructive", + description: "Two-factor authentication is required to register a security key.", + }); + return; + } + + const options = startRes.data.data; + const credential = await startRegistration(options); + + await api.post("/auth/passkey/register/verify", { + credential, + }); + + toast({ + description: t('securityKeyRegisterSuccess') + }); + + registerForm.reset(); + setShowRegisterDialog(false); + await loadSecurityKeys(); + } catch (error) { + toast({ + variant: "destructive", + description: formatAxiosError(error, t('securityKeyRegisterError')), + }); + } finally { + setIsRegistering(false); + } + }; + + const handleDeleteSecurityKey = async (values: DeleteFormValues) => { + if (!selectedSecurityKey) return; + + try { + const encodedCredentialId = encodeURIComponent(selectedSecurityKey.credentialId); + await api.delete(`/auth/passkey/${encodedCredentialId}`, { + data: { + password: values.password, + } + }); + + toast({ + description: t('securityKeyRemoveSuccess') + }); + + deleteForm.reset(); + setSelectedSecurityKey(null); + await loadSecurityKeys(); + } catch (error) { + toast({ + variant: "destructive", + description: formatAxiosError(error, t('securityKeyRemoveError')), + }); + } + }; + + const onOpenChange = (open: boolean) => { + if (open) { + loadSecurityKeys(); + } else { + registerForm.reset(); + deleteForm.reset(); + setSelectedSecurityKey(null); + setShowRegisterDialog(false); + } + setOpen(open); + }; + + return ( + <> + + + + + + {t('securityKeyManage')} + + + {t('securityKeyDescription')} + + + +
+
+

{t('securityKeyList')}

+
+ {securityKeys.length > 0 && ( + + {securityKeys.length} {securityKeys.length === 1 ? 'key' : 'keys'} + + )} + +
+
+ + {securityKeys.length > 0 ? ( +
+ {securityKeys.map((securityKey) => ( + + +
+
+ +
+
+

{securityKey.name}

+

+ {t('securityKeyLastUsed', { + date: new Date(securityKey.lastUsed).toLocaleDateString() + })} +

+
+
+ +
+
+ ))} +
+ ) : ( +
+ +

No security keys registered

+

Add a security key to enhance your account security

+
+ )} + + {securityKeys.length === 1 && ( + + {t('securityKeyRecommendation')} + + )} +
+
+
+ + + + + Register New Security Key + + Connect your security key and enter a name to identify it + + + +
+ + ( + + {t('securityKeyNameLabel')} + + + + + + )} + /> + ( + + {t('password')} + + + + + + )} + /> + + + + + + + +
+
+ + !open && setSelectedSecurityKey(null)}> + + + + + Remove Security Key + + + Enter your password to remove the security key "{selectedSecurityKey?.name}" + + + +
+ + ( + + {t('password')} + + + + + + )} + /> + + + + + + + +
+
+ + ); +} \ No newline at end of file From 6ccc05b18319a9e8285134f72567ad8bd3e52d04 Mon Sep 17 00:00:00 2001 From: Adrian Astles Date: Sat, 5 Jul 2025 18:56:32 +0800 Subject: [PATCH 07/12] Update security key error handling and user feedback. Improve user guidance for security key interactions and Implement proper error handling for permission denials and timing issues. --- messages/en-US.json | 8 ++- server/routers/auth/passkey.ts | 10 +++- src/components/LoginForm.tsx | 62 +++++++++++++++++------- src/components/SecurityKeyForm.tsx | 78 +++++++++++++++++++++++++----- 4 files changed, 127 insertions(+), 31 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index fda1a590..4a68fb17 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1152,5 +1152,11 @@ "securityKeyAuthError": "Failed to authenticate with security key", "securityKeyRecommendation": "Consider registering another security key on a different device to ensure you don't get locked out of your account.", "registering": "Registering...", - "securityKeyPrompt": "Please verify your identity using your security key. Make sure your security key is connected and ready." + "securityKeyPrompt": "Please verify your identity using your security key. Make sure your security key is connected and ready.", + "securityKeyBrowserNotSupported": "Your browser doesn't support security keys. Please use a modern browser like Chrome, Firefox, or Safari.", + "securityKeyPermissionDenied": "Please allow access to your security key to continue signing in.", + "securityKeyRemovedTooQuickly": "Please keep your security key connected until the sign-in process completes.", + "securityKeyNotSupported": "Your security key may not be compatible. Please try a different security key.", + "securityKeyUnknownError": "There was a problem using your security key. Please try again.", + "twoFactorRequired": "Two-factor authentication is required to register a security key." } diff --git a/server/routers/auth/passkey.ts b/server/routers/auth/passkey.ts index 835b9c8b..268f2144 100644 --- a/server/routers/auth/passkey.ts +++ b/server/routers/auth/passkey.ts @@ -32,7 +32,15 @@ import { verifyPassword } from "@server/auth/password"; import { unauthorized } from "@server/auth/unauthorizedResponse"; // The RP ID is the domain name of your application -const rpID = new URL(config.getRawConfig().app.dashboard_url).hostname; +const rpID = (() => { + const url = new URL(config.getRawConfig().app.dashboard_url); + // For localhost, we must use 'localhost' without port + if (url.hostname === 'localhost') { + return 'localhost'; + } + return url.hostname; +})(); + const rpName = "Pangolin"; const origin = config.getRawConfig().app.dashboard_url; diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index f806f77d..ac435043 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -183,6 +183,14 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { async function loginWithSecurityKey() { try { + // Check browser compatibility first + if (!window.PublicKeyCredential) { + setError(t('securityKeyBrowserNotSupported', { + defaultValue: "Your browser doesn't support security keys. Please use a modern browser like Chrome, Firefox, or Safari." + })); + return; + } + setLoading(true); setError(null); @@ -203,29 +211,49 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { const { tempSessionId, ...options } = startRes.data.data; // Perform WebAuthn authentication - const credential = await startAuthentication(options); + try { + const credential = await startAuthentication(options); + + // Verify authentication + const verifyRes = await api.post( + "/auth/passkey/authenticate/verify", + { credential }, + { + headers: { + 'X-Temp-Session-Id': tempSessionId + } + } + ); - // Verify authentication - const verifyRes = await api.post( - "/auth/passkey/authenticate/verify", - { credential }, - { - headers: { - 'X-Temp-Session-Id': tempSessionId + if (verifyRes) { + if (onLogin) { + await onLogin(); } } - ); - - if (verifyRes) { - if (onLogin) { - await onLogin(); + } catch (error: any) { + if (error.name === 'NotAllowedError') { + if (error.message.includes('denied permission')) { + setError(t('securityKeyPermissionDenied', { + defaultValue: "Please allow access to your security key to continue signing in." + })); + } else { + setError(t('securityKeyRemovedTooQuickly', { + defaultValue: "Please keep your security key connected until the sign-in process completes." + })); + } + } else if (error.name === 'NotSupportedError') { + setError(t('securityKeyNotSupported', { + defaultValue: "Your security key may not be compatible. Please try a different security key." + })); + } else { + setError(t('securityKeyUnknownError', { + defaultValue: "There was a problem using your security key. Please try again." + })); } + throw error; // Re-throw to be caught by outer catch } } catch (e) { - console.error(e); - setError(formatAxiosError(e, t('securityKeyAuthError', { - defaultValue: "Security key authentication failed" - }))); + console.error(formatAxiosError(e)); } finally { setLoading(false); } diff --git a/src/components/SecurityKeyForm.tsx b/src/components/SecurityKeyForm.tsx index 73b82c3a..cc90f64b 100644 --- a/src/components/SecurityKeyForm.tsx +++ b/src/components/SecurityKeyForm.tsx @@ -120,6 +120,17 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps) const handleRegisterSecurityKey = async (values: RegisterFormValues) => { try { + // Check browser compatibility first + if (!window.PublicKeyCredential) { + toast({ + variant: "destructive", + description: t('securityKeyBrowserNotSupported', { + defaultValue: "Your browser doesn't support security keys. Please use a modern browser like Chrome, Firefox, or Safari." + }) + }); + return; + } + setIsRegistering(true); const startRes = await api.post("/auth/passkey/register/start", { name: values.name, @@ -129,29 +140,72 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps) if (startRes.status === 202) { toast({ variant: "destructive", - description: "Two-factor authentication is required to register a security key.", + description: t('twoFactorRequired', { + defaultValue: "Two-factor authentication is required to register a security key." + }) }); return; } const options = startRes.data.data; - const credential = await startRegistration(options); + + try { + const credential = await startRegistration(options); - await api.post("/auth/passkey/register/verify", { - credential, - }); + await api.post("/auth/passkey/register/verify", { + credential, + }); - toast({ - description: t('securityKeyRegisterSuccess') - }); + toast({ + description: t('securityKeyRegisterSuccess', { + defaultValue: "Security key registered successfully" + }) + }); - registerForm.reset(); - setShowRegisterDialog(false); - await loadSecurityKeys(); + registerForm.reset(); + setShowRegisterDialog(false); + await loadSecurityKeys(); + } catch (error: any) { + if (error.name === 'NotAllowedError') { + if (error.message.includes('denied permission')) { + toast({ + variant: "destructive", + description: t('securityKeyPermissionDenied', { + defaultValue: "Please allow access to your security key to continue registration." + }) + }); + } else { + toast({ + variant: "destructive", + description: t('securityKeyRemovedTooQuickly', { + defaultValue: "Please keep your security key connected until the registration process completes." + }) + }); + } + } else if (error.name === 'NotSupportedError') { + toast({ + variant: "destructive", + description: t('securityKeyNotSupported', { + defaultValue: "Your security key may not be compatible. Please try a different security key." + }) + }); + } else { + toast({ + variant: "destructive", + description: t('securityKeyUnknownError', { + defaultValue: "There was a problem registering your security key. Please try again." + }) + }); + } + throw error; // Re-throw to be caught by outer catch + } } catch (error) { + console.error('Security key registration error:', error); toast({ variant: "destructive", - description: formatAxiosError(error, t('securityKeyRegisterError')), + description: formatAxiosError(error, t('securityKeyRegisterError', { + defaultValue: "Failed to register security key" + })) }); } finally { setIsRegistering(false); From 5009906385c44bce01e522490d6bb92e4b510ab7 Mon Sep 17 00:00:00 2001 From: Adrian Astles Date: Sat, 5 Jul 2025 21:51:31 +0800 Subject: [PATCH 08/12] renamed passkey to security key to stay aligned with the UI and other backend naming. --- server/db/pg/schema.ts | 16 ++- server/db/sqlite/migrate.ts | 27 +++- server/db/sqlite/schema.ts | 4 +- server/routers/auth/index.ts | 8 +- server/routers/auth/login.ts | 17 +-- .../auth/{passkey.ts => securityKey.ts} | 122 +++++++++--------- server/routers/external.ts | 24 ++-- server/setup/scriptsSqlite/1.4.0.ts | 2 +- server/setup/scriptsSqlite/1.7.0.ts | 6 +- server/setup/scriptsSqlite/1.8.0.ts | 10 +- server/setup/scriptsSqlite/1.8.1.ts | 8 +- src/components/LoginForm.tsx | 24 ++-- src/components/SecurityKeyForm.tsx | 8 +- 13 files changed, 158 insertions(+), 118 deletions(-) rename server/routers/auth/{passkey.ts => securityKey.ts} (83%) diff --git a/server/db/pg/schema.ts b/server/db/pg/schema.ts index 05389d42..045cba73 100644 --- a/server/db/pg/schema.ts +++ b/server/db/pg/schema.ts @@ -491,10 +491,24 @@ export const idpOrg = pgTable("idpOrg", { orgMapping: varchar("orgMapping") }); +export const securityKeys = pgTable("webauthnCredentials", { + credentialId: varchar("credentialId").primaryKey(), + userId: varchar("userId").notNull().references(() => users.userId, { + onDelete: "cascade" + }), + publicKey: varchar("publicKey").notNull(), + signCount: integer("signCount").notNull(), + transports: varchar("transports"), + name: varchar("name"), + lastUsed: varchar("lastUsed").notNull(), + dateCreated: varchar("dateCreated").notNull(), + securityKeyName: varchar("securityKeyName") +}); + export const webauthnChallenge = pgTable("webauthnChallenge", { sessionId: varchar("sessionId").primaryKey(), challenge: varchar("challenge").notNull(), - passkeyName: varchar("passkeyName"), + securityKeyName: varchar("securityKeyName"), userId: varchar("userId").references(() => users.userId, { onDelete: "cascade" }), diff --git a/server/db/sqlite/migrate.ts b/server/db/sqlite/migrate.ts index 10cec1a6..55399ea6 100644 --- a/server/db/sqlite/migrate.ts +++ b/server/db/sqlite/migrate.ts @@ -7,12 +7,37 @@ import type { Database as BetterSqlite3Database } from "better-sqlite3"; const migrationsFolder = path.join("server/migrations"); +const dropAllTables = (sqlite: BetterSqlite3Database) => { + console.log("Dropping all existing tables..."); + + // Disable foreign key checks + sqlite.pragma('foreign_keys = OFF'); + + // Get all tables + const tables = sqlite.prepare(` + SELECT name FROM sqlite_master + WHERE type='table' + AND name NOT LIKE 'sqlite_%' + `).all() as { name: string }[]; + + // Drop each table + for (const table of tables) { + console.log(`Dropping table: ${table.name}`); + sqlite.prepare(`DROP TABLE IF EXISTS "${table.name}"`).run(); + } + + // Re-enable foreign key checks + sqlite.pragma('foreign_keys = ON'); +}; + const runMigrations = async () => { console.log("Running migrations..."); try { // Initialize the database file with a valid SQLite header const sqlite = new Database(location) as BetterSqlite3Database; - sqlite.pragma('foreign_keys = ON'); + + // Drop all existing tables first + dropAllTables(sqlite); // Run the migrations migrate(db as any, { diff --git a/server/db/sqlite/schema.ts b/server/db/sqlite/schema.ts index 1a6dadef..1425385f 100644 --- a/server/db/sqlite/schema.ts +++ b/server/db/sqlite/schema.ts @@ -135,7 +135,7 @@ export const users = sqliteTable("user", { .default(false) }); -export const passkeys = sqliteTable("webauthnCredentials", { +export const securityKeys = sqliteTable("webauthnCredentials", { credentialId: text("credentialId").primaryKey(), userId: text("userId").notNull().references(() => users.userId, { onDelete: "cascade" @@ -151,7 +151,7 @@ export const passkeys = sqliteTable("webauthnCredentials", { export const webauthnChallenge = sqliteTable("webauthnChallenge", { sessionId: text("sessionId").primaryKey(), challenge: text("challenge").notNull(), - passkeyName: text("passkeyName"), + securityKeyName: text("securityKeyName"), userId: text("userId").references(() => users.userId, { onDelete: "cascade" }), diff --git a/server/routers/auth/index.ts b/server/routers/auth/index.ts index 4c7014a4..cc8fd630 100644 --- a/server/routers/auth/index.ts +++ b/server/routers/auth/index.ts @@ -6,10 +6,10 @@ export * from "./requestTotpSecret"; export * from "./disable2fa"; export * from "./verifyEmail"; export * from "./requestEmailVerificationCode"; -export * from "./changePassword"; -export * from "./requestPasswordReset"; export * from "./resetPassword"; -export * from "./checkResourceSession"; +export * from "./requestPasswordReset"; export * from "./setServerAdmin"; export * from "./initialSetupComplete"; -export * from "./passkey"; +export * from "./changePassword"; +export * from "./checkResourceSession"; +export * from "./securityKey"; diff --git a/server/routers/auth/login.ts b/server/routers/auth/login.ts index 72040a03..2dbbde1c 100644 --- a/server/routers/auth/login.ts +++ b/server/routers/auth/login.ts @@ -4,7 +4,7 @@ import { serializeSessionCookie } from "@server/auth/sessions/app"; import { db } from "@server/db"; -import { users, passkeys } from "@server/db"; +import { users, securityKeys } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; import { eq, and } from "drizzle-orm"; @@ -35,6 +35,7 @@ export type LoginBody = z.infer; export type LoginResponse = { codeRequested?: boolean; emailVerificationRequired?: boolean; + useSecurityKey?: boolean; }; export const dynamic = "force-dynamic"; @@ -91,15 +92,15 @@ export async function login( const existingUser = existingUserRes[0]; - // Check if user has passkeys registered - const userPasskeys = await db + // Check if user has security keys registered + const userSecurityKeys = await db .select() - .from(passkeys) - .where(eq(passkeys.userId, existingUser.userId)); + .from(securityKeys) + .where(eq(securityKeys.userId, existingUser.userId)); - if (userPasskeys.length > 0) { - return response<{ usePasskey: boolean }>(res, { - data: { usePasskey: true }, + if (userSecurityKeys.length > 0) { + return response<{ useSecurityKey: boolean }>(res, { + data: { useSecurityKey: true }, success: true, error: false, message: "Please use your security key to sign in", diff --git a/server/routers/auth/passkey.ts b/server/routers/auth/securityKey.ts similarity index 83% rename from server/routers/auth/passkey.ts rename to server/routers/auth/securityKey.ts index 268f2144..c5ee48e6 100644 --- a/server/routers/auth/passkey.ts +++ b/server/routers/auth/securityKey.ts @@ -4,7 +4,7 @@ import HttpCode from "@server/types/HttpCode"; import { fromError } from "zod-validation-error"; import { z } from "zod"; import { db } from "@server/db"; -import { User, passkeys, users, webauthnChallenge } from "@server/db"; +import { User, securityKeys, users, webauthnChallenge } from "@server/db"; import { eq, and, lt } from "drizzle-orm"; import { response } from "@server/lib"; import logger from "@server/logger"; @@ -55,14 +55,14 @@ setInterval(async () => { await db .delete(webauthnChallenge) .where(lt(webauthnChallenge.expiresAt, now)); - logger.debug("Cleaned up expired passkey challenges"); + logger.debug("Cleaned up expired security key challenges"); } catch (error) { - logger.error("Failed to clean up expired passkey challenges", error); + logger.error("Failed to clean up expired security key challenges", error); } }, 5 * 60 * 1000); // Helper functions for challenge management -async function storeChallenge(sessionId: string, challenge: string, passkeyName?: string, userId?: string) { +async function storeChallenge(sessionId: string, challenge: string, securityKeyName?: string, userId?: string) { const expiresAt = Date.now() + (5 * 60 * 1000); // 5 minutes // Delete any existing challenge for this session @@ -72,7 +72,7 @@ async function storeChallenge(sessionId: string, challenge: string, passkeyName? await db.insert(webauthnChallenge).values({ sessionId, challenge, - passkeyName, + securityKeyName, userId, expiresAt }); @@ -102,7 +102,7 @@ async function clearChallenge(sessionId: string) { await db.delete(webauthnChallenge).where(eq(webauthnChallenge.sessionId, sessionId)); } -export const registerPasskeyBody = z.object({ +export const registerSecurityKeyBody = z.object({ name: z.string().min(1), password: z.string().min(1) }).strict(); @@ -119,7 +119,7 @@ export const verifyAuthenticationBody = z.object({ credential: z.any() }).strict(); -export const deletePasskeyBody = z.object({ +export const deleteSecurityKeyBody = z.object({ password: z.string().min(1) }).strict(); @@ -128,7 +128,7 @@ export async function startRegistration( res: Response, next: NextFunction ): Promise { - const parsedBody = registerPasskeyBody.safeParse(req.body); + const parsedBody = registerSecurityKeyBody.safeParse(req.body); if (!parsedBody.success) { return next( @@ -142,12 +142,12 @@ export async function startRegistration( const { name, password } = parsedBody.data; const user = req.user as User; - // Only allow internal users to use passkeys + // Only allow internal users to use security keys if (user.type !== UserType.Internal) { return next( createHttpError( HttpCode.BAD_REQUEST, - "Passkeys are only available for internal users" + "Security keys are only available for internal users" ) ); } @@ -170,13 +170,13 @@ export async function startRegistration( }); } - // Get existing passkeys for user - const existingPasskeys = await db + // Get existing security keys for user + const existingSecurityKeys = await db .select() - .from(passkeys) - .where(eq(passkeys.userId, user.userId)); + .from(securityKeys) + .where(eq(securityKeys.userId, user.userId)); - const excludeCredentials = existingPasskeys.map(key => ({ + const excludeCredentials = existingSecurityKeys.map(key => ({ id: Buffer.from(key.credentialId, 'base64').toString('base64url'), type: 'public-key' as const, transports: key.transports ? JSON.parse(key.transports) as AuthenticatorTransport[] : undefined @@ -237,12 +237,12 @@ export async function verifyRegistration( const { credential } = parsedBody.data; const user = req.user as User; - // Only allow internal users to use passkeys + // Only allow internal users to use security keys if (user.type !== UserType.Internal) { return next( createHttpError( HttpCode.BAD_REQUEST, - "Passkeys are only available for internal users" + "Security keys are only available for internal users" ) ); } @@ -279,14 +279,14 @@ export async function verifyRegistration( ); } - // Store the passkey in the database - await db.insert(passkeys).values({ + // Store the security key in the database + await db.insert(securityKeys).values({ credentialId: Buffer.from(registrationInfo.credentialID).toString('base64'), userId: user.userId, publicKey: Buffer.from(registrationInfo.credentialPublicKey).toString('base64'), signCount: registrationInfo.counter || 0, transports: credential.response.transports ? JSON.stringify(credential.response.transports) : null, - name: challengeData.passkeyName, + name: challengeData.securityKeyName, lastUsed: new Date().toISOString(), dateCreated: new Date().toISOString() }); @@ -298,7 +298,7 @@ export async function verifyRegistration( data: null, success: true, error: false, - message: "Passkey registered successfully", + message: "Security key registered successfully", status: HttpCode.OK }); } catch (error) { @@ -312,34 +312,34 @@ export async function verifyRegistration( } } -export async function listPasskeys( +export async function listSecurityKeys( req: Request, res: Response, next: NextFunction ): Promise { const user = req.user as User; - // Only allow internal users to use passkeys + // Only allow internal users to use security keys if (user.type !== UserType.Internal) { return next( createHttpError( HttpCode.BAD_REQUEST, - "Passkeys are only available for internal users" + "Security keys are only available for internal users" ) ); } try { - const userPasskeys = await db + const userSecurityKeys = await db .select() - .from(passkeys) - .where(eq(passkeys.userId, user.userId)); + .from(securityKeys) + .where(eq(securityKeys.userId, user.userId)); - return response(res, { - data: userPasskeys, + return response(res, { + data: userSecurityKeys, success: true, error: false, - message: "Passkeys retrieved successfully", + message: "Security keys retrieved successfully", status: HttpCode.OK }); } catch (error) { @@ -347,13 +347,13 @@ export async function listPasskeys( return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, - "Failed to retrieve passkeys" + "Failed to retrieve security keys" ) ); } } -export async function deletePasskey( +export async function deleteSecurityKey( req: Request, res: Response, next: NextFunction @@ -362,7 +362,7 @@ export async function deletePasskey( const credentialId = decodeURIComponent(encodedCredentialId); const user = req.user as User; - const parsedBody = deletePasskeyBody.safeParse(req.body); + const parsedBody = deleteSecurityKeyBody.safeParse(req.body); if (!parsedBody.success) { return next( @@ -375,12 +375,12 @@ export async function deletePasskey( const { password } = parsedBody.data; - // Only allow internal users to use passkeys + // Only allow internal users to use security keys if (user.type !== UserType.Internal) { return next( createHttpError( HttpCode.BAD_REQUEST, - "Passkeys are only available for internal users" + "Security keys are only available for internal users" ) ); } @@ -404,17 +404,17 @@ export async function deletePasskey( } await db - .delete(passkeys) + .delete(securityKeys) .where(and( - eq(passkeys.credentialId, credentialId), - eq(passkeys.userId, user.userId) + eq(securityKeys.credentialId, credentialId), + eq(securityKeys.userId, user.userId) )); return response(res, { data: null, success: true, error: false, - message: "Passkey deleted successfully", + message: "Security key deleted successfully", status: HttpCode.OK }); } catch (error) { @@ -422,7 +422,7 @@ export async function deletePasskey( return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, - "Failed to delete passkey" + "Failed to delete security key" ) ); } @@ -454,7 +454,7 @@ export async function startAuthentication( }> = []; let userId; - // If email is provided, get passkeys for that specific user + // If email is provided, get security keys for that specific user if (email) { const [user] = await db .select() @@ -473,27 +473,27 @@ export async function startAuthentication( userId = user.userId; - const userPasskeys = await db + const userSecurityKeys = await db .select() - .from(passkeys) - .where(eq(passkeys.userId, user.userId)); + .from(securityKeys) + .where(eq(securityKeys.userId, user.userId)); - if (userPasskeys.length === 0) { + if (userSecurityKeys.length === 0) { return next( createHttpError( HttpCode.BAD_REQUEST, - "No passkeys registered for this user" + "No security keys registered for this user" ) ); } - allowCredentials = userPasskeys.map(key => ({ + allowCredentials = userSecurityKeys.map(key => ({ id: Buffer.from(key.credentialId, 'base64'), type: 'public-key' as const, transports: key.transports ? JSON.parse(key.transports) as AuthenticatorTransport[] : undefined })); } else { - // If no email provided, allow any passkey (for resident key authentication) + // If no email provided, allow any security key (for resident key authentication) allowCredentials = []; } @@ -570,15 +570,15 @@ export async function verifyAuthentication( ); } - // Find the passkey in database + // Find the security key in database const credentialId = Buffer.from(credential.id, 'base64').toString('base64'); - const [passkey] = await db + const [securityKey] = await db .select() - .from(passkeys) - .where(eq(passkeys.credentialId, credentialId)) + .from(securityKeys) + .where(eq(securityKeys.credentialId, credentialId)) .limit(1); - if (!passkey) { + if (!securityKey) { return next( createHttpError( HttpCode.BAD_REQUEST, @@ -591,14 +591,14 @@ export async function verifyAuthentication( const [user] = await db .select() .from(users) - .where(eq(users.userId, passkey.userId)) + .where(eq(users.userId, securityKey.userId)) .limit(1); if (!user || user.type !== UserType.Internal) { return next( createHttpError( HttpCode.BAD_REQUEST, - "User not found or not authorized for passkey authentication" + "User not found or not authorized for security key authentication" ) ); } @@ -609,10 +609,10 @@ export async function verifyAuthentication( expectedOrigin: origin, expectedRPID: rpID, authenticator: { - credentialID: Buffer.from(passkey.credentialId, 'base64'), - credentialPublicKey: Buffer.from(passkey.publicKey, 'base64'), - counter: passkey.signCount, - transports: passkey.transports ? JSON.parse(passkey.transports) as AuthenticatorTransport[] : undefined + credentialID: Buffer.from(securityKey.credentialId, 'base64'), + credentialPublicKey: Buffer.from(securityKey.publicKey, 'base64'), + counter: securityKey.signCount, + transports: securityKey.transports ? JSON.parse(securityKey.transports) as AuthenticatorTransport[] : undefined }, requireUserVerification: false }); @@ -630,12 +630,12 @@ export async function verifyAuthentication( // Update sign count await db - .update(passkeys) + .update(securityKeys) .set({ signCount: authenticationInfo.newCounter, lastUsed: new Date().toISOString() }) - .where(eq(passkeys.credentialId, credentialId)); + .where(eq(securityKeys.credentialId, credentialId)); // Create session for the user const { createSession, generateSessionToken, serializeSessionCookie } = await import("@server/auth/sessions/app"); diff --git a/server/routers/external.ts b/server/routers/external.ts index 1eebb531..c3c2e763 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -789,35 +789,35 @@ authRouter.post("/idp/:idpId/oidc/validate-callback", idp.validateOidcCallback); authRouter.put("/set-server-admin", auth.setServerAdmin); authRouter.get("/initial-setup-complete", auth.initialSetupComplete); -// Passkey routes +// Security Key routes authRouter.post( - "/passkey/register/start", + "/security-key/register/start", rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes - max: 5, // Allow 5 passkey registrations per 15 minutes per IP - keyGenerator: (req) => `passkeyRegister:${req.ip}:${req.user?.userId}`, + max: 5, // Allow 5 security key registrations per 15 minutes per IP + keyGenerator: (req) => `securityKeyRegister:${req.ip}:${req.user?.userId}`, handler: (req, res, next) => { - const message = `You can only register ${5} passkeys every ${15} minutes. Please try again later.`; + const message = `You can only register ${5} security keys every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); } }), verifySessionUserMiddleware, auth.startRegistration ); -authRouter.post("/passkey/register/verify", verifySessionUserMiddleware, auth.verifyRegistration); +authRouter.post("/security-key/register/verify", verifySessionUserMiddleware, auth.verifyRegistration); authRouter.post( - "/passkey/authenticate/start", + "/security-key/authenticate/start", rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 10, // Allow 10 authentication attempts per 15 minutes per IP - keyGenerator: (req) => `passkeyAuth:${req.ip}`, + keyGenerator: (req) => `securityKeyAuth:${req.ip}`, handler: (req, res, next) => { - const message = `You can only attempt passkey authentication ${10} times every ${15} minutes. Please try again later.`; + const message = `You can only attempt security key authentication ${10} times every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); } }), auth.startAuthentication ); -authRouter.post("/passkey/authenticate/verify", auth.verifyAuthentication); -authRouter.get("/passkey/list", verifySessionUserMiddleware, auth.listPasskeys); -authRouter.delete("/passkey/:credentialId", verifySessionUserMiddleware, auth.deletePasskey); +authRouter.post("/security-key/authenticate/verify", auth.verifyAuthentication); +authRouter.get("/security-key/list", verifySessionUserMiddleware, auth.listSecurityKeys); +authRouter.delete("/security-key/:credentialId", verifySessionUserMiddleware, auth.deleteSecurityKey); diff --git a/server/setup/scriptsSqlite/1.4.0.ts b/server/setup/scriptsSqlite/1.4.0.ts index e961b287..3885b4bf 100644 --- a/server/setup/scriptsSqlite/1.4.0.ts +++ b/server/setup/scriptsSqlite/1.4.0.ts @@ -8,7 +8,7 @@ export default async function migration() { try { db.transaction((trx) => { - trx.run(sql`CREATE TABLE 'passkey' ( + trx.run(sql`CREATE TABLE 'securityKey' ( 'credentialId' text PRIMARY KEY NOT NULL, 'userId' text NOT NULL, 'publicKey' text NOT NULL, diff --git a/server/setup/scriptsSqlite/1.7.0.ts b/server/setup/scriptsSqlite/1.7.0.ts index 153d3724..59166827 100644 --- a/server/setup/scriptsSqlite/1.7.0.ts +++ b/server/setup/scriptsSqlite/1.7.0.ts @@ -14,7 +14,7 @@ export default async function migration() { db.pragma("foreign_keys = OFF"); db.transaction(() => { db.exec(` - CREATE TABLE IF NOT EXISTS passkey ( + CREATE TABLE IF NOT EXISTS securityKey ( credentialId TEXT PRIMARY KEY, userId TEXT NOT NULL, publicKey TEXT NOT NULL, @@ -28,9 +28,9 @@ export default async function migration() { `); })(); // executes the transaction immediately db.pragma("foreign_keys = ON"); - console.log(`Created passkey table`); + console.log(`Created securityKey table`); } catch (e) { - console.error("Unable to create passkey table"); + console.error("Unable to create securityKey table"); console.error(e); throw e; } diff --git a/server/setup/scriptsSqlite/1.8.0.ts b/server/setup/scriptsSqlite/1.8.0.ts index 19ff4052..7777d50e 100644 --- a/server/setup/scriptsSqlite/1.8.0.ts +++ b/server/setup/scriptsSqlite/1.8.0.ts @@ -14,22 +14,22 @@ export default async function migration() { db.pragma("foreign_keys = OFF"); db.transaction(() => { db.exec(` - CREATE TABLE IF NOT EXISTS passkeyChallenge ( + CREATE TABLE IF NOT EXISTS securityKeyChallenge ( sessionId TEXT PRIMARY KEY, challenge TEXT NOT NULL, - passkeyName TEXT, + securityKeyName TEXT, userId TEXT, expiresAt INTEGER NOT NULL, FOREIGN KEY (userId) REFERENCES user(id) ON DELETE CASCADE ); - CREATE INDEX IF NOT EXISTS idx_passkeyChallenge_expiresAt ON passkeyChallenge(expiresAt); + CREATE INDEX IF NOT EXISTS idx_securityKeyChallenge_expiresAt ON securityKeyChallenge(expiresAt); `); })(); // executes the transaction immediately db.pragma("foreign_keys = ON"); - console.log(`Created passkeyChallenge table`); + console.log(`Created securityKeyChallenge table`); } catch (e) { - console.error("Unable to create passkeyChallenge table"); + console.error("Unable to create securityKeyChallenge table"); console.error(e); throw e; } diff --git a/server/setup/scriptsSqlite/1.8.1.ts b/server/setup/scriptsSqlite/1.8.1.ts index 473a354c..560b314a 100644 --- a/server/setup/scriptsSqlite/1.8.1.ts +++ b/server/setup/scriptsSqlite/1.8.1.ts @@ -6,21 +6,21 @@ export default async function migrate() { // Rename the table await db.run(` - ALTER TABLE passkeyChallenge RENAME TO webauthnChallenge; + ALTER TABLE securityKeyChallenge RENAME TO webauthnChallenge; `); console.log("Successfully renamed table"); // Rename the index await db.run(` - DROP INDEX IF EXISTS idx_passkeyChallenge_expiresAt; + DROP INDEX IF EXISTS idx_securityKeyChallenge_expiresAt; CREATE INDEX IF NOT EXISTS idx_webauthnChallenge_expiresAt ON webauthnChallenge(expiresAt); `); console.log("Successfully updated index"); - console.log(`Renamed passkeyChallenge table to webauthnChallenge`); + console.log(`Renamed securityKeyChallenge table to webauthnChallenge`); return true; } catch (error: any) { - console.error("Unable to rename passkeyChallenge table:", error); + console.error("Unable to rename securityKeyChallenge table:", error); console.error("Error details:", error.message); return false; } diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index ac435043..e14315eb 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -4,8 +4,8 @@ import { useState } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import * as z from "zod"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; +import { Button } from "@app/components/ui/button"; +import { Input } from "@app/components/ui/input"; import { Form, FormControl, @@ -13,15 +13,15 @@ import { FormItem, FormLabel, FormMessage -} from "@/components/ui/form"; +} from "@app/components/ui/form"; import { Card, CardContent, CardDescription, CardHeader, CardTitle -} from "@/components/ui/card"; -import { Alert, AlertDescription } from "@/components/ui/alert"; +} from "@app/components/ui/card"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; import { LoginResponse } from "@server/routers/auth"; import { useRouter } from "next/navigation"; import { AxiosResponse } from "axios"; @@ -120,8 +120,8 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { setError(null); const data = res.data.data; - if (data?.usePasskey) { - await initiateSecurityKeyAuth(); + if (data?.useSecurityKey) { + setShowSecurityKeyPrompt(true); return; } @@ -197,7 +197,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { const email = form.getValues().email; // Start WebAuthn authentication - const startRes = await api.post("/auth/passkey/authenticate/start", { + const startRes = await api.post("/auth/security-key/authenticate/start", { email: email || undefined }); @@ -216,7 +216,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { // Verify authentication const verifyRes = await api.post( - "/auth/passkey/authenticate/verify", + "/auth/security-key/authenticate/verify", { credential }, { headers: { @@ -355,9 +355,9 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { pattern={ REGEXP_ONLY_DIGITS_AND_CHARS } - onChange={(e) => { - field.onChange(e); - if (e.length === 6) { + onChange={(value: string) => { + field.onChange(value); + if (value.length === 6) { mfaForm.handleSubmit(onSubmit)(); } }} diff --git a/src/components/SecurityKeyForm.tsx b/src/components/SecurityKeyForm.tsx index cc90f64b..a83165bc 100644 --- a/src/components/SecurityKeyForm.tsx +++ b/src/components/SecurityKeyForm.tsx @@ -108,7 +108,7 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps) const loadSecurityKeys = async () => { try { - const response = await api.get("/auth/passkey/list"); + const response = await api.get("/auth/security-key/list"); setSecurityKeys(response.data.data); } catch (error) { toast({ @@ -132,7 +132,7 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps) } setIsRegistering(true); - const startRes = await api.post("/auth/passkey/register/start", { + const startRes = await api.post("/auth/security-key/register/start", { name: values.name, password: values.password, }); @@ -152,7 +152,7 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps) try { const credential = await startRegistration(options); - await api.post("/auth/passkey/register/verify", { + await api.post("/auth/security-key/register/verify", { credential, }); @@ -217,7 +217,7 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps) try { const encodedCredentialId = encodeURIComponent(selectedSecurityKey.credentialId); - await api.delete(`/auth/passkey/${encodedCredentialId}`, { + await api.delete(`/auth/security-key/${encodedCredentialId}`, { data: { password: values.password, } From a093c54b162ad378b4978f4dfd8ca0f84f6771db Mon Sep 17 00:00:00 2001 From: Adrian Astles Date: Sun, 6 Jul 2025 20:41:26 +0800 Subject: [PATCH 09/12] simplified security key management interface. --- messages/en-US.json | 2 +- src/components/SecurityKeyForm.tsx | 19 ++++++------------- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 4a68fb17..a0b570fc 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1150,7 +1150,7 @@ "securityKeyLoadError": "Failed to load security keys", "securityKeyLogin": "Sign in with security key", "securityKeyAuthError": "Failed to authenticate with security key", - "securityKeyRecommendation": "Consider registering another security key on a different device to ensure you don't get locked out of your account.", + "securityKeyRecommendation": "Tip: Register a backup security key on another device to ensure you always have access to your account.", "registering": "Registering...", "securityKeyPrompt": "Please verify your identity using your security key. Make sure your security key is connected and ready.", "securityKeyBrowserNotSupported": "Your browser doesn't support security keys. Please use a modern browser like Chrome, Firefox, or Safari.", diff --git a/src/components/SecurityKeyForm.tsx b/src/components/SecurityKeyForm.tsx index a83165bc..bb76ee56 100644 --- a/src/components/SecurityKeyForm.tsx +++ b/src/components/SecurityKeyForm.tsx @@ -267,19 +267,12 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps)

{t('securityKeyList')}

-
- {securityKeys.length > 0 && ( - - {securityKeys.length} {securityKeys.length === 1 ? 'key' : 'keys'} - - )} - -
+
{securityKeys.length > 0 ? ( From 813992141a8c68c8880f6f5fa42e998eb6d4a4e3 Mon Sep 17 00:00:00 2001 From: Adrian Astles Date: Mon, 7 Jul 2025 01:08:01 +0800 Subject: [PATCH 10/12] Fix version mismatch with package-lock.json with package.json --- package-lock.json | 73 ++++++++++++++++++++++------------------------- 1 file changed, 34 insertions(+), 39 deletions(-) diff --git a/package-lock.json b/package-lock.json index 37cd4e0e..5adc125f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -124,8 +124,7 @@ "tsc-alias": "1.8.16", "tsx": "4.20.3", "typescript": "^5", - "typescript-eslint": "^8.35.0", - "yargs": "18.0.0" + "typescript-eslint": "^8.35.0" } }, "node_modules/@alloc/quick-lru": { @@ -199,16 +198,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -230,13 +219,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.27.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.7.tgz", - "integrity": "sha512-qnzXzDXdr/po3bOTbTIQZ7+TxNKxpkN5IifVLXS+r7qwynkZfPyjZfE7hCXbo7IoO9TNcSyibgONsf2HauUd3Q==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", + "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.27.7" + "@babel/types": "^7.28.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -279,10 +268,20 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/traverse/node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/@babel/types": { - "version": "7.27.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.7.tgz", - "integrity": "sha512-8OLQgDScAOHXnAz2cV+RfzzNMipuLVBz2biuAJFMV9bfkNf393je3VM8CLkjQodW5+iWsSJdSgSWT6rsZoXHPw==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz", + "integrity": "sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg==", "dev": true, "license": "MIT", "dependencies": { @@ -1516,9 +1515,9 @@ "license": "MIT" }, "node_modules/@hookform/resolvers": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz", - "integrity": "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==", + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.9.1.tgz", + "integrity": "sha512-ud2HqmGBM0P0IABqoskKWI6PEf6ZDDBZkFqe2Vnl+mTHCEHzr3ISjjZyCwTjC/qpL25JC9aIDkloQejvMeq0ug==", "license": "MIT", "peerDependencies": { "react-hook-form": "^7.0.0" @@ -2023,6 +2022,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, "license": "ISC", "dependencies": { "minipass": "^7.0.4" @@ -2032,9 +2032,9 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.11.tgz", - "integrity": "sha512-C512c1ytBTio4MrpWKlJpyFHT6+qfFL8SZ58zBzJ1OOzUEjHeF1BtjY2fH7n4x/g2OV/KiiMLAivOp1DXmiMMw==", + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", + "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", "dev": true, "license": "MIT", "dependencies": { @@ -6245,6 +6245,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=18" @@ -6301,7 +6302,6 @@ "version": "9.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^7.2.0", @@ -6316,7 +6316,6 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -6329,14 +6328,12 @@ "version": "10.4.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", - "dev": true, "license": "MIT" }, "node_modules/cliui/node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", @@ -6354,7 +6351,6 @@ "version": "9.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", @@ -7553,7 +7549,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -8498,7 +8493,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -8508,7 +8502,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -8726,6 +8719,7 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, "license": "ISC" }, "node_modules/graphemer": { @@ -9473,6 +9467,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "dev": true, "license": "ISC", "engines": { "node": ">=16" @@ -10326,6 +10321,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", + "dev": true, "license": "MIT", "dependencies": { "minipass": "^7.1.2" @@ -10338,6 +10334,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "dev": true, "license": "MIT", "bin": { "mkdirp": "dist/cjs/src/bin.js" @@ -15735,6 +15732,7 @@ "version": "7.4.3", "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "dev": true, "license": "ISC", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", @@ -16373,6 +16371,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^3.1.1" @@ -16669,7 +16668,6 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -16679,6 +16677,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=18" @@ -16700,7 +16699,6 @@ "version": "18.0.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", - "dev": true, "license": "MIT", "dependencies": { "cliui": "^9.0.1", @@ -16718,7 +16716,6 @@ "version": "22.0.0", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", - "dev": true, "license": "ISC", "engines": { "node": "^20.19.0 || ^22.12.0 || >=23" @@ -16728,14 +16725,12 @@ "version": "10.4.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", - "dev": true, "license": "MIT" }, "node_modules/yargs/node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", @@ -16796,4 +16791,4 @@ } } } -} \ No newline at end of file +} From f0a1c10ec5488443b74b0a6b2a271bf9aa3eb9fe Mon Sep 17 00:00:00 2001 From: Adrian Astles <49412215+adrianeastles@users.noreply.github.com> Date: Mon, 7 Jul 2025 17:48:23 +0800 Subject: [PATCH 11/12] fix(auth): improve security key login flow. - Fix login to verify password before showing security key prompt - Add proper 2FA verification flow when deleting security keys Previously, users with security keys would see the security key prompt even if they entered an incorrect password. Now the password is verified first. Additionally, security key deletion now properly handles 2FA verification when enabled. --- server/routers/auth/login.ts | 32 ++--- server/routers/auth/securityKey.ts | 55 ++++--- src/components/LoginForm.tsx | 221 ++++++++++++++--------------- src/components/SecurityKeyForm.tsx | 141 +++++++++++++++++- 4 files changed, 294 insertions(+), 155 deletions(-) diff --git a/server/routers/auth/login.ts b/server/routers/auth/login.ts index 2dbbde1c..091910c1 100644 --- a/server/routers/auth/login.ts +++ b/server/routers/auth/login.ts @@ -92,22 +92,6 @@ export async function login( const existingUser = existingUserRes[0]; - // Check if user has security keys registered - const userSecurityKeys = await db - .select() - .from(securityKeys) - .where(eq(securityKeys.userId, existingUser.userId)); - - if (userSecurityKeys.length > 0) { - return response<{ useSecurityKey: boolean }>(res, { - data: { useSecurityKey: true }, - success: true, - error: false, - message: "Please use your security key to sign in", - status: HttpCode.UNAUTHORIZED - }); - } - const validPassword = await verifyPassword( password, existingUser.passwordHash! @@ -126,6 +110,22 @@ export async function login( ); } + // Check if user has security keys registered + const userSecurityKeys = await db + .select() + .from(securityKeys) + .where(eq(securityKeys.userId, existingUser.userId)); + + if (userSecurityKeys.length > 0) { + return response(res, { + data: { useSecurityKey: true }, + success: true, + error: false, + message: "Security key authentication required", + status: HttpCode.OK + }); + } + if (existingUser.twoFactorEnabled) { if (!code) { return response<{ codeRequested: boolean }>(res, { diff --git a/server/routers/auth/securityKey.ts b/server/routers/auth/securityKey.ts index c5ee48e6..6f681975 100644 --- a/server/routers/auth/securityKey.ts +++ b/server/routers/auth/securityKey.ts @@ -30,6 +30,7 @@ import config from "@server/lib/config"; import { UserType } from "@server/types/UserTypes"; import { verifyPassword } from "@server/auth/password"; import { unauthorized } from "@server/auth/unauthorizedResponse"; +import { verifyTotpCode } from "@server/auth/totp"; // The RP ID is the domain name of your application const rpID = (() => { @@ -120,7 +121,8 @@ export const verifyAuthenticationBody = z.object({ }).strict(); export const deleteSecurityKeyBody = z.object({ - password: z.string().min(1) + password: z.string().min(1), + code: z.string().optional() }).strict(); export async function startRegistration( @@ -159,17 +161,6 @@ export async function startRegistration( return next(unauthorized()); } - // If user has 2FA enabled, require a code - if (user.twoFactorEnabled) { - return response<{ codeRequested: boolean }>(res, { - data: { codeRequested: true }, - success: true, - error: false, - message: "Two-factor authentication required", - status: HttpCode.ACCEPTED - }); - } - // Get existing security keys for user const existingSecurityKeys = await db .select() @@ -373,7 +364,7 @@ export async function deleteSecurityKey( ); } - const { password } = parsedBody.data; + const { password, code } = parsedBody.data; // Only allow internal users to use security keys if (user.type !== UserType.Internal) { @@ -392,15 +383,37 @@ export async function deleteSecurityKey( return next(unauthorized()); } - // If user has 2FA enabled, require a code + // If user has 2FA enabled, require and verify the code if (user.twoFactorEnabled) { - return response<{ codeRequested: boolean }>(res, { - data: { codeRequested: true }, - success: true, - error: false, - message: "Two-factor authentication required", - status: HttpCode.ACCEPTED - }); + if (!code) { + return response<{ codeRequested: boolean }>(res, { + data: { codeRequested: true }, + success: true, + error: false, + message: "Two-factor authentication required", + status: HttpCode.ACCEPTED + }); + } + + const validOTP = await verifyTotpCode( + code, + user.twoFactorSecret!, + user.userId + ); + + if (!validOTP) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Two-factor code incorrect. Email: ${user.email}. IP: ${req.ip}.` + ); + } + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "The two-factor code you entered is incorrect" + ) + ); + } } await db diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index e14315eb..c2fa43cf 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -98,108 +98,12 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { async function initiateSecurityKeyAuth() { setShowSecurityKeyPrompt(true); - setError(null); - await loginWithSecurityKey(); - setShowSecurityKeyPrompt(false); - } - - async function onSubmit(values: any) { - const { email, password } = form.getValues(); - const { code } = mfaForm.getValues(); - setLoading(true); + setError(null); try { - const res = await api.post>("/auth/login", { - email, - password, - code - }); - - if (res) { - setError(null); - const data = res.data.data; - - if (data?.useSecurityKey) { - setShowSecurityKeyPrompt(true); - return; - } - - if (data?.codeRequested) { - setMfaRequested(true); - setLoading(false); - mfaForm.reset(); - return; - } - - if (data?.emailVerificationRequired) { - if (redirect) { - router.push(`/auth/verify-email?redirect=${redirect}`); - } else { - router.push("/auth/verify-email"); - } - return; - } - - if (onLogin) { - await onLogin(); - } - } - } catch (e) { - console.error(e); - const errorMessage = formatAxiosError(e, t('loginError')); - if (errorMessage.includes("Please use your security key")) { - await initiateSecurityKeyAuth(); - return; - } - setError(errorMessage); - } - - setLoading(false); - } - - async function loginWithIdp(idpId: number) { - try { - const res = await api.post>( - `/auth/idp/${idpId}/oidc/generate-url`, - { - redirectUrl: redirect || "/" - } - ); - - console.log(res); - - if (!res) { - setError(t('loginError')); - return; - } - - const data = res.data.data; - window.location.href = data.redirectUrl; - } catch (e) { - console.error(formatAxiosError(e)); - } - } - - async function loginWithSecurityKey() { - try { - // Check browser compatibility first - if (!window.PublicKeyCredential) { - setError(t('securityKeyBrowserNotSupported', { - defaultValue: "Your browser doesn't support security keys. Please use a modern browser like Chrome, Firefox, or Safari." - })); - return; - } - - setLoading(true); - setError(null); - - const email = form.getValues().email; - - // Start WebAuthn authentication - const startRes = await api.post("/auth/security-key/authenticate/start", { - email: email || undefined - }); + // Start WebAuthn authentication without email + const startRes = await api.post("/auth/security-key/authenticate/start", {}); if (!startRes) { setError(t('securityKeyAuthError', { @@ -250,12 +154,104 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { defaultValue: "There was a problem using your security key. Please try again." })); } - throw error; // Re-throw to be caught by outer catch } - } catch (e) { - console.error(formatAxiosError(e)); + } catch (e: any) { + if (e.isAxiosError) { + setError(formatAxiosError(e, t('securityKeyAuthError', { + defaultValue: "Failed to authenticate with security key" + }))); + } else { + console.error(e); + setError(e.message || t('securityKeyAuthError', { + defaultValue: "Failed to authenticate with security key" + })); + } } finally { setLoading(false); + setShowSecurityKeyPrompt(false); + } + } + + async function onSubmit(values: any) { + const { email, password } = form.getValues(); + const { code } = mfaForm.getValues(); + + setLoading(true); + setError(null); + setShowSecurityKeyPrompt(false); + + try { + const res = await api.post>("/auth/login", { + email, + password, + code + }); + + const data = res.data.data; + + if (data?.useSecurityKey) { + await initiateSecurityKeyAuth(); + return; + } + + if (data?.codeRequested) { + setMfaRequested(true); + setLoading(false); + mfaForm.reset(); + return; + } + + if (data?.emailVerificationRequired) { + if (redirect) { + router.push(`/auth/verify-email?redirect=${redirect}`); + } else { + router.push("/auth/verify-email"); + } + return; + } + + if (onLogin) { + await onLogin(); + } + } catch (e: any) { + if (e.isAxiosError) { + const errorMessage = formatAxiosError(e, t('loginError', { + defaultValue: "Failed to log in" + })); + setError(errorMessage); + return; + } else { + console.error(e); + setError(e.message || t('loginError', { + defaultValue: "Failed to log in" + })); + return; + } + } finally { + setLoading(false); + } + } + + async function loginWithIdp(idpId: number) { + try { + const res = await api.post>( + `/auth/idp/${idpId}/oidc/generate-url`, + { + redirectUrl: redirect || "/" + } + ); + + console.log(res); + + if (!res) { + setError(t('loginError')); + return; + } + + const data = res.data.data; + window.location.href = data.redirectUrl; + } catch (e) { + console.error(formatAxiosError(e)); } } @@ -321,6 +317,16 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
+ +
+ +
@@ -415,17 +421,6 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { {!mfaRequested && ( <> - - - + + + + + + + !open && setShow2FADialog(false)}> + + + Two-Factor Authentication Required + + Please enter your two-factor authentication code to remove the security key + + + +
+ + ( + + Two-Factor Code + + + + + + )} + /> + + + + From f97b133c8c7c61152bafbc433666a7168bc3dc0c Mon Sep 17 00:00:00 2001 From: Adrian Astles <49412215+adrianeastles@users.noreply.github.com> Date: Tue, 8 Jul 2025 22:04:24 +0800 Subject: [PATCH 12/12] Resolved build error. --- server/routers/auth/securityKey.ts | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/server/routers/auth/securityKey.ts b/server/routers/auth/securityKey.ts index 6f681975..4e642ece 100644 --- a/server/routers/auth/securityKey.ts +++ b/server/routers/auth/securityKey.ts @@ -24,7 +24,9 @@ import type { } from "@simplewebauthn/server"; import type { AuthenticatorTransport, - PublicKeyCredentialDescriptorJSON + AuthenticatorTransportFuture, + PublicKeyCredentialDescriptorJSON, + PublicKeyCredentialDescriptorFuture } from "@simplewebauthn/types"; import config from "@server/lib/config"; import { UserType } from "@server/types/UserTypes"; @@ -168,10 +170,10 @@ export async function startRegistration( .where(eq(securityKeys.userId, user.userId)); const excludeCredentials = existingSecurityKeys.map(key => ({ - id: Buffer.from(key.credentialId, 'base64').toString('base64url'), + id: new Uint8Array(Buffer.from(key.credentialId, 'base64')), type: 'public-key' as const, - transports: key.transports ? JSON.parse(key.transports) as AuthenticatorTransport[] : undefined - } satisfies PublicKeyCredentialDescriptorJSON)); + transports: key.transports ? JSON.parse(key.transports) as AuthenticatorTransportFuture[] : undefined + })); const options: GenerateRegistrationOptionsOpts = { rpName, @@ -460,11 +462,7 @@ export async function startAuthentication( const { email } = parsedBody.data; try { - let allowCredentials: Array<{ - id: Buffer; - type: 'public-key'; - transports?: string[]; - }> = []; + let allowCredentials: PublicKeyCredentialDescriptorFuture[] = []; let userId; // If email is provided, get security keys for that specific user @@ -501,9 +499,9 @@ export async function startAuthentication( } allowCredentials = userSecurityKeys.map(key => ({ - id: Buffer.from(key.credentialId, 'base64'), + id: new Uint8Array(Buffer.from(key.credentialId, 'base64')), type: 'public-key' as const, - transports: key.transports ? JSON.parse(key.transports) as AuthenticatorTransport[] : undefined + transports: key.transports ? JSON.parse(key.transports) as AuthenticatorTransportFuture[] : undefined })); } else { // If no email provided, allow any security key (for resident key authentication) @@ -625,7 +623,7 @@ export async function verifyAuthentication( credentialID: Buffer.from(securityKey.credentialId, 'base64'), credentialPublicKey: Buffer.from(securityKey.publicKey, 'base64'), counter: securityKey.signCount, - transports: securityKey.transports ? JSON.parse(securityKey.transports) as AuthenticatorTransport[] : undefined + transports: securityKey.transports ? JSON.parse(securityKey.transports) as AuthenticatorTransportFuture[] : undefined }, requireUserVerification: false });