Merge remote-tracking branch 'origin/main' into feat/client-pin-reset

This commit is contained in:
Karl Ludwig Weise
2026-09-11 15:50:34 +02:00
53 changed files with 605 additions and 477 deletions
+89 -82
View File
@@ -9,6 +9,7 @@
"version": "2.4.0",
"license": "AGPL-3.0",
"dependencies": {
"@date-fns/tz": "^1.5.0",
"@noble/hashes": "^1.8.0",
"@noble/post-quantum": "^0.4.1",
"@simplewebauthn/server": "^11.0.0",
@@ -23,7 +24,7 @@
"drizzle-orm": "^0.45.2",
"jose": "^6.0.11",
"mode-watcher": "^1.0.8",
"nodemailer": "^9.0.1",
"nodemailer": "^9.1.1",
"postgres": "^3.4.7",
"secrets.js-34r7h": "^2.0.2",
"uuidv7": "^1.0.2",
@@ -47,7 +48,7 @@
"@types/dotenv": "^6.1.1",
"@types/node": "^24",
"@types/nodemailer": "^6.4.17",
"bits-ui": "^2.15.4",
"bits-ui": "^2.19.0",
"clsx": "^2.1.1",
"drizzle-kit": "^0.31.1",
"eslint": "^9.29.0",
@@ -71,7 +72,7 @@
"typescript-eslint": "^8.34.1",
"vaul-svelte": "^1.0.0-next.7",
"vite": "^6.4.2",
"vitest": "^4.1.9"
"vitest": "^4.1.11"
}
},
"node_modules/@adobe/css-tools": {
@@ -404,6 +405,12 @@
"kuler": "^2.0.0"
}
},
"node_modules/@date-fns/tz": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz",
"integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==",
"license": "MIT"
},
"node_modules/@drizzle-team/brocli": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",
@@ -1526,41 +1533,41 @@
"license": "MIT"
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
"integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@humanfs/types": "^0.15.0"
},
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/node": {
"version": "0.16.6",
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz",
"integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==",
"version": "0.16.8",
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
"integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@humanfs/core": "^0.19.1",
"@humanwhocodes/retry": "^0.3.0"
"@humanfs/core": "^0.19.2",
"@humanfs/types": "^0.15.0",
"@humanwhocodes/retry": "^0.4.0"
},
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz",
"integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==",
"node_modules/@humanfs/types": {
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
"integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18.18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/nzakas"
"node": ">=18.18.0"
}
},
"node_modules/@humanwhocodes/module-importer": {
@@ -3500,16 +3507,16 @@
}
},
"node_modules/@vitest/expect": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
"integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.9",
"@vitest/utils": "4.1.9",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@@ -3518,13 +3525,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
"integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.9",
"@vitest/spy": "4.1.11",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -3545,9 +3552,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
"integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3558,13 +3565,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
"integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.9",
"@vitest/utils": "4.1.11",
"pathe": "^2.0.3"
},
"funding": {
@@ -3572,14 +3579,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
"integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.9",
"@vitest/utils": "4.1.9",
"@vitest/pretty-format": "4.1.11",
"@vitest/utils": "4.1.11",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -3588,9 +3595,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
"integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -3598,13 +3605,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
"integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.9",
"@vitest/pretty-format": "4.1.11",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -3803,9 +3810,9 @@
"license": "MIT"
},
"node_modules/bits-ui": {
"version": "2.18.1",
"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.18.1.tgz",
"integrity": "sha512-KkemzKFH4T3gt3H+P86JcnAWExjByv/6vlwjm/BoCwTPHu03yiCdxbghdJLvFReQTe0acCAiRcKfmixxD6XvlA==",
"version": "2.19.0",
"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.19.0.tgz",
"integrity": "sha512-dHDm5Jw2NZJgLRvHAwPeuR4BnZij+AqTIq33OqVfo+qiMXontoC/yIvGT5TZaviv7OuBdJ2QUy2JPQ2yKBvF6w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5431,9 +5438,9 @@
}
},
"node_modules/joi": {
"version": "17.13.4",
"resolved": "https://registry.npmjs.org/joi/-/joi-17.13.4.tgz",
"integrity": "sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==",
"version": "17.13.7",
"resolved": "https://registry.npmjs.org/joi/-/joi-17.13.7.tgz",
"integrity": "sha512-MF80Dm5Y2veNy8QWVx9Bj3ui4mo7+VPSPsR1M+oaHXV0Gx6zGX9a2F+OZG3Blby9tOlzU9Rs5FUimlEhbKtfnQ==",
"dev": true,
"license": "BSD-3-Clause",
"optional": true,
@@ -5469,9 +5476,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"dev": true,
"funding": [
{
@@ -6169,9 +6176,9 @@
}
},
"node_modules/nodemailer": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz",
"integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==",
"version": "9.1.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.1.1.tgz",
"integrity": "sha512-izw9mVKFix6YSnC9eLgV6g1opl9DUlRio9ZNcq+Wu9Ujn2UwF+8Nl0B8nz22kEC+CTZCvinkxwJ0DeFbb6NwcQ==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
@@ -7311,9 +7318,9 @@
}
},
"node_modules/svelte-eslint-parser/node_modules/postcss-selector-parser": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
"version": "7.1.6",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz",
"integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7610,9 +7617,9 @@
}
},
"node_modules/tinyrainbow": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
"integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -8535,19 +8542,19 @@
}
},
"node_modules/vitest": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
"integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.9",
"@vitest/mocker": "4.1.9",
"@vitest/pretty-format": "4.1.9",
"@vitest/runner": "4.1.9",
"@vitest/snapshot": "4.1.9",
"@vitest/spy": "4.1.9",
"@vitest/utils": "4.1.9",
"@vitest/expect": "4.1.11",
"@vitest/mocker": "4.1.11",
"@vitest/pretty-format": "4.1.11",
"@vitest/runner": "4.1.11",
"@vitest/snapshot": "4.1.11",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -8575,12 +8582,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.9",
"@vitest/browser-preview": "4.1.9",
"@vitest/browser-webdriverio": "4.1.9",
"@vitest/coverage-istanbul": "4.1.9",
"@vitest/coverage-v8": "4.1.9",
"@vitest/ui": "4.1.9",
"@vitest/browser-playwright": "4.1.11",
"@vitest/browser-preview": "4.1.11",
"@vitest/browser-webdriverio": "4.1.11",
"@vitest/coverage-istanbul": "4.1.11",
"@vitest/coverage-v8": "4.1.11",
"@vitest/ui": "4.1.11",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+4 -3
View File
@@ -57,7 +57,7 @@
"@types/dotenv": "^6.1.1",
"@types/node": "^24",
"@types/nodemailer": "^6.4.17",
"bits-ui": "^2.15.4",
"bits-ui": "^2.19.0",
"clsx": "^2.1.1",
"drizzle-kit": "^0.31.1",
"eslint": "^9.29.0",
@@ -81,9 +81,10 @@
"typescript-eslint": "^8.34.1",
"vaul-svelte": "^1.0.0-next.7",
"vite": "^6.4.2",
"vitest": "^4.1.9"
"vitest": "^4.1.11"
},
"dependencies": {
"@date-fns/tz": "^1.5.0",
"@noble/hashes": "^1.8.0",
"@noble/post-quantum": "^0.4.1",
"@simplewebauthn/server": "^11.0.0",
@@ -98,7 +99,7 @@
"drizzle-orm": "^0.45.2",
"jose": "^6.0.11",
"mode-watcher": "^1.0.8",
"nodemailer": "^9.0.1",
"nodemailer": "^9.1.1",
"postgres": "^3.4.7",
"secrets.js-34r7h": "^2.0.2",
"uuidv7": "^1.0.2",
+12
View File
@@ -459,6 +459,18 @@
}
],
"error": "Fehler beim Senden"
},
"missingCryptoKeys": {
"title": "Kryptografische Schlüssel fehlen",
"description": "Dieser Tab enthält nicht die kryptografischen Schlüssel zum Entschlüsseln und Verschlüsseln von Daten. Lade die Schlüssel, um Termine/Daten anzuzeigen und hinzuzufügen.",
"cancel": "Ich will nur Einstellungen ändern",
"success": "Kryptografische Schlüssel geladen",
"error": "Fehler beim Laden der kryptografischen Schlüssel"
},
"inactiveSession": {
"title": "Bist du noch da?",
"description": "Du wirst in Kürze automatisch abgemeldet.",
"cancel": "Ich bin noch da"
}
},
"agents": {
+12
View File
@@ -467,6 +467,18 @@
}
],
"error": "Failed to send reminders"
},
"missingCryptoKeys": {
"title": "Cryptography Keys Missing",
"description": "This tab is missing the cryptographic keys to decrypt and encrypt data. Load keys to view and add appointments/data.",
"cancel": "I just want to change settings",
"success": "Cryptography keys loaded",
"error": "Failed to load cryptography keys"
},
"inactiveSession": {
"title": "Are you still here?",
"description": "You will be automatically logged out soon.",
"cancel": "I am still here"
}
},
"agents": {
-14
View File
@@ -153,9 +153,7 @@ export class UnifiedAppointmentCrypto {
// Staff-specific properties
private staffKeyPair: StaffKeyPair | null = null;
private staffId: string | null = null;
private tenantId: string | null = null;
private staffAuthenticated: boolean = false;
private keyExpiry: number | null = null;
// Shared crypto utilities
private kyberCrypto: KyberCrypto = new KyberCrypto();
@@ -803,9 +801,7 @@ export class UnifiedAppointmentCrypto {
};
this.staffId = staffId;
this.tenantId = tenantId;
this.staffAuthenticated = true;
this.keyExpiry = Date.now() + 10 * 60 * 1000; // 10 minutes
console.log("✅ Staff authentication successful with PRF");
} catch (error) {
@@ -837,10 +833,6 @@ export class UnifiedAppointmentCrypto {
throw new Error("Staff not authenticated");
}
if (this.keyExpiry && Date.now() > this.keyExpiry) {
throw new Error("Staff session expired - please authenticate again");
}
try {
// Parse the staffKeyShare which now contains: encapsulatedSecret || iv || encryptedTunnelKey
const staffKeyShareBytes = this.hexToUint8Array(encryptedData.staffKeyShare);
@@ -975,9 +967,7 @@ export class UnifiedAppointmentCrypto {
logoutStaff(): void {
this.staffKeyPair = null;
this.staffId = null;
this.tenantId = null;
this.staffAuthenticated = false;
this.keyExpiry = null;
}
/**
@@ -1744,10 +1734,6 @@ export class UnifiedAppointmentCrypto {
throw new Error("Staff not authenticated");
}
if (this.keyExpiry && Date.now() > this.keyExpiry) {
throw new Error("Staff session expired - please authenticate again");
}
try {
// Parse the staffKeyShare which now contains: encapsulatedSecret || iv || encryptedTunnelKey
const staffKeyShareBytes = this.hexToUint8Array(staffKeyShare);
@@ -52,10 +52,10 @@
}
};
const decryptPayload = async () => {
const decryptPayload = async (retry = true) => {
if (item.metaData?.encryptedPayload) {
if (!$staffCrypto.isAuthenticated || !$staffCrypto.crypto) {
const maxWaitTime = 5000; // 5 seconds
const maxWaitTime = 5000;
const startTime = Date.now();
while (!$staffCrypto.isAuthenticated && Date.now() - startTime < maxWaitTime) {
@@ -63,6 +63,13 @@
}
if (!$staffCrypto.isAuthenticated || !$staffCrypto.crypto) {
const user = $auth.user;
if (retry && user && user.tenantId) {
console.warn("Staff crypto not initialized, retrying notification item decryption...");
await staffCrypto.authenticateAndWait(user.id, user.tenantId);
decryptPayload(false);
return;
}
console.error("Staff crypto not initialized after waiting");
return;
}
@@ -52,6 +52,7 @@
triggerHidden = false,
triggerVariant = "default",
isActionLoading = false,
isDismissable = true,
actions,
children,
}: HTMLAttributes<HTMLDivElement> & {
@@ -62,6 +63,7 @@
description?: string;
triggerVariant?: ButtonVariant;
isActionLoading?: boolean;
isDismissable?: boolean;
actions?: ListItemAction[];
} = $props();
@@ -150,40 +152,49 @@
{/if}
</Dialog.Trigger>
{/if}
<Dialog.Content
class={cn(
"max-h-[95vh] sm:max-w-106.25",
actions && actions.length > 0 && "[&>button:last-child]:hidden", // hides default close button
)}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<Dialog.Header class="flex flex-row items-start justify-between gap-2">
<div class="flex flex-col gap-1 text-left">
<Dialog.Title class={cn(description ? "" : "-mb-1")}>{title}</Dialog.Title>
{#if description}
<Dialog.Description>
{description}
</Dialog.Description>
{/if}
</div>
{#if actions && actions.length > 0}
<div class="flex items-center gap-2">
{@render actionsSnippet?.()}
<Dialog.Close>
<X class="size-4" />
</Dialog.Close>
<Dialog.Portal>
<Dialog.Overlay class={cn(isDismissable === true ? "" : "z-60!")} />
<Dialog.Content
class={cn(
"max-h-[95vh] sm:max-w-106.25",
// pull non-dismissable dialogs to the top
isDismissable === true ? "" : "z-70!",
// hides default close button
actions && actions.length > 0 ? "[&>button:last-child]:hidden" : "",
)}
onOpenAutoFocus={(e) => e.preventDefault()}
escapeKeydownBehavior={isDismissable === false ? "ignore" : "close"}
interactOutsideBehavior={isDismissable === false ? "ignore" : "close"}
showCloseButton={isDismissable === true}
>
<Dialog.Header class="flex flex-row items-start justify-between gap-2">
<div class="flex flex-col gap-1 text-left">
<Dialog.Title class={cn(description ? "" : "-mb-1")}>{title}</Dialog.Title>
{#if description}
<Dialog.Description>
{description}
</Dialog.Description>
{/if}
</div>
{/if}
</Dialog.Header>
<ScrollArea class="-mx-1 max-h-[75vh] overflow-hidden">
<div class="px-1 pt-2 pb-3">
{@render children?.()}
</div>
</ScrollArea>
</Dialog.Content>
{#if actions && actions.length > 0}
<div class="flex items-center gap-2">
{@render actionsSnippet?.()}
<Dialog.Close>
<X class="size-4" />
</Dialog.Close>
</div>
{/if}
</Dialog.Header>
<ScrollArea class="-mx-1 max-h-[75vh] overflow-hidden">
<div class="px-1 pt-2 pb-3">
{@render children?.()}
</div>
</ScrollArea>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
{:else}
<Drawer.Root bind:open>
<Drawer.Root bind:open dismissible={isDismissable}>
{#if !triggerHidden}
<Drawer.Trigger class={buttonVariants({ variant: triggerVariant })}>
{#if typeof triggerLabel === "string"}
@@ -193,29 +204,40 @@
{/if}
</Drawer.Trigger>
{/if}
<Drawer.Content
class="data-[vaul-drawer-direction=bottom]:max-h-[95vh] data-[vaul-drawer-direction=top]:max-h-[95vh]"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<Drawer.Header class="flex flex-row justify-between gap-2 text-left">
<div>
<Drawer.Title class={cn(description ? "" : "-mb-1")}>{title}</Drawer.Title>
{#if description}
<Drawer.Description>
{description}
</Drawer.Description>
{/if}
</div>
<div>
{@render actionsSnippet?.()}
</div>
</Drawer.Header>
<HorizontalPagePadding class="max-h-[95vh] overflow-y-scroll pt-2">
{@render children?.()}
</HorizontalPagePadding>
<Drawer.Footer class="pt-2">
<Drawer.Close class={buttonVariants({ variant: "outline" })}>{m.cancel()}</Drawer.Close>
</Drawer.Footer>
</Drawer.Content>
<Drawer.Portal>
<Drawer.Overlay class={cn(isDismissable === true ? "" : "z-60!")} />
<Drawer.Content
class={cn(
"data-[vaul-drawer-direction=bottom]:max-h-[95vh] data-[vaul-drawer-direction=top]:max-h-[95vh]",
// pull non-dismissable dialogs to the top
isDismissable === true ? "" : "z-70!",
)}
onOpenAutoFocus={(e) => e.preventDefault()}
escapeKeydownBehavior={isDismissable === false ? "ignore" : "close"}
interactOutsideBehavior={isDismissable === false ? "ignore" : "close"}
>
<Drawer.Header class="flex flex-row justify-between gap-2 text-left">
<div>
<Drawer.Title class={cn(description ? "" : "-mb-1")}>{title}</Drawer.Title>
{#if description}
<Drawer.Description>
{description}
</Drawer.Description>
{/if}
</div>
<div>
{@render actionsSnippet?.()}
</div>
</Drawer.Header>
<HorizontalPagePadding class="max-h-[95vh] overflow-y-scroll pt-2">
{@render children?.()}
</HorizontalPagePadding>
{#if isDismissable !== false}
<Drawer.Footer class="pt-2">
<Drawer.Close class={buttonVariants({ variant: "outline" })}>{m.cancel()}</Drawer.Close>
</Drawer.Footer>
{/if}
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
{/if}
+2 -2
View File
@@ -1,5 +1,5 @@
export const SETUP_STATES_LIST = ["SETTINGS", "AGENTS", "CHANNELS", "STAFF", "READY"];
export const TENANT_FEATURE_FLAGS = [
export const TENANT_FEATURE_FLAGS = {
// List of supported features flags
];
} as const;
+12 -6
View File
@@ -2,18 +2,20 @@ import { getTenantDb } from "../db";
import * as tenantSchema from "../db/tenant-schema";
import {
type SelectAgent,
type SelectAgentAbsence,
type SelectAppointment,
type SelectChannel,
type SelectSlotTemplate,
type SelectAppointment,
type SelectAgentAbsence,
scheduleCache,
} from "../db/tenant-schema";
import { eq, and, between, sql, or, inArray } from "drizzle-orm";
import logger from "$lib/logger";
import { tz } from "@date-fns/tz";
import { endOfDay, startOfDay } from "date-fns";
import { and, between, eq, inArray, or, sql } from "drizzle-orm";
import { z } from "zod";
import { ValidationError } from "../utils/errors";
import { WebAuthnService } from "../auth/webauthn-service";
import { ValidationError } from "../utils/errors";
import { isValidTimeZone, toLocalTime, toLocalTimeIgnoringDst } from "../utils/timezone";
const CACHE_MAX_AHEAD_MONTHS = 14;
@@ -698,8 +700,12 @@ export class ScheduleService {
// Generate cache for each timezone
for (const timeZone of timeZones) {
await this.getSchedule({
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
startDate: startOfDay(startDate, { in: tz(timeZone) })
.withTimeZone("UTC")
.toISOString(),
endDate: endOfDay(endDate, { in: tz(timeZone) })
.withTimeZone("UTC")
.toISOString(),
tenantId: this.tenantId,
channelId,
timeZone,
+28 -1
View File
@@ -1,7 +1,10 @@
import { browser } from "$app/environment";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import type { SupportedLocale } from "$lib/const/locales";
import { ROUTES } from "$lib/const/routes";
import type { UserRole } from "$lib/server/auth/authorization-service";
import { writable } from "svelte/store";
import { get, writable } from "svelte/store";
export interface PasskeyAuthData {
authenticatorData: string;
@@ -15,6 +18,8 @@ export interface AuthState {
refreshPromise: Promise<Response> | null;
user?: AuthStateUser;
passkeyAuthData?: PasskeyAuthData;
lastActive?: Date | undefined;
isInactive?: boolean;
}
export type AuthStateUser = {
@@ -31,6 +36,7 @@ function createAuthStore() {
const store = writable<AuthState>({
isAuthenticated: false,
refreshPromise: null,
isInactive: false,
});
return {
@@ -64,6 +70,7 @@ function createAuthStore() {
refreshPromise: null,
user: undefined,
passkeyAuthData: undefined,
lastActive: undefined,
});
},
setPasskeyAuthData: (data: PasskeyAuthData) => {
@@ -98,6 +105,26 @@ function createAuthStore() {
unsubscribe();
return authState!.user?.tenantId || null;
},
refreshLastActive: () => {
store.update((state) => ({ ...state, lastActive: new Date(), isInactive: false }));
},
checkLastActive: () => {
const lastActive = get(store).lastActive;
if (!lastActive) return;
const now = new Date();
const diff = now.getTime() - lastActive.getTime();
// Logs user out after 15 minutes of inactivity
if (diff > 15 * 60 * 1000) {
goto(resolve(ROUTES.LOGOUT));
}
// Triggers inactivity modal after 10 minutes
if (diff > 10 * 60 * 1000) {
store.update((state) => ({ ...state, isInactive: true }));
}
},
waitForRefresh: async () => {
let authState: AuthState;
const unsubscribe = store.subscribe((state) => {
+30 -1
View File
@@ -1,9 +1,10 @@
import { writable } from "svelte/store";
import { get, writable } from "svelte/store";
import { UnifiedAppointmentCrypto } from "$lib/client/appointment-crypto";
import { auth } from "./auth";
interface StaffCryptoState {
crypto: UnifiedAppointmentCrypto | null;
isAuthenticating: boolean;
isAuthenticated: boolean;
error: string | null;
}
@@ -11,6 +12,7 @@ interface StaffCryptoState {
const createStaffCryptoStore = () => {
const store = writable<StaffCryptoState>({
crypto: null,
isAuthenticating: false,
isAuthenticated: false,
error: null,
});
@@ -37,11 +39,18 @@ const createStaffCryptoStore = () => {
*/
async authenticate(staffId: string, tenantId: string): Promise<boolean> {
try {
store.set({
...get(store),
isAuthenticating: true,
error: null,
});
const crypto = new UnifiedAppointmentCrypto();
await crypto.authenticateStaff(staffId, tenantId);
store.set({
crypto,
isAuthenticating: false,
isAuthenticated: true,
error: null,
});
@@ -51,6 +60,7 @@ const createStaffCryptoStore = () => {
const errorMessage = error instanceof Error ? error.message : "Authentication failed";
store.set({
crypto: null,
isAuthenticating: false,
isAuthenticated: false,
error: errorMessage,
});
@@ -59,6 +69,24 @@ const createStaffCryptoStore = () => {
}
},
async authenticateAndWait(staffId: string, tenantId: string): Promise<boolean> {
const isAuthenticating = get(store).isAuthenticating;
if (isAuthenticating) {
// Wait for the ongoing authentication to complete
return new Promise((resolve) => {
const unsubscribe = store.subscribe((state) => {
if (!state.isAuthenticating) {
unsubscribe();
resolve(state.isAuthenticated);
}
});
});
} else {
// Start a new authentication process
return await this.authenticate(staffId, tenantId);
}
},
/**
* Clear the staff crypto state and auth data
*/
@@ -66,6 +94,7 @@ const createStaffCryptoStore = () => {
auth.clearPasskeyAuthData();
store.set({
crypto: null,
isAuthenticating: false,
isAuthenticated: false,
error: null,
});
+2
View File
@@ -1,3 +1,4 @@
import type { ClientTunnelResponse } from "$lib/server/services/appointment-service";
import type { CalendarAgent, DaySchedule } from "$lib/server/services/schedule-service";
export type AppointmentStatus = "available" | "booked" | "reserved" | "rejected";
@@ -48,6 +49,7 @@ export type TAppointmentInClipboard = {
email?: string;
phone?: string;
dateTime?: Date;
tunnel?: ClientTunnelResponse;
};
export type TCalendarItem = TCalendarSlot & {
+3
View File
@@ -1,4 +1,5 @@
import type { SelectTenant } from "$lib/server/db/central-schema";
import { TENANT_FEATURE_FLAGS } from "$lib/const/tenants";
export type TTenant = Pick<
SelectTenant,
@@ -28,3 +29,5 @@ export type TTenantSettings = Omit<SelectTenant, "databaseUrl" | "setupState" |
requirePhone: boolean;
};
};
export type TTenantFeatureFlag = keyof typeof TENANT_FEATURE_FLAGS;
+10 -1
View File
@@ -1,5 +1,7 @@
import { SETUP_STATES_LIST } from "$lib/const/tenants";
import type { TTenant } from "$lib/types/tenant";
import type { TTenant, TTenantFeatureFlag } from "$lib/types/tenant";
import { get } from "svelte/store";
import { tenants } from "$lib/stores/tenants";
export const changeTenantUsingApi = async (tenantId: string | null): Promise<boolean> => {
const response = await fetch("/api/admin/tenant", {
@@ -23,3 +25,10 @@ export const isSetupStateDone = (tenant: TTenant, step: TTenant["setupState"]):
const stepIndex = SETUP_STATES_LIST.indexOf(step);
return curStateIndex > stepIndex;
};
export const hasFeatureFlag = (flag: TTenantFeatureFlag): boolean => {
const curTenant = get(tenants).currentTenant;
if (!curTenant) return false;
return curTenant.features.includes(flag) || false;
};
@@ -10,6 +10,7 @@
import { onMount } from "svelte";
import { getNextAppointments, sendAppointmentReminders } from "./utils";
import { m } from "$i18n/messages";
import { auth } from "$lib/stores/auth";
let status: "init" | "loading" | "sending" | "success" | "error" = $state("init");
let appointments: TEmailAppointmentReminder[] = $state([]);
@@ -27,6 +28,8 @@
// Also gives staff crypto time to load, if not already loaded
await new Promise((resolve) => setTimeout(resolve, 1000));
await auth.waitForRefresh();
const allAppointments = await getNextAppointments(tenantId);
const decryptPromises = allAppointments.map(async (appointment) => {
if (
@@ -0,0 +1,33 @@
<script lang="ts">
import { resolve } from "$app/paths";
import { m } from "$i18n/messages";
import { Button } from "$lib/components/ui/button";
import { openDialog, closeDialog, ResponsiveDialog } from "$lib/components/ui/responsive-dialog";
import { ROUTES } from "$lib/const/routes";
import { auth } from "$lib/stores/auth";
$effect(() => {
if ($auth.isInactive) {
openDialog("inactivity-warning");
} else {
closeDialog("inactivity-warning");
}
});
</script>
<ResponsiveDialog
id="inactivity-warning"
title={m["dashboard.inactiveSession.title"]()}
description={m["dashboard.inactiveSession.description"]()}
triggerHidden={true}
isDismissable={false}
>
<div class="flex flex-col gap-2">
<Button onclick={() => auth.refreshLastActive()} class="w-full">
{m["dashboard.inactiveSession.cancel"]()}
</Button>
<Button href={resolve(ROUTES.LOGOUT)} variant="link" class="w-full">
{m["logout.title"]()}
</Button>
</div>
</ResponsiveDialog>
@@ -0,0 +1,100 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { Button } from "$lib/components/ui/button";
import { Passkey } from "$lib/components/ui/passkey";
import type { PasskeyState } from "$lib/components/ui/passkey/state.svelte";
import { closeDialog, ResponsiveDialog } from "$lib/components/ui/responsive-dialog";
import logger from "$lib/logger";
import { auth } from "$lib/stores/auth";
import { staffCrypto } from "$lib/stores/staff-crypto";
import { arrayBufferToBase64, fetchChallenge, getCredential } from "$lib/utils/passkey";
import { toast } from "svelte-sonner";
import type { Writable } from "svelte/store";
import { writable } from "svelte/store";
const passkeyLoading: Writable<PasskeyState> = writable("initial");
const onSetPasskey = async () => {
$passkeyLoading = "loading";
if (!$auth.user?.email || !$auth.user.tenantId) {
$passkeyLoading = "error";
return;
}
const challenge = await fetchChallenge($auth.user.email);
if (!challenge) {
$passkeyLoading = "error";
logger.error("Failed to fetch challenge", { email: $auth.user.email });
} else {
$passkeyLoading = "user";
// Call WebAuthn with PRF enabled (uses email as salt for multi-passkey support)
const credentialResp = await getCredential({
...challenge,
email: $auth.user.email,
enablePRF: true,
}).catch((error) => {
$passkeyLoading = "error";
logger.error("Failed to get credential", { ...challenge, error });
});
if (!credentialResp) {
$passkeyLoading = "error";
logger.error("Credential response is falsy");
return;
}
// Update form data with passkey info
const authenticatorDataBase64 = arrayBufferToBase64(
// @ts-expect-error response type needs to be fixed
credentialResp.response.authenticatorData,
);
// Store authenticatorData and PRF output for later key reconstruction
const passkeyId = credentialResp.id;
// Extract PRF output from WebAuthn response (if PRF was enabled)
let prfOutputBase64: string | undefined;
if (credentialResp.prfOutput) {
prfOutputBase64 = arrayBufferToBase64(credentialResp.prfOutput);
logger.info("PRF output retrieved from login", {
prfOutputLength: credentialResp.prfOutput.byteLength,
});
} else {
logger.warn("No PRF output in login response - crypto features may not work", {
email: $auth.user.email,
});
}
auth.setPasskeyAuthData({
authenticatorData: authenticatorDataBase64,
passkeyId,
email: $auth.user.email,
prfOutput: prfOutputBase64,
});
await staffCrypto.authenticate($auth.user.id, $auth.user.tenantId);
// Update UI to show passkey is ready
$passkeyLoading = "success";
closeDialog("missing-staff-crypto");
toast.success(m["dashboard.missingCryptoKeys.success"]());
}
};
</script>
<ResponsiveDialog
id="missing-staff-crypto"
title={m["dashboard.missingCryptoKeys.title"]()}
description={m["dashboard.missingCryptoKeys.description"]()}
triggerHidden={true}
isDismissable={false}
>
<div class="flex flex-col gap-2">
<Passkey.State state="click" onclick={onSetPasskey} class="justify-center" />
<Button onclick={() => closeDialog("missing-staff-crypto")} variant="link" class="w-full">
{m["dashboard.missingCryptoKeys.cancel"]()}
</Button>
</div>
</ResponsiveDialog>
@@ -9,6 +9,11 @@
import { staff } from "$lib/stores/staff";
import { QueryClient, QueryClientProvider } from "@tanstack/svelte-query";
import { browser } from "$app/environment";
import { page } from "$app/state";
import MissingStaffCrypto from "./(components)/MissingStaffCrypto.svelte";
import { openDialog } from "$lib/components/ui/responsive-dialog";
import { clock } from "$lib/stores/time";
import InactivityWarning from "./(components)/InactivityWarning.svelte";
let { data, children }: LayoutProps = $props();
@@ -59,6 +64,18 @@
};
});
$effect(() => {
if (page.url.pathname) {
auth.refreshLastActive();
}
});
$effect(() => {
if ($clock) {
auth.checkLastActive();
}
});
const updateStores = async () => {
staff.load();
notifications.load();
@@ -80,6 +97,7 @@
if (success) {
console.log("✅ Staff crypto initialized successfully");
} else {
openDialog("missing-staff-crypto");
console.log("️ Staff crypto not initialized (no session data or error)");
}
}
@@ -88,4 +106,6 @@
<QueryClientProvider client={queryClient}>
{@render children()}
<MissingStaffCrypto />
<InactivityWarning />
</QueryClientProvider>
@@ -6,8 +6,11 @@
import { InputDateTime } from "$lib/components/ui/input-date-time";
import RadioCards from "$lib/components/ui/radio-cards/radio-cards.svelte";
import * as Select from "$lib/components/ui/select";
import { times } from "$lib/components/ui/slot-template/utils";
import { Text } from "$lib/components/ui/typography";
import { agents as agentsStore } from "$lib/stores/agents";
import { auth } from "$lib/stores/auth";
import { cn } from "$lib/utils";
import {
getDefaultEndTime,
getDefaultStartTime,
@@ -24,8 +27,6 @@
import type { AbsenceType } from "../types";
import { reasons, types } from "../utils";
import { formSchema } from "./schema";
import { cn } from "$lib/utils";
import { times } from "$lib/components/ui/slot-template/utils";
let { done }: { done: () => void } = $props();
@@ -46,6 +47,7 @@
dataType: "json",
validators: zodClient(formSchema),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(m["absences.add.success"]());
done();
@@ -4,6 +4,7 @@
import { Input } from "$lib/components/ui/input";
import { Text } from "$lib/components/ui/typography";
import { agents as agentsStore } from "$lib/stores/agents";
import { auth } from "$lib/stores/auth";
import type { TAbsence } from "$lib/types/absence";
import { toast } from "svelte-sonner";
import { superForm } from "sveltekit-superforms";
@@ -20,6 +21,7 @@
{
validators: zodClient(formSchema),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(m["absences.delete.success"]());
done();
@@ -4,14 +4,13 @@
import * as Form from "$lib/components/ui/form";
import { Input } from "$lib/components/ui/input";
import { InputDateTime } from "$lib/components/ui/input-date-time";
import { RadioCards } from "$lib/components/ui/radio-cards";
import * as Select from "$lib/components/ui/select";
import { times } from "$lib/components/ui/slot-template/utils";
import { agents as agentsStore } from "$lib/stores/agents";
import { auth } from "$lib/stores/auth";
import type { TAbsence } from "$lib/types/absence";
import { toast } from "svelte-sonner";
import { superForm } from "sveltekit-superforms";
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { formSchema } from ".";
import { reasons, types } from "../utils";
import { cn } from "$lib/utils";
import {
timeLocalWithoutOffsetToUTC,
timeUTCToLocalWithoutOffset,
@@ -19,10 +18,12 @@
toWeekdaysLabel,
weekdays,
} from "$lib/utils/datetime";
import { toast } from "svelte-sonner";
import { SvelteDate } from "svelte/reactivity";
import { times } from "$lib/components/ui/slot-template/utils";
import { RadioCards } from "$lib/components/ui/radio-cards";
import { cn } from "$lib/utils";
import { superForm } from "sveltekit-superforms";
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { formSchema } from ".";
import { reasons, types } from "../utils";
let { entity, done }: { entity: TAbsence; done: () => void } = $props();
@@ -45,6 +46,7 @@
dataType: "json",
validators: zodClient(formSchema),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(m["absences.edit.success"]());
done();
@@ -19,6 +19,7 @@
dataType: "json",
validators: zodClient(formSchema),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(m["account.change-passphrase.success"]());
} else if (event.result.type === "failure") {
@@ -21,6 +21,7 @@
dataType: "json",
validators: zodClient(formSchema),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(m["account.general.success"]());
} else if (event.result.type === "failure") {
@@ -38,6 +38,9 @@
},
{
validators: zodClient(formSchema),
onResult: () => {
auth.refreshLastActive();
},
onChange: (event) => {
if (event.paths.includes("email")) {
setProperPasskeyState();
@@ -5,6 +5,7 @@
import { Input } from "$lib/components/ui/input";
import { TranslationWithComponent } from "$lib/components/ui/translation-with-component";
import { Text } from "$lib/components/ui/typography";
import { auth } from "$lib/stores/auth";
import { toast } from "svelte-sonner";
import { superForm } from "sveltekit-superforms";
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
@@ -29,6 +30,7 @@
),
),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(m["account.passkeys.delete.success"]());
done();
@@ -2,6 +2,7 @@
import { m } from "$i18n/messages.js";
import * as Form from "$lib/components/ui/form";
import { Input } from "$lib/components/ui/input";
import { auth } from "$lib/stores/auth";
import type { RedactedPasskeyHydrated } from "$lib/types/passkeys";
import { untrack } from "svelte";
import { toast } from "svelte-sonner";
@@ -20,6 +21,7 @@
dataType: "json",
validators: zodClient(formSchema),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(m["account.passkeys.edit.success"]());
done();
@@ -5,13 +5,14 @@
import { InputCroppedImageBlob } from "$lib/components/ui/input-cropped-image-blob";
import { LanguageTabs } from "$lib/components/ui/language-tabs";
import { Textarea } from "$lib/components/ui/textarea";
import { auth } from "$lib/stores/auth";
import { tenants } from "$lib/stores/tenants";
import ItemIcon from "@lucide/svelte/icons/user-star";
import { toast } from "svelte-sonner";
import { get } from "svelte/store";
import { superForm } from "sveltekit-superforms";
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { formSchema } from ".";
import { tenants } from "$lib/stores/tenants";
import { get } from "svelte/store";
let { done }: { done: () => void } = $props();
@@ -29,6 +30,7 @@
dataType: "json",
validators: zodClient(formSchema),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(m["agents.add.success"]());
done();
@@ -11,6 +11,7 @@
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { z } from "zod";
import { formSchema } from ".";
import { auth } from "$lib/stores/auth";
let { entity, done }: { entity: TAgent; done: () => void } = $props();
@@ -30,6 +31,7 @@
),
),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(m["agents.delete.success"]());
done();
@@ -5,14 +5,15 @@
import { InputCroppedImageBlob } from "$lib/components/ui/input-cropped-image-blob";
import { LanguageTabs } from "$lib/components/ui/language-tabs";
import { Textarea } from "$lib/components/ui/textarea";
import { auth } from "$lib/stores/auth";
import { tenants } from "$lib/stores/tenants";
import type { TAgent } from "$lib/types/agent";
import ItemIcon from "@lucide/svelte/icons/user-star";
import { toast } from "svelte-sonner";
import { get } from "svelte/store";
import { superForm } from "sveltekit-superforms";
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { formSchema } from ".";
import { tenants } from "$lib/stores/tenants";
import { get } from "svelte/store";
let { entity, done }: { entity: TAgent; done: () => void } = $props();
@@ -33,6 +34,7 @@
dataType: "json",
validators: zodClient(formSchema),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(m["agents.edit.success"]());
done();
@@ -6,13 +6,16 @@
import { ResponsiveDialog } from "$lib/components/ui/responsive-dialog";
import { type SupportedLocale } from "$lib/const/locales";
import { agents as agentsStore } from "$lib/stores/agents";
import { auth } from "$lib/stores/auth";
import { type CurAppointmentItem } from "$lib/stores/calendar";
import { channels as channelsStore } from "$lib/stores/channels";
import type { TAppointmentFilter, TCalendarMode } from "$lib/types/calendar";
import { getCurrentTranlslation } from "$lib/utils/localizations";
import { CalendarPlus, Move, Trash2 } from "@lucide/svelte";
import { onMount } from "svelte";
import { toast } from "svelte-sonner";
import { cancelAppointment, confirmAppointment, denyAppointment } from "./utils";
import { getClientTunnel } from "./add-appointment/utils";
let {
tenantId,
@@ -41,6 +44,7 @@
let isDeleting = $state(false);
const denyItem = async () => {
auth.refreshLastActive();
const proceed = confirm(
`${m["calendar.notificationHint"]()} ${m["calendar.denyAppointment.confirm"]()}`,
);
@@ -64,6 +68,7 @@
};
const confirmItem = async () => {
auth.refreshLastActive();
isConfirming = true;
const success = await confirmAppointment({
tenant: tenantId,
@@ -80,6 +85,10 @@
}
isConfirming = false;
};
onMount(() => {
auth.refreshLastActive();
});
</script>
<ResponsiveDialog
@@ -118,21 +127,30 @@
type: "action",
label: m["calendar.addFollowUpAppointment.action"](),
icon: CalendarPlus,
onClick: () => {
mode = {
mode: "ADD_FOLLOW_UP",
appointment: {
email: item.decrypted.email,
shareEmail: item.decrypted.shareEmail,
dateTime: item.appointment.appointment?.dateTime,
hasNoEmail: Boolean(item.decrypted.email),
phone: item.decrypted.phone,
name: item.decrypted.name,
locale: item.decrypted.locale || getLocale(),
},
};
shownAppointments = "available";
close();
onClick: async () => {
const tunnel = item.decrypted.email
? await getClientTunnel(tenantId, item.decrypted.email)
: // Client without email
null;
if (tunnel !== undefined) {
mode = {
mode: "ADD_FOLLOW_UP",
appointment: {
email: item.decrypted.email,
shareEmail: item.decrypted.shareEmail,
dateTime: item.appointment.appointment?.dateTime,
hasNoEmail: Boolean(item.decrypted.email),
phone: item.decrypted.phone,
name: item.decrypted.name,
locale: item.decrypted.locale || getLocale(),
tunnel: tunnel || undefined,
},
};
shownAppointments = "available";
close();
} else {
console.error("Error getting client tunnel for follow-up appointment");
}
},
},
{
@@ -4,11 +4,11 @@
import { staffCrypto } from "$lib/stores/staff-crypto";
import { type TCalendarItem } from "$lib/types/calendar";
import Loader2Icon from "@lucide/svelte/icons/loader-2";
import { onMount } from "svelte";
import { calendarStore } from "$lib/stores/calendar";
import { Button } from "$lib/components/ui/button";
import { m } from "$i18n/messages";
import { cn } from "$lib/utils";
import { auth } from "$lib/stores/auth";
let {
item,
@@ -21,15 +21,23 @@
let decrypted = $state<AppointmentData | undefined>();
let error = $state<string | undefined>();
onMount(() => {
decrypt();
$effect(() => {
if (!decrypted) {
if ($staffCrypto.isAuthenticated) {
decrypt();
} else {
error = "Keys missing";
}
}
});
const errors = {
missingKeyShare: "Missing key share",
};
const decrypt = async () => {
const decrypt = async (retry = true) => {
error = undefined;
if (!item.appointment) {
console.error("Unable to decrypt appointment data - no appointment data in item.", item.id);
error = "Missing data";
@@ -52,7 +60,7 @@
// Wait for crypto to be initialized (max 5 seconds)
if (!$staffCrypto.isAuthenticated || !$staffCrypto.crypto) {
const maxWaitTime = 5000; // 5 seconds
const maxWaitTime = 5000;
const startTime = Date.now();
while (!$staffCrypto.isAuthenticated && Date.now() - startTime < maxWaitTime) {
@@ -60,6 +68,13 @@
}
if (!$staffCrypto.isAuthenticated || !$staffCrypto.crypto) {
const user = $auth.user;
if (retry && user && user.tenantId) {
console.warn("Staff crypto not initialized, retrying decryption...");
await staffCrypto.authenticateAndWait(user.id, user.tenantId);
decrypt(false);
return;
}
error = "Crypto not initialized";
console.error("Staff crypto not initialized after waiting");
return;
@@ -12,6 +12,7 @@
import * as Sidebar from "$lib/components/ui/sidebar";
import { Text } from "$lib/components/ui/typography";
import { agents as agentsStore } from "$lib/stores/agents";
import { auth } from "$lib/stores/auth";
import { channels as channelsStore } from "$lib/stores/channels";
import { sidebar as sidebarStore } from "$lib/stores/sidebar";
import type { TAppointmentFilter } from "$lib/types/calendar";
@@ -52,6 +53,7 @@
let agents = $derived($agentsStore.agents.filter((x) => !x.archived));
const zoom = (direction: number) => {
auth.refreshLastActive();
const currentIndex = CALENDAR_ZOOM_STEPS.indexOf(scale);
if (currentIndex === -1) return;
const nextIndex = currentIndex + direction;
@@ -60,11 +62,13 @@
};
const changeView = (newView: CalendarView) => {
auth.refreshLastActive();
view = newView;
document.cookie = `calendarView=${newView}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Strict`;
};
const clearFilters = () => {
auth.refreshLastActive();
shownAppointments = "all";
shownChannels = [];
shownAgents = [];
@@ -239,6 +243,7 @@
{shownAgents}
{shownChannels}
onSelectDay={(v: DateValue | undefined) => {
auth.refreshLastActive();
sidebarStore.setCalendarExpanded(!sidebar.isCalendarExpanded);
if (view === "week-workdays" && v && isWeekend(v, getLocale())) {
changeView("week");
@@ -3,6 +3,7 @@
import { getLocale } from "$i18n/runtime";
import { Button, buttonVariants } from "$lib/components/ui/button";
import * as Popover from "$lib/components/ui/popover/index.js";
import { auth } from "$lib/stores/auth";
import type { TAppointmentFilter, TCalendarMode } from "$lib/types/calendar";
import { cn } from "$lib/utils";
import {
@@ -37,6 +38,7 @@
let isWeekView = $derived(view !== "day");
const prev = () => {
auth.refreshLastActive();
const nextDate = new CalendarDate(
selectedDate.year,
selectedDate.month,
@@ -48,6 +50,7 @@
};
const next = () => {
auth.refreshLastActive();
const nextDate = new CalendarDate(selectedDate.year, selectedDate.month, selectedDate.day).add({
days: isWeekView ? 7 : 1,
});
@@ -55,10 +58,12 @@
};
const setToToday = () => {
auth.refreshLastActive();
selectedDate = today(getLocalTimeZone());
};
const onSelectDay = (v: DateValue | undefined) => {
auth.refreshLastActive();
open = false;
if (view === "week-workdays" && v && isWeekend(v, getLocale())) {
view = "week";
@@ -4,6 +4,7 @@
import Button from "$lib/components/ui/button/button.svelte";
import { ResponsiveDialog } from "$lib/components/ui/responsive-dialog";
import { Text } from "$lib/components/ui/typography";
import { auth } from "$lib/stores/auth";
import { channels as channelsStore } from "$lib/stores/channels";
import type { TCalendarModeMove, TCalendarSlot } from "$lib/types/calendar";
import { utcToLocalWithoutDST } from "$lib/utils/datetime";
@@ -66,15 +67,15 @@
});
if (success) {
toast.success(m["calendar.moveAppointment.success"]());
isSubmitting = false;
updateCalendar();
} else {
toast.error(m["calendar.moveAppointment.error"]());
}
isSubmitting = false;
};
onMount(() => {
console.log("item.start", item.start);
auth.refreshLastActive();
if (mode.agentId && item.availableAgents) {
const availableAgents = item.availableAgents.map((it) => it.id);
if (availableAgents.includes(mode.agentId)) {
@@ -16,6 +16,7 @@
import SelectAgent from "./SelectAgent.svelte";
import Summary from "./Summary.svelte";
import type { TAddAppointment, TAddAppointmentStep } from "./types";
import { auth } from "$lib/stores/auth";
let {
tenantId,
@@ -46,6 +47,7 @@
let isSubmitting = $state(false);
const proceed = (data: TAddAppointment) => {
auth.refreshLastActive();
switch (true) {
case !data.agentId: {
newAppointment = {
@@ -77,6 +79,7 @@
};
const addAppointment = async () => {
auth.refreshLastActive();
if (
newAppointment.name &&
newAppointment.agentId &&
@@ -133,6 +136,7 @@
phone: appointment.phone,
locale: appointment.locale,
hasNoEmail: Boolean(appointment.email),
tunnel: appointment.tunnel,
});
}
}
@@ -146,7 +150,7 @@
<SelectAgent availableAgents={item.availableAgents} {newAppointment} {proceed} />
{:else if step === "summary"}
<div class="flex flex-col gap-2">
{#if newAppointment.hasNoEmail || !newAppointment.tunnel}
{#if !newAppointment.tunnel}
<Text style="sm" color="light" class="text-center">
{m["calendar.addAppointment.steps.summary.hint"]()}
</Text>
@@ -1,15 +1,14 @@
<script lang="ts">
import { m } from "$i18n/messages.js";
import Button from "$lib/components/ui/button/button.svelte";
import * as Form from "$lib/components/ui/form";
import { Input } from "$lib/components/ui/input";
import { toast } from "svelte-sonner";
import { superForm } from "sveltekit-superforms";
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { formSchema } from ".";
import type { TAddAppointment } from "../types";
import Button from "$lib/components/ui/button/button.svelte";
import { hashEmail } from "$lib/client/appointment-crypto";
import { fetchClientTunnels } from "../../../../staff/(components)/utils";
import { toast } from "svelte-sonner";
import { getClientTunnel } from "../utils";
let {
tenantId,
@@ -32,9 +31,7 @@
isSubmitting = true;
// Find client tunnel, if it exists
const tunnels = await fetchClientTunnels(tenantId);
const hashedEmail = await hashEmail($formData.email);
const tunnel = tunnels.find((t) => t.emailHash === hashedEmail);
const tunnel = await getClientTunnel(tenantId, $formData.email);
if (tunnel) {
toast.success(m["calendar.addAppointment.steps.selectClient.proceedExistingClient"]());
@@ -0,0 +1,9 @@
import { hashEmail } from "$lib/client/appointment-crypto";
import { fetchClientTunnels } from "../../../staff/(components)/utils";
export const getClientTunnel = async (tenantId: string, email: string) => {
const tunnels = await fetchClientTunnels(tenantId);
const hashedEmail = await hashEmail(email);
const tunnel = tunnels.find((t) => t.emailHash === hashedEmail);
return tunnel;
};
@@ -10,6 +10,7 @@
import { Textarea } from "$lib/components/ui/textarea";
import { Headline, Text } from "$lib/components/ui/typography";
import { agents as agentsStore } from "$lib/stores/agents";
import { auth } from "$lib/stores/auth";
import { staff as staffStore } from "$lib/stores/staff";
import { tenants } from "$lib/stores/tenants";
import { toast } from "svelte-sonner";
@@ -44,6 +45,7 @@
dataType: "json",
validators: zodClient(formSchema),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(m["channels.add.success"]());
done();
@@ -5,13 +5,14 @@
import { Input } from "$lib/components/ui/input";
import { TranslationWithComponent } from "$lib/components/ui/translation-with-component";
import { Text } from "$lib/components/ui/typography";
import { auth } from "$lib/stores/auth";
import type { TChannelWithFullAgents } from "$lib/types/channel";
import { getCurrentTranlslation } from "$lib/utils/localizations";
import { toast } from "svelte-sonner";
import { superForm } from "sveltekit-superforms";
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { z } from "zod";
import { formSchema } from ".";
import type { TChannelWithFullAgents } from "$lib/types/channel";
import { getCurrentTranlslation } from "$lib/utils/localizations";
let { entity, done }: { entity: TChannelWithFullAgents; done: () => void } = $props();
@@ -31,6 +32,7 @@
),
),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(m["channels.delete.success"]());
done();
@@ -19,6 +19,7 @@
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { formSchema } from ".";
import { DEFAULT_SLOT_TEMPLATE } from "../utils";
import { auth } from "$lib/stores/auth";
let { entity, done }: { entity: TChannelWithFullAgents; done: () => void } = $props();
const agents = $derived($agentsStore.agents ?? []);
@@ -47,6 +48,7 @@
dataType: "json",
validators: zodClient(formSchema),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(m["channels.edit.success"]());
done();
@@ -12,6 +12,7 @@
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { formSchema } from ".";
import { Checkbox } from "$lib/components/ui/checkbox";
import { auth } from "$lib/stores/auth";
let { entity, done }: { entity: TChannelWithFullAgents; done: () => void } = $props();
@@ -21,6 +22,7 @@
{
validators: zodClient(formSchema),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(
$formData.pause ? m["channels.pause.success"]() : m["channels.unpause.success"](),
@@ -9,13 +9,14 @@
import * as Select from "$lib/components/ui/select";
import { Textarea } from "$lib/components/ui/textarea";
import { supportedLocales, translatedLocales } from "$lib/const/locales";
import { auth } from "$lib/stores/auth";
import { tenants } from "$lib/stores/tenants";
import type { TTenantSettings } from "$lib/types/tenant";
import DefaultOrgIcon from "@lucide/svelte/icons/landmark";
import { toast } from "svelte-sonner";
import { superForm } from "sveltekit-superforms";
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { formSchema } from "./schema";
import { tenants } from "$lib/stores/tenants";
let { entity }: { entity: TTenantSettings } = $props();
@@ -57,6 +58,7 @@
dataType: "json",
validators: zodClient(formSchema),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
tenants.reload();
toast.success(m["tenants.edit.success"]());
@@ -4,6 +4,7 @@
import { Input } from "$lib/components/ui/input";
import * as Select from "$lib/components/ui/select";
import { supportedLocales, translatedLocales } from "$lib/const/locales";
import { auth } from "$lib/stores/auth";
import { tenants } from "$lib/stores/tenants";
import type { TStaff } from "$lib/types/users";
import { toast } from "svelte-sonner";
@@ -29,6 +30,7 @@
dataType: "json",
validators: zodClient(formSchema),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(m["staff.add.success"]());
done();
@@ -5,12 +5,13 @@
import { Input } from "$lib/components/ui/input";
import { TranslationWithComponent } from "$lib/components/ui/translation-with-component";
import { Text } from "$lib/components/ui/typography";
import { auth } from "$lib/stores/auth";
import type { TStaff } from "$lib/types/users";
import { toast } from "svelte-sonner";
import { superForm } from "sveltekit-superforms";
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { z } from "zod";
import { formSchema } from ".";
import type { TStaff } from "$lib/types/users";
let { entity, done }: { entity: TStaff; done: () => void } = $props();
@@ -30,6 +31,7 @@
),
),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(m["staff.delete.success"]());
done();
@@ -10,6 +10,7 @@
import { formSchema } from ".";
import RolePermissions from "../role-permissions.svelte";
import { roles } from "../utils";
import { auth } from "$lib/stores/auth";
let { entity, done }: { entity: TStaff; done: () => void } = $props();
@@ -27,6 +28,7 @@
dataType: "json",
validators: zodClient(formSchema),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(m["staff.edit.success"]());
done();
@@ -23,6 +23,7 @@
let tunnels: ClientTunnelResponse[] = $state([]);
const grantAccess = async () => {
auth.refreshLastActive();
if (!tenantId || !$staffCrypto.crypto) return;
const cryptoClient = $staffCrypto.crypto;
@@ -5,6 +5,7 @@
import { Input } from "$lib/components/ui/input";
import { TranslationWithComponent } from "$lib/components/ui/translation-with-component";
import { Text } from "$lib/components/ui/typography";
import { auth } from "$lib/stores/auth";
import type { TStaff } from "$lib/types/users";
import { toast } from "svelte-sonner";
import { superForm } from "sveltekit-superforms";
@@ -19,6 +20,7 @@
{
validators: zodClient(formSchema),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(m["staff.resendInvite.success"]());
done();
@@ -1,16 +1,17 @@
<script lang="ts">
import { page } from "$app/state";
import { m } from "$i18n/messages.js";
import CheckboxWithLabel from "$lib/components/ui/checkbox-with-label/checkbox-with-label.svelte";
import * as Form from "$lib/components/ui/form";
import { Input } from "$lib/components/ui/input";
import { ERRORS } from "$lib/errors";
import * as Select from "$lib/components/ui/select";
import { TENANT_FEATURE_FLAGS } from "$lib/const/tenants";
import { ERRORS } from "$lib/errors";
import { auth } from "$lib/stores/auth";
import { toast } from "svelte-sonner";
import { superForm } from "sveltekit-superforms";
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { formSchema } from ".";
import { TENANT_FEATURE_FLAGS } from "$lib/const/tenants";
import { page } from "$app/state";
let { done }: { done: () => void } = $props();
@@ -25,6 +26,7 @@
{
validators: zodClient(formSchema),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(m["tenants.add.success"]());
done();
@@ -83,12 +85,12 @@
<Select.Trigger {...props} class="w-full">
{$formData.features.length > 0
? $formData.features
.map((id) => TENANT_FEATURE_FLAGS.find((x) => x === id))
.map((id) => Object.keys(TENANT_FEATURE_FLAGS).find((x) => x === id))
.join(", ")
: m["tenants.add.features.placeholder"]()}
</Select.Trigger>
<Select.Content>
{#each TENANT_FEATURE_FLAGS as feature (feature)}
{#each Object.keys(TENANT_FEATURE_FLAGS) as feature (feature)}
<Select.Item value={feature}>{feature}</Select.Item>
{/each}
</Select.Content>
@@ -33,6 +33,7 @@
),
),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(m["tenants.delete.success"]());
done();
@@ -10,6 +10,7 @@
import * as Select from "$lib/components/ui/select";
import { TENANT_FEATURE_FLAGS } from "$lib/const/tenants";
import { page } from "$app/state";
import { auth } from "$lib/stores/auth";
let { entity, done }: { entity: TTenant; done: () => void } = $props();
@@ -19,6 +20,7 @@
{
validators: zodClient(formSchema),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(m["tenants.edit.success"]());
done();
@@ -66,12 +68,12 @@
<Select.Trigger {...props} class="w-full">
{$formData.features.length > 0
? $formData.features
.map((id) => TENANT_FEATURE_FLAGS.find((x) => x === id))
.map((id) => Object.keys(TENANT_FEATURE_FLAGS).find((x) => x === id))
.join(", ")
: m["tenants.add.features.placeholder"]()}
</Select.Trigger>
<Select.Content>
{#each TENANT_FEATURE_FLAGS as feature (feature)}
{#each Object.keys(TENANT_FEATURE_FLAGS) as feature (feature)}
<Select.Item value={feature}>{feature}</Select.Item>
{/each}
</Select.Content>
@@ -43,6 +43,10 @@
onResult: async (event) => {
if (event.result.type === "success") {
auth.setUser(event.result.data?.user);
// Wait for cookies to be set before navigating to dashboard
await new Promise((resolve) => setTimeout(resolve, 200));
await goto(resolve(ROUTES.DASHBOARD.MAIN));
} else {
if ($formData.type === "passkey" && $formData.id === "") {
@@ -243,85 +243,6 @@ registerOpenAPIRoute("/tenants/{id}/appointments/{appointmentId}", "PUT", {
},
});
// Register OpenAPI documentation for DELETE
registerOpenAPIRoute("/tenants/{id}/appointments/{appointmentId}", "DELETE", {
summary: "Delete appointment",
description: "Deletes a specific appointment. Only staff members can delete appointments.",
tags: ["Appointments"],
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Tenant ID",
},
{
name: "appointmentId",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Appointment ID",
},
],
responses: {
"200": {
description: "Appointment deleted successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
message: { type: "string", description: "Success message" },
},
required: ["message"],
},
},
},
},
"400": {
description: "Invalid input data",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"401": {
description: "Authentication required",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"403": {
description: "Insufficient permissions",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"404": {
description: "Appointment not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
},
});
export const GET: RequestHandler = async ({ params, locals }) => {
const log = logger.setContext("API");
@@ -452,49 +373,3 @@ export const PUT: RequestHandler = async ({ params, locals, request }) => {
return new InternalError().toJson();
}
};
export const DELETE: RequestHandler = async ({ params, locals }) => {
const log = logger.setContext("API");
try {
const tenantId = params.id;
const appointmentId = params.appointmentId;
if (!tenantId || !appointmentId) {
throw new ValidationError("Tenant ID and appointment ID are required");
}
checkPermission(locals, tenantId, true);
log.debug("Deleting appointment", {
tenantId,
appointmentId,
requestedBy: locals.user?.id,
});
const appointmentService = await AppointmentService.forTenant(tenantId);
const deleted = await appointmentService.deleteAppointment(appointmentId);
if (!deleted) {
return json({ error: "Appointment not found" }, { status: 404 });
}
log.debug("Appointment deleted successfully", {
tenantId,
appointmentId,
requestedBy: locals.user?.id,
});
return json({
message: "Appointment deleted successfully",
});
} catch (error) {
logError(log)("Error deleting appointment", error, locals.user?.id, params.id);
if (error instanceof BackendError) {
return error.toJson();
}
return new InternalError().toJson();
}
};
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from "vitest";
import { DELETE, PUT } from "../+server";
import { PUT } from "../+server";
import type { RequestEvent } from "@sveltejs/kit";
// Mock dependencies
@@ -242,133 +242,4 @@ describe("Appointment Detail API Routes", () => {
expect(result.error).toBe("Internal server error");
});
});
describe("DELETE /api/tenants/[id]/appointments/[appointmentId]", () => {
it("should delete appointment for tenant admin", async () => {
mockAppointmentService.deleteAppointment.mockResolvedValue(true);
const event = createMockRequestEvent();
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.message).toBe("Appointment deleted successfully");
expect(mockAppointmentService.deleteAppointment).toHaveBeenCalledWith(mockAppointmentId);
});
it("should allow global admin to delete any tenant's appointments", async () => {
mockAppointmentService.deleteAppointment.mockResolvedValue(true);
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
role: "GLOBAL_ADMIN",
tenantId: "different-tenant",
},
} as any,
});
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.message).toBe("Appointment deleted successfully");
});
it("should return 403 for staff users trying to delete appointments", async () => {
vi.mocked(checkPermission).mockImplementationOnce(() => {
throw new AuthorizationError("Insufficient permissions");
});
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
role: "STAFF",
tenantId: mockTenantId,
},
} as any,
});
const response = await DELETE(event);
const result = await response.json();
expect(response.status).toBe(403);
expect(result.error).toBe("Insufficient permissions");
expect(mockAppointmentService.deleteAppointment).not.toHaveBeenCalled();
});
it("should return 401 for unauthenticated requests", async () => {
vi.mocked(checkPermission).mockImplementationOnce(() => {
throw new AuthenticationError("Authentication required");
});
const event = createMockRequestEvent({ locals: { user: null } as any });
const response = await DELETE(event);
const result = await response.json();
expect(response.status).toBe(401);
expect(result.error).toBe("Authentication required");
});
it("should handle missing tenant ID", async () => {
const event = createMockRequestEvent({
params: { id: undefined, appointmentId: mockAppointmentId },
});
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBe("Tenant ID and appointment ID are required");
});
it("should handle missing appointment ID", async () => {
const event = createMockRequestEvent({
params: { id: mockTenantId, appointmentId: undefined },
});
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBe("Tenant ID and appointment ID are required");
});
it("should handle appointment not found", async () => {
mockAppointmentService.deleteAppointment.mockResolvedValue(false);
const event = createMockRequestEvent();
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(404);
expect(data.error).toBe("Appointment not found");
});
it("should handle service errors", async () => {
mockAppointmentService.deleteAppointment.mockRejectedValue(
new NotFoundError("Appointment not found"),
);
const event = createMockRequestEvent();
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(404);
expect(data.error).toBe("Appointment not found");
});
it("should handle internal server errors", async () => {
mockAppointmentService.deleteAppointment.mockRejectedValue(new Error("Database error"));
const event = createMockRequestEvent();
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(500);
expect(data.error).toBe("Internal server error");
});
});
});