mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-09-13 12:47:39 +02:00
Merge remote-tracking branch 'origin/main' into feat/client-pin-reset
This commit is contained in:
Generated
+89
-82
@@ -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
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
+4
-2
@@ -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();
|
||||
|
||||
+2
@@ -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();
|
||||
|
||||
+10
-8
@@ -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();
|
||||
|
||||
+1
@@ -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") {
|
||||
|
||||
+1
@@ -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") {
|
||||
|
||||
+3
@@ -38,6 +38,9 @@
|
||||
},
|
||||
{
|
||||
validators: zodClient(formSchema),
|
||||
onResult: () => {
|
||||
auth.refreshLastActive();
|
||||
},
|
||||
onChange: (event) => {
|
||||
if (event.paths.includes("email")) {
|
||||
setProperPasskeyState();
|
||||
|
||||
+2
@@ -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
@@ -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();
|
||||
|
||||
+4
-2
@@ -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();
|
||||
|
||||
+2
@@ -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();
|
||||
|
||||
+4
-2
@@ -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)) {
|
||||
|
||||
+5
-1
@@ -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>
|
||||
|
||||
+4
-7
@@ -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;
|
||||
};
|
||||
+2
@@ -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();
|
||||
|
||||
+4
-2
@@ -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();
|
||||
|
||||
+2
@@ -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();
|
||||
|
||||
+2
@@ -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"](),
|
||||
|
||||
+3
-1
@@ -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"]());
|
||||
|
||||
+2
@@ -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();
|
||||
|
||||
+3
-1
@@ -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();
|
||||
|
||||
+2
@@ -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();
|
||||
|
||||
+1
@@ -23,6 +23,7 @@
|
||||
let tunnels: ClientTunnelResponse[] = $state([]);
|
||||
|
||||
const grantAccess = async () => {
|
||||
auth.refreshLastActive();
|
||||
if (!tenantId || !$staffCrypto.crypto) return;
|
||||
const cryptoClient = $staffCrypto.crypto;
|
||||
|
||||
|
||||
+2
@@ -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();
|
||||
|
||||
+7
-5
@@ -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>
|
||||
|
||||
+1
@@ -33,6 +33,7 @@
|
||||
),
|
||||
),
|
||||
onResult: async (event) => {
|
||||
auth.refreshLastActive();
|
||||
if (event.result.type === "success") {
|
||||
toast.success(m["tenants.delete.success"]());
|
||||
done();
|
||||
|
||||
+4
-2
@@ -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
-130
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user