diff --git a/README.md b/README.md index cb36eea..cb07b75 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,14 @@ End-to-end encrypted appointment booking platform +## Local development + +- `npm install` +- `npm run docker:dev:up` +- `npm run db:migrate` +- `npm run db:push` +- `npm run dev` + ## Funding This project is funded by: diff --git a/package-lock.json b/package-lock.json index 8751463..4da4c5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,6 +50,7 @@ "eslint": "^9.29.0", "eslint-config-prettier": "^10.1.5", "eslint-plugin-svelte": "^3.9.3", + "formsnap": "^2.0.1", "globals": "^16.2.0", "jsdom": "^26.1.0", "prettier": "^3.5.3", @@ -57,6 +58,8 @@ "prettier-plugin-tailwindcss": "^0.6.13", "svelte": "^5.34.7", "svelte-check": "^4.2.2", + "svelte-sonner": "^1.0.5", + "sveltekit-superforms": "^2.27.1", "tailwind-merge": "^3.3.1", "tailwind-variants": "^1.0.0", "tailwindcss": "^4.1.10", @@ -87,6 +90,25 @@ "node": ">=6.0.0" } }, + "node_modules/@ark/schema": { + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@ark/schema/-/schema-0.46.0.tgz", + "integrity": "sha512-c2UQdKgP2eqqDArfBqQIJppxJHvNNXuQPeuSPlDML4rjw+f1cu0qAlzOG4b8ujgm9ctIDWwhpyw6gjG5ledIVQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@ark/util": "0.46.0" + } + }, + "node_modules/@ark/util": { + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@ark/util/-/util-0.46.0.tgz", + "integrity": "sha512-JPy/NGWn/lvf1WmGCPw2VGpBg5utZraE84I7wli18EDF3p3zc/e9WolT35tINeZO3l7C77SjqRJeAUoT0CvMRg==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", @@ -1299,6 +1321,14 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/schemasafe": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@exodus/schemasafe/-/schemasafe-1.3.0.tgz", + "integrity": "sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/@floating-ui/core": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.2.tgz", @@ -1327,6 +1357,58 @@ "dev": true, "license": "MIT" }, + "node_modules/@gcornut/valibot-json-schema": { + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@gcornut/valibot-json-schema/-/valibot-json-schema-0.42.0.tgz", + "integrity": "sha512-4Et4AN6wmqeA0PfU5Clkv/IS27wiefsWf6TemAZrb75uzkClYEFavim7SboeKwbll9Nbsn2Iv0LT/HS5H7orZg==", + "dev": true, + "optional": true, + "dependencies": { + "valibot": "~0.42.0" + }, + "bin": { + "valibot-json-schema": "bin/index.js" + }, + "optionalDependencies": { + "@types/json-schema": ">= 7.0.14", + "esbuild-runner": ">= 2.2.2" + } + }, + "node_modules/@gcornut/valibot-json-schema/node_modules/valibot": { + "version": "0.42.1", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-0.42.1.tgz", + "integrity": "sha512-3keXV29Ar5b//Hqi4MbSdV7lfVp6zuYLZuA9V1PvQUsXqogr+u5lvLPLk3A4f74VUXDnf/JfWMN6sB+koJ/FFw==", + "dev": true, + "license": "MIT", + "optional": true, + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1647,6 +1729,14 @@ "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", "license": "MIT" }, + "node_modules/@poppinss/macroable": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@poppinss/macroable/-/macroable-1.0.5.tgz", + "integrity": "sha512-6u61y1HHd090MEk1Av0/1btDmm2Hh/+XoJj+HgFYRh9koUPI822ybJbwLHuqjLNCiY+o1gRykg2igEqOf/VBZw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/@rollup/plugin-commonjs": { "version": "28.0.6", "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.6.tgz", @@ -2020,6 +2110,33 @@ "win32" ] }, + "node_modules/@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, "node_modules/@sinclair/typebox": { "version": "0.31.28", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.31.28.tgz", @@ -2037,6 +2154,14 @@ "sqlite-wasm": "bin/index.js" } }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/@sveltejs/acorn-typescript": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.5.tgz", @@ -2629,6 +2754,49 @@ "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", "license": "MIT" }, + "node_modules/@types/validator": { + "version": "13.15.2", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.2.tgz", + "integrity": "sha512-y7pa/oEJJ4iGYBxOpfAKn5b9+xuihvzDVnC/OSvlVnGxVg0pOqmjiMafiJ1KVNQEaPZf9HsEp5icEwGg8uIe5Q==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@typeschema/class-validator": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@typeschema/class-validator/-/class-validator-0.3.0.tgz", + "integrity": "sha512-OJSFeZDIQ8EK1HTljKLT5CItM2wsbgczLN8tMEfz3I1Lmhc5TBfkZ0eikFzUC16tI3d1Nag7um6TfCgp2I2Bww==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@typeschema/core": "0.14.0" + }, + "peerDependencies": { + "class-validator": "^0.14.1" + }, + "peerDependenciesMeta": { + "class-validator": { + "optional": true + } + } + }, + "node_modules/@typeschema/core": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@typeschema/core/-/core-0.14.0.tgz", + "integrity": "sha512-Ia6PtZHcL3KqsAWXjMi5xIyZ7XMH4aSnOQes8mfMLx+wGFGtGRNlwe6Y7cYvX+WfNK67OL0/HSe9t8QDygV0/w==", + "dev": true, + "license": "MIT", + "optional": true, + "peerDependencies": { + "@types/json-schema": "^7.0.15" + }, + "peerDependenciesMeta": { + "@types/json-schema": { + "optional": true + } + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.34.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.34.1.tgz", @@ -2886,6 +3054,38 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@vinejs/compiler": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@vinejs/compiler/-/compiler-3.0.0.tgz", + "integrity": "sha512-v9Lsv59nR56+bmy2p0+czjZxsLHwaibJ+SV5iK9JJfehlJMa501jUJQqqz4X/OqKXrxtE3uTQmSqjUqzF3B2mw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@vinejs/vine": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@vinejs/vine/-/vine-3.0.1.tgz", + "integrity": "sha512-ZtvYkYpZOYdvbws3uaOAvTFuvFXoQGAtmzeiXu+XSMGxi5GVsODpoI9Xu9TplEMuD/5fmAtBbKb9cQHkWkLXDQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@poppinss/macroable": "^1.0.4", + "@types/validator": "^13.12.2", + "@vinejs/compiler": "^3.0.0", + "camelcase": "^8.0.0", + "dayjs": "^1.11.13", + "dlv": "^1.1.3", + "normalize-url": "^8.0.1", + "validator": "^13.12.0" + }, + "engines": { + "node": ">=18.16.0" + } + }, "node_modules/@vitest/expect": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", @@ -3113,6 +3313,18 @@ "node": ">= 0.4" } }, + "node_modules/arktype": { + "version": "2.1.20", + "resolved": "https://registry.npmjs.org/arktype/-/arktype-2.1.20.tgz", + "integrity": "sha512-IZCEEXaJ8g+Ijd59WtSYwtjnqXiwM8sWQ5EjGamcto7+HVN9eK0C4p0zDlCuAwWhpqr6fIBkxPuYDl4/Mcj/+Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@ark/schema": "0.46.0", + "@ark/util": "0.46.0" + } + }, "node_modules/array-timsort": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", @@ -3266,6 +3478,20 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", + "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/chai": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", @@ -3333,6 +3559,19 @@ "node": ">=18" } }, + "node_modules/class-validator": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.2.tgz", + "integrity": "sha512-3kMVRF2io8N8pY1IFIXlho9r8IPUUIfHe2hYVtiebvAzU2XeQFXTv+XI4WX+TnXmtwXMDcjngcpkiPM0O9PvLw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/validator": "^13.11.8", + "libphonenumber-js": "^1.11.1", + "validator": "^13.9.0" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -3545,6 +3784,14 @@ "url": "https://github.com/sponsors/kossnocorp" } }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -3636,6 +3883,14 @@ "integrity": "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==", "license": "MIT" }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/dom-accessibility-api": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", @@ -3811,6 +4066,18 @@ } } }, + "node_modules/effect": { + "version": "3.17.7", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.17.7.tgz", + "integrity": "sha512-dpt0ONUn3zzAuul6k4nC/coTTw27AL5nhkORXgTi6NfMPzqWYa1M05oKmOMTxpVSTKepqXVcW9vIwkuaaqx9zA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, "node_modules/enabled": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", @@ -3918,6 +4185,32 @@ "esbuild": ">=0.12 <1" } }, + "node_modules/esbuild-runner": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/esbuild-runner/-/esbuild-runner-2.2.2.tgz", + "integrity": "sha512-fRFVXcmYVmSmtYm2mL8RlUASt2TDkGh3uRcvHFOKNr/T58VrfVeKD9uT9nlgxk96u0LS0ehS/GY7Da/bXWKkhw==", + "dev": true, + "license": "Apache License 2.0", + "optional": true, + "dependencies": { + "source-map-support": "0.5.21", + "tslib": "2.4.0" + }, + "bin": { + "esr": "bin/esr.js" + }, + "peerDependencies": { + "esbuild": "*" + } + }, + "node_modules/esbuild-runner/node_modules/tslib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -4202,6 +4495,30 @@ "node": ">=12.0.0" } }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4353,6 +4670,47 @@ "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", "license": "MIT" }, + "node_modules/formsnap": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/formsnap/-/formsnap-2.0.1.tgz", + "integrity": "sha512-iJSe4YKd/W6WhLwKDVJU9FQeaJRpEFuolhju7ZXlRpUVyDdqFdMP8AUBICgnVvQPyP41IPAlBa/v0Eo35iE6wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "svelte-toolbelt": "^0.5.0" + }, + "engines": { + "node": ">=18", + "pnpm": ">=8.7.0" + }, + "funding": { + "url": "https://github.com/sponsors/huntabyte" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "sveltekit-superforms": "^2.19.0" + } + }, + "node_modules/formsnap/node_modules/svelte-toolbelt": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.5.0.tgz", + "integrity": "sha512-t3tenZcnfQoIeRuQf/jBU7bvTeT3TGkcEE+1EUr5orp0lR7NEpprflpuie3x9Dn0W9nOKqs3HwKGJeeN5Ok1sQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte" + ], + "dependencies": { + "clsx": "^2.1.1", + "style-to-object": "^1.0.8" + }, + "engines": { + "node": ">=18", + "pnpm": ">=8.7.0" + }, + "peerDependencies": { + "svelte": "^5.0.0-next.126" + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -4739,6 +5097,21 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/joi": { + "version": "17.13.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", + "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, "node_modules/jose": { "version": "6.0.11", "resolved": "https://registry.npmjs.org/jose/-/jose-6.0.11.tgz", @@ -4822,6 +5195,21 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -4905,6 +5293,14 @@ "node": ">= 0.8.0" } }, + "node_modules/libphonenumber-js": { + "version": "1.12.12", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.12.tgz", + "integrity": "sha512-aWVR6xXYYRvnK0v/uIwkf5Lthq9Jpn0N8TISW/oDTWlYB2sOimuiLn9Q26aUw4KxkJoiT8ACdiw44Y8VwKFIfQ==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/lightningcss": { "version": "1.30.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", @@ -5244,6 +5640,13 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, + "node_modules/memoize-weak": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/memoize-weak/-/memoize-weak-1.0.2.tgz", + "integrity": "sha512-gj39xkrjEw7nCn4nJ1M5ms6+MyMlyiGmttzsqAUsAKn6bYKwuTHh/AO3cKPF8IBrTIYTxb0wWXFs3E//Y8VoWQ==", + "dev": true, + "license": "ISC" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -5434,6 +5837,20 @@ "node": ">=6.0.0" } }, + "node_modules/normalize-url": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.0.2.tgz", + "integrity": "sha512-Ee/R3SyN4BuynXcnTaekmaVdbDAEiNrHqjQIA37mHU8G9pf7aaAD4ZX3XjBLo6rsdcxA/gtkcNYZLt30ACgynw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/nwsapi": { "version": "2.2.20", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz", @@ -5912,6 +6329,14 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/property-expr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", + "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5922,6 +6347,24 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "optional": true + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -6426,6 +6869,17 @@ "inline-style-parser": "0.2.4" } }, + "node_modules/superstruct": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/superstruct/-/superstruct-2.0.2.tgz", + "integrity": "sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -6543,6 +6997,36 @@ "node": ">=4" } }, + "node_modules/svelte-sonner": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/svelte-sonner/-/svelte-sonner-1.0.5.tgz", + "integrity": "sha512-9dpGPFqKb/QWudYqGnEz93vuY+NgCEvyNvxoCLMVGw6sDN/3oVeKV1xiEirW2E1N3vJEyj5imSBNOGltQHA7mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "runed": "^0.28.0" + }, + "peerDependencies": { + "svelte": "^5.0.0" + } + }, + "node_modules/svelte-sonner/node_modules/runed": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.28.0.tgz", + "integrity": "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "license": "MIT", + "dependencies": { + "esm-env": "^1.0.0" + }, + "peerDependencies": { + "svelte": "^5.7.0" + } + }, "node_modules/svelte-toolbelt": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.7.1.tgz", @@ -6578,6 +7062,111 @@ "svelte": "^5.7.0" } }, + "node_modules/sveltekit-superforms": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/sveltekit-superforms/-/sveltekit-superforms-2.27.1.tgz", + "integrity": "sha512-cvq2AevkZ0Zrk0w0gNM3kjcnJMtJ0jzu+2zqDoM9a+lZa+8bGpNl4YqxVkemiJNkGnFgNC8xr5xF5BlMzjookQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ciscoheat" + }, + { + "type": "ko-fi", + "url": "https://ko-fi.com/ciscoheat" + }, + { + "type": "paypal", + "url": "https://www.paypal.com/donate/?hosted_button_id=NY7F5ALHHSVQS" + } + ], + "license": "MIT", + "dependencies": { + "devalue": "^5.1.1", + "memoize-weak": "^1.0.2", + "ts-deepmerge": "^7.0.3" + }, + "optionalDependencies": { + "@exodus/schemasafe": "^1.3.0", + "@gcornut/valibot-json-schema": "^0.42.0", + "@sinclair/typebox": "^0.34.35", + "@typeschema/class-validator": "^0.3.0", + "@vinejs/vine": "^3.0.1", + "arktype": "^2.1.20", + "class-validator": "^0.14.2", + "effect": "^3.16.7", + "joi": "^17.13.3", + "json-schema-to-ts": "^3.1.1", + "superstruct": "^2.0.2", + "valibot": "^1.1.0", + "yup": "^1.6.1", + "zod": "^3.25.64", + "zod-to-json-schema": "^3.24.5" + }, + "peerDependencies": { + "@exodus/schemasafe": "^1.3.0", + "@sinclair/typebox": "^0.34.28", + "@sveltejs/kit": "1.x || 2.x", + "@typeschema/class-validator": "^0.3.0", + "@vinejs/vine": "^1.8.0 || ^2.0.0 || ^3.0.0", + "arktype": ">=2.0.0-rc.23", + "class-validator": "^0.14.1", + "effect": "^3.13.7", + "joi": "^17.13.1", + "superstruct": "^2.0.2", + "svelte": "3.x || 4.x || >=5.0.0-next.51", + "valibot": "^1.0.0", + "yup": "^1.4.0", + "zod": "^3.25.0" + }, + "peerDependenciesMeta": { + "@exodus/schemasafe": { + "optional": true + }, + "@sinclair/typebox": { + "optional": true + }, + "@typeschema/class-validator": { + "optional": true + }, + "@vinejs/vine": { + "optional": true + }, + "arktype": { + "optional": true + }, + "class-validator": { + "optional": true + }, + "effect": { + "optional": true + }, + "joi": { + "optional": true + }, + "superstruct": { + "optional": true + }, + "valibot": { + "optional": true + }, + "yup": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/sveltekit-superforms/node_modules/@sinclair/typebox": { + "version": "0.34.40", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.40.tgz", + "integrity": "sha512-gwBNIP8ZAYev/ORDWW0QvxdwPXwxBtLsdsJgSc7eDIRt8ubP+rxUBzPsrwnu16fgEF8Bx4lh/+mvQvJzcTM6Kw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -6672,6 +7261,14 @@ "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", "license": "MIT" }, + "node_modules/tiny-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -6765,6 +7362,14 @@ "node": ">=8.0" } }, + "node_modules/toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -6809,6 +7414,14 @@ "node": ">= 14.0.0" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -6822,6 +7435,16 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-deepmerge": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/ts-deepmerge/-/ts-deepmerge-7.0.3.tgz", + "integrity": "sha512-Du/ZW2RfwV/D4cmA5rXafYjBQVuvu4qGiEEla4EmEHVHgRdx68Gftx7i66jn2bzHPwSVZY36Ae6OuDn9el4ZKA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14.13.1" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -6852,6 +7475,20 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", @@ -6957,6 +7594,33 @@ "uuidv7": "cli.js" } }, + "node_modules/valibot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.1.0.tgz", + "integrity": "sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw==", + "dev": true, + "license": "MIT", + "optional": true, + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/validator": { + "version": "13.15.15", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz", + "integrity": "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vite": { "version": "6.3.5", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", @@ -7381,6 +8045,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yup": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.7.0.tgz", + "integrity": "sha512-VJce62dBd+JQvoc+fCVq+KZfPHr+hXaxCcVgotfwWvlR0Ja3ffYKaJBT8rptPOSKOGJDCUnW2C2JWpud7aRP6Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "property-expr": "^2.0.5", + "tiny-case": "^1.0.3", + "toposort": "^2.0.2", + "type-fest": "^2.19.0" + } + }, "node_modules/zimmerframe": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz", @@ -7395,6 +8073,17 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-to-json-schema": { + "version": "3.24.6", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz", + "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", + "dev": true, + "license": "ISC", + "optional": true, + "peerDependencies": { + "zod": "^3.24.1" + } } } } diff --git a/package.json b/package.json index d9a7ee5..3fb41c6 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "eslint": "^9.29.0", "eslint-config-prettier": "^10.1.5", "eslint-plugin-svelte": "^3.9.3", + "formsnap": "^2.0.1", "globals": "^16.2.0", "jsdom": "^26.1.0", "prettier": "^3.5.3", @@ -65,6 +66,8 @@ "prettier-plugin-tailwindcss": "^0.6.13", "svelte": "^5.34.7", "svelte-check": "^4.2.2", + "svelte-sonner": "^1.0.5", + "sveltekit-superforms": "^2.27.1", "tailwind-merge": "^3.3.1", "tailwind-variants": "^1.0.0", "tailwindcss": "^4.1.10", diff --git a/project.inlang/messages/de.json b/project.inlang/messages/de.json index 5fe9afb..e155f42 100644 --- a/project.inlang/messages/de.json +++ b/project.inlang/messages/de.json @@ -6,5 +6,91 @@ "label": "Sprache wählen", "search": "Sprache suchen", "notFound": "Sprache nicht gefunden" + }, + "setup": { + "start": "Konfiguration starten", + "create_admin_account": { + "title": "Administratorkonto hinzufügen", + "description": "Wir beginnen damit, Ihr Administratorkonto hinzuzufügen. Es wird zur Verwaltung dieser Instanz verwendet.", + "action": "Bestätigungsmail senden", + "success": "Konto erstellt!" + }, + "verify_email": { + "title": "Rufe Deine E-Mails ab", + "description": "Wir haben Dir einen magischen Link an {email} gesendet", + "action": "E-Mail erneut senden", + "success": "E-Mail wurde gesendet" + }, + "confirm": { + "success": { + "title": "Gute Arbeit!", + "description": "Du hast Dir diese Instanz gesichert.", + "hint": "Im nächsten Schritt legen wir einen Mandanten an, damit dieser Termine entgegen nehmen kann", + "action": "Ersten Mandanten anlegen" + }, + "error": { + "title": "Bestätigung fehlgeschlagen", + "description": "Dein magischer Link war inkorrekt oder veraltet" + } + } + }, + "slogan": "Ende zu Ende verschlüsselte Platform für Termine.", + "logo": "OpenReception Logo: Ein 3D-Pixel-Art mit Kalender, der auch ein Schloss ist", + "form": { + "email": "E-Mail Adresse", + "passphrase": "Passphrase", + "passphraseRequirements": "Mindestens 30 Zeichen.", + "errors": { + "email": "Ungültiges Format", + "passphrase": "Muss mindestens 30 Zeichen lang sein", + "noPassAtAll": "Either passphrase or passkey is required", + "bothPassSet": "Nur Passphrase oder Passkey darf gesetzt sein" + }, + "passkey": "Passkey" + }, + "login": { + "or": "Oder", + "usePasskey": "Passkey verwenden", + "usePassphrase": "Passphrase verwenden", + "title": "Anmelden", + "description_passphrase": "Gib Deine E-Mail-Adresse und Deinen Passphrase ein, um Dich anzumelden.", + "action": "Anmelden", + "error": "Anmeldung fehlgeschlagen. Bitte erneut versuchen" + }, + "confirm": { + "success": { + "title": "Du bist bereit!", + "description": "Du hast Dein Konto erfolgreich gesichert", + "action": "Weiter" + }, + "error": { + "title": "Bestätigung fehlgeschlagen", + "description": "Dein magischer Link war inkorrekt oder veraltet", + "action": "Neue E-Mail anfordern" + }, + "title": "E-Mail wird verifiziert", + "resend": { + "title": "Bestätigungsmail erneut senden", + "description": "Gib' Deine E-Mail Adresse an, damit wir Dir einen neuen magischen Link senden können.", + "action": "E-Mail erneut senden", + "success": "E-Mail gesendet" + } + }, + "logout": { + "title": "Erfolgreich abgemeldet", + "description": "Du wurdest abgemeldet.", + "success": "Erfolgreich abgemeldet", + "action": "Zur Anmeldung" + }, + "passkey": { + "add": { + "initial": "Warten auf E-Mail Adresse", + "click": "Klicke/Tippe um einen Passkey hinzuzufügen", + "loading": "Lade Passkey-Challenge", + "user": "Warte auf Passkey-Erstellung", + "success": "Passkey hinzugefügt", + "error": "Passkey konnte nicht hinzugefügt werden", + "retry": "Erneut versuchen" + } } } diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json index 0f9e789..43b650e 100644 --- a/project.inlang/messages/en.json +++ b/project.inlang/messages/en.json @@ -6,5 +6,99 @@ "label": "Select Language", "search": "Search Language", "notFound": "Language not found" + }, + "setup": { + "start": "Start configuration", + "create_admin_account": { + "title": "Create Admin Account", + "description": "We’ll start by adding your admin account. It will be used to manage this instance.", + "action": "Send Verification E-Mail", + "success": "Account created!", + "error": "An unkonwn error occured." + }, + "verify_email": { + "title": "Check your E-Mail", + "description": "We’ve send you a magic link to {email}", + "action": "Resend E-Mail", + "success": "E-Mail was resent" + }, + "confirm": { + "success": { + "title": "Good job!", + "description": "You’ve secured this instance.", + "hint": "Next step is setting up a tenant to collect appointments", + "action": "Add first Tenant" + }, + "error": { + "title": "Verification failed", + "description": "The magic link you’ve tried is incorrect or outdated" + } + } + }, + "slogan": "End-to-End encrypted appointment booking platform.", + "logo": "OpenReception Logo: A 3D pixel art with calendar that's also a lock", + "form": { + "email": "E-Mail Address", + "passphrase": "Passphrase", + "passphraseRequirements": "At least 30 characters.", + "errors": { + "email": "Invalid format", + "passphrase": "Must be at least 30 characters long", + "noPassAtAll": "Either passphrase or passkey is required", + "bothPassSet": "Only one of passphrase or passkey should be provided" + }, + "noPassAtAll": "Either passphrase or passkey is required", + "passkey": "Passkey" + }, + "login": { + "or": "Or", + "usePasskey": "use passkey", + "usePassphrase": "use passphrase", + "orUse": "Or {button}", + "title": "Login", + "description_passphrase": "Enter your email and passphrase below to access your account.", + "action": "Login", + "error": "Login failed. Please retry", + "passphrase": { + "initial": "Requesting passkey", + "success": "Passkey saved", + "error": "Passkey could not be retrieved" + } + }, + "confirm": { + "success": { + "title": "You’re all set!", + "description": "You’ve successfully secured your account", + "action": "Proceed" + }, + "error": { + "title": "Verification failed", + "description": "The magic link you’ve tried is incorrect or outdated", + "action": "Request new E-Mail" + }, + "title": "E-Mail verification", + "resend": { + "title": "Resend Verification E-Mail", + "description": "Enter your e-mail address to get the verification link again.", + "action": "Resend E-Mail", + "success": "E-Mail sent" + } + }, + "logout": { + "title": "Logout complete", + "description": "You are logged-out.", + "success": "Logout successful", + "action": "Go to Login" + }, + "passkey": { + "add": { + "initial": "Waiting for E-Mail Address", + "click": "Click/Tap to add passkey", + "loading": "Completing challenge", + "user": "Waiting for creation to complete", + "success": "Added Passkey successfully", + "error": "Passkey could not be added", + "retry": "Retry" + } } } diff --git a/src/app.css b/src/app.css index 29cefe3..a856a7a 100644 --- a/src/app.css +++ b/src/app.css @@ -126,6 +126,9 @@ --color-medium: var(--medium); --color-dark: var(--dark); --color-darker: var(--darker); + + --max-w-sm: 400px; + --max-w-md: 640px; } @layer base { diff --git a/src/app.d.ts b/src/app.d.ts index a7c8026..8876c95 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -13,6 +13,9 @@ declare global { // interface PageState {} // interface Platform {} } + interface PageState { + email?: string; + } } export {}; diff --git a/src/hooks.server.ts b/src/hooks.server.ts index e98e909..8d234ca 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -5,7 +5,8 @@ import { corsHandle } from "./server-hooks/corsHandle"; import { loggingHandle } from "./server-hooks/loggingHandle"; import { rateLimitHandle } from "./server-hooks/rateLimitHandle"; import { secHeaderHandle } from "./server-hooks/secHeaderHandle"; -import { authHandle } from "./server-hooks/authHandle"; +import { apiAuthHandle } from "./server-hooks/apiAuthHandle"; +import { authGuard } from "./server-hooks/authGuard"; import { i18nHandle } from "./server-hooks/i18nHandle"; import { building } from "$app/environment"; @@ -40,5 +41,6 @@ export const handle = sequence( rateLimitHandle, corsHandle, secHeaderHandle, - authHandle + apiAuthHandle, + authGuard ); diff --git a/src/lib/components/layouts/centered-card/action-hint.svelte b/src/lib/components/layouts/centered-card/action-hint.svelte new file mode 100644 index 0000000..e053c8d --- /dev/null +++ b/src/lib/components/layouts/centered-card/action-hint.svelte @@ -0,0 +1,10 @@ + + + + {@render children?.()} + diff --git a/src/lib/components/layouts/centered-card/action.svelte b/src/lib/components/layouts/centered-card/action.svelte new file mode 100644 index 0000000..eab531b --- /dev/null +++ b/src/lib/components/layouts/centered-card/action.svelte @@ -0,0 +1,9 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/layouts/centered-card/description.svelte b/src/lib/components/layouts/centered-card/description.svelte new file mode 100644 index 0000000..df36383 --- /dev/null +++ b/src/lib/components/layouts/centered-card/description.svelte @@ -0,0 +1,11 @@ + + + + {@render children?.()} + diff --git a/src/lib/components/layouts/centered-card/header.svelte b/src/lib/components/layouts/centered-card/header.svelte new file mode 100644 index 0000000..0f8d05d --- /dev/null +++ b/src/lib/components/layouts/centered-card/header.svelte @@ -0,0 +1,9 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/layouts/centered-card/index.ts b/src/lib/components/layouts/centered-card/index.ts new file mode 100644 index 0000000..b88fb2e --- /dev/null +++ b/src/lib/components/layouts/centered-card/index.ts @@ -0,0 +1,9 @@ +import Action from "./action.svelte"; +import ActionHint from "./action-hint.svelte"; +import Description from "./description.svelte"; +import Header from "./header.svelte"; +import Main from "./main.svelte"; +import Root from "./root.svelte"; +import Title from "./title.svelte"; + +export { Action, ActionHint, Description, Header, Main, Root, Title }; diff --git a/src/lib/components/layouts/centered-card/main.svelte b/src/lib/components/layouts/centered-card/main.svelte new file mode 100644 index 0000000..925987c --- /dev/null +++ b/src/lib/components/layouts/centered-card/main.svelte @@ -0,0 +1,10 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/layouts/centered-card/root.svelte b/src/lib/components/layouts/centered-card/root.svelte new file mode 100644 index 0000000..3715f1d --- /dev/null +++ b/src/lib/components/layouts/centered-card/root.svelte @@ -0,0 +1,21 @@ + + + + + {@render children?.()} + + diff --git a/src/lib/components/layouts/centered-card/title.svelte b/src/lib/components/layouts/centered-card/title.svelte new file mode 100644 index 0000000..9efb262 --- /dev/null +++ b/src/lib/components/layouts/centered-card/title.svelte @@ -0,0 +1,11 @@ + + + + {@render children?.()} + diff --git a/src/lib/components/layouts/empty-layout/action.svelte b/src/lib/components/layouts/empty-layout/action.svelte new file mode 100644 index 0000000..eac18d1 --- /dev/null +++ b/src/lib/components/layouts/empty-layout/action.svelte @@ -0,0 +1,7 @@ + + +{@render children?.()} diff --git a/src/lib/components/layouts/empty-layout/index.ts b/src/lib/components/layouts/empty-layout/index.ts new file mode 100644 index 0000000..5f51c7f --- /dev/null +++ b/src/lib/components/layouts/empty-layout/index.ts @@ -0,0 +1,5 @@ +import Root from "./root.svelte"; +import Action from "./action.svelte"; +import Main from "./main.svelte"; + +export { Root, Action, Main }; diff --git a/src/lib/components/layouts/empty-layout/main.svelte b/src/lib/components/layouts/empty-layout/main.svelte new file mode 100644 index 0000000..e8a5e3a --- /dev/null +++ b/src/lib/components/layouts/empty-layout/main.svelte @@ -0,0 +1,16 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/layouts/empty-layout/root.svelte b/src/lib/components/layouts/empty-layout/root.svelte new file mode 100644 index 0000000..c0150e2 --- /dev/null +++ b/src/lib/components/layouts/empty-layout/root.svelte @@ -0,0 +1,18 @@ + + + +
+ {@render children?.()} +
+
diff --git a/src/lib/components/layouts/index.ts b/src/lib/components/layouts/index.ts new file mode 100644 index 0000000..be69dbe --- /dev/null +++ b/src/lib/components/layouts/index.ts @@ -0,0 +1,4 @@ +import * as EmptyLayout from "./empty-layout"; +import * as CenteredCard from "./centered-card"; + +export { EmptyLayout, CenteredCard }; diff --git a/src/lib/components/templates/empty-state/center-loading-state.svelte b/src/lib/components/templates/empty-state/center-loading-state.svelte new file mode 100644 index 0000000..5f4cad6 --- /dev/null +++ b/src/lib/components/templates/empty-state/center-loading-state.svelte @@ -0,0 +1,14 @@ + + +
+ + + + +
diff --git a/src/lib/components/templates/empty-state/empty-state.svelte b/src/lib/components/templates/empty-state/empty-state.svelte new file mode 100644 index 0000000..e07ec90 --- /dev/null +++ b/src/lib/components/templates/empty-state/empty-state.svelte @@ -0,0 +1,18 @@ + + +
+ + {headline} + {description} +
diff --git a/src/lib/components/templates/empty-state/index.ts b/src/lib/components/templates/empty-state/index.ts new file mode 100644 index 0000000..eeba8e0 --- /dev/null +++ b/src/lib/components/templates/empty-state/index.ts @@ -0,0 +1,4 @@ +import CenterState from "./empty-state.svelte"; +import CenterLoadingState from "./center-loading-state.svelte"; + +export { CenterState, CenterLoadingState }; diff --git a/src/lib/components/templates/index.ts b/src/lib/components/templates/language-switch/index.ts similarity index 100% rename from src/lib/components/templates/index.ts rename to src/lib/components/templates/language-switch/index.ts diff --git a/src/lib/components/templates/language-switch.svelte b/src/lib/components/templates/language-switch/language-switch.svelte similarity index 62% rename from src/lib/components/templates/language-switch.svelte rename to src/lib/components/templates/language-switch/language-switch.svelte index a82e6dd..890ed83 100644 --- a/src/lib/components/templates/language-switch.svelte +++ b/src/lib/components/templates/language-switch/language-switch.svelte @@ -1,10 +1,17 @@ -
+
diff --git a/src/lib/components/ui/button/button.svelte b/src/lib/components/ui/button/button.svelte index 4da437d..46d9ec8 100644 --- a/src/lib/components/ui/button/button.svelte +++ b/src/lib/components/ui/button/button.svelte @@ -1,10 +1,11 @@ @@ -49,6 +52,7 @@ type = "button", disabled, children, + isLoading, ...restProps }: ButtonProps = $props(); @@ -75,6 +79,9 @@ {disabled} {...restProps} > + {#if isLoading} + + {/if} {@render children?.()} {/if} diff --git a/src/lib/components/ui/combobox/combobox.svelte b/src/lib/components/ui/combobox/combobox.svelte index e95177b..7286e41 100644 --- a/src/lib/components/ui/combobox/combobox.svelte +++ b/src/lib/components/ui/combobox/combobox.svelte @@ -4,14 +4,17 @@ import { tick } from "svelte"; import * as Command from "$lib/components/ui/command/index.js"; import * as Popover from "$lib/components/ui/popover/index.js"; - import { Button } from "$lib/components/ui/button/index.js"; + import { Button, type ButtonSize, type ButtonVariant } from "$lib/components/ui/button/index.js"; import { cn } from "$lib/utils.js"; const { labels, value, options = [], - onChange + onChange, + class: className = "", + triggerVariant = "outline", + triggerSize = "default" }: { labels: { placeholder: string; @@ -20,6 +23,9 @@ }; value: string; onChange: (value: string) => void; + triggerVariant?: ButtonVariant; + triggerSize?: ButtonSize; + class?: string; options?: { label: string; value: string; keywords?: string[] }[]; } = $props(); @@ -42,8 +48,9 @@ - {/if} +
+ {#if isWithLanguageSwitch} + + - + {/if} + {@render children?.()} + + + {#if dev} + + Viewport: +
xs
+ + + + + +
+ {/if} + + {m.poweredBy()} + OpenReception + + {#if dev} + + {/if} +
diff --git a/src/lib/components/ui/passkey/index.ts b/src/lib/components/ui/passkey/index.ts new file mode 100644 index 0000000..d53af12 --- /dev/null +++ b/src/lib/components/ui/passkey/index.ts @@ -0,0 +1,5 @@ +import State from "./state.svelte"; + +const Passkey = { State }; + +export { Passkey }; diff --git a/src/lib/components/ui/passkey/state.svelte b/src/lib/components/ui/passkey/state.svelte new file mode 100644 index 0000000..be07830 --- /dev/null +++ b/src/lib/components/ui/passkey/state.svelte @@ -0,0 +1,55 @@ + + + diff --git a/src/lib/components/ui/skeleton/index.ts b/src/lib/components/ui/skeleton/index.ts new file mode 100644 index 0000000..2be5c50 --- /dev/null +++ b/src/lib/components/ui/skeleton/index.ts @@ -0,0 +1,7 @@ +import Root from "./skeleton.svelte"; + +export { + Root, + // + Root as Skeleton +}; diff --git a/src/lib/components/ui/skeleton/skeleton.svelte b/src/lib/components/ui/skeleton/skeleton.svelte new file mode 100644 index 0000000..b21d230 --- /dev/null +++ b/src/lib/components/ui/skeleton/skeleton.svelte @@ -0,0 +1,17 @@ + + +
diff --git a/src/lib/components/ui/sonner/index.ts b/src/lib/components/ui/sonner/index.ts new file mode 100644 index 0000000..1ad9f4a --- /dev/null +++ b/src/lib/components/ui/sonner/index.ts @@ -0,0 +1 @@ +export { default as Toaster } from "./sonner.svelte"; diff --git a/src/lib/components/ui/sonner/sonner.svelte b/src/lib/components/ui/sonner/sonner.svelte new file mode 100644 index 0000000..1f50e1e --- /dev/null +++ b/src/lib/components/ui/sonner/sonner.svelte @@ -0,0 +1,13 @@ + + + diff --git a/src/lib/components/ui/typography/headline.svelte b/src/lib/components/ui/typography/headline.svelte index 71e59da..7ecf40c 100644 --- a/src/lib/components/ui/typography/headline.svelte +++ b/src/lib/components/ui/typography/headline.svelte @@ -4,11 +4,11 @@ import { tv, type VariantProps } from "tailwind-variants"; const variants = tv({ - base: "scroll-m-20 tracking-tight", + base: "scroll-m-20", variants: { style: { - h1: "text-4xl font-extrabold lg:text-5xl", - h2: "border-b pb-2 text-3xl font-semibold transition-colors first:mt-0", + h1: "text-4xl font-extrabold lg:text-5xl tracking-tight", + h2: "text-3xl font-semibold transition-colors first:mt-0", h3: "text-2xl font-semibold", h4: "text-xl font-semibold" } diff --git a/src/lib/components/ui/typography/text.svelte b/src/lib/components/ui/typography/text.svelte index c268b3b..9cd3365 100644 --- a/src/lib/components/ui/typography/text.svelte +++ b/src/lib/components/ui/typography/text.svelte @@ -7,27 +7,38 @@ variants: { style: { lg: "text-xl", - md: "leading-7 [&:not(:first-child)]:mt-6", + md: "leading-5", sm: "text-sm font-medium leading-none", xs: "text-xs" + }, + color: { + default: "", + darker: "text-darker", + dark: "text-dark", + medium: "text-medium", + light: "text-light", + lighter: "text-lighter" } }, defaultVariants: { - style: "xs" + style: "xs", + color: "default" } }); let { ref = $bindable(null), style, + color = "default", class: className, children, ...restProps }: WithElementRef> & { style: VariantProps["style"]; + color?: VariantProps["color"]; } = $props(); - + {@render children?.()} diff --git a/src/lib/const/routes.ts b/src/lib/const/routes.ts new file mode 100644 index 0000000..2bbbffe --- /dev/null +++ b/src/lib/const/routes.ts @@ -0,0 +1,13 @@ +export const ROUTES = { + SETUP: { + MAIN: "/setup", + CREATE_ADMIN_ACCOUNT: "/setup/create-admin-account", + CHECK_EMAIL: "/setup/check-email" + }, + RESEND_CONFIRMATION: "/confirm/resend", + LOGIN: "/login", + LOGOUT: "/logout", + DASHBOARD: { + MAIN: "/dashboard" + } +}; diff --git a/src/lib/server/auth/webauthn-service.ts b/src/lib/server/auth/webauthn-service.ts index a92963f..97aa2a8 100644 --- a/src/lib/server/auth/webauthn-service.ts +++ b/src/lib/server/auth/webauthn-service.ts @@ -1,7 +1,7 @@ -import { centralDb } from "$lib/server/db"; +import { centralDb as db } from "$lib/server/db"; import { userPasskey } from "$lib/server/db/central-schema"; import { eq } from "drizzle-orm"; -import { createHash } from "node:crypto"; +import { randomBytes, createHash } from "node:crypto"; import { UniversalLogger } from "$lib/logger"; const logger = new UniversalLogger().setContext("WebAuthnService"); @@ -41,18 +41,18 @@ export class WebAuthnService { }); // Get the passkey from database - const passkeys = await centralDb + const passkeyResults = await db .select() .from(userPasskey) .where(eq(userPasskey.id, credential.id)) .limit(1); - if (passkeys.length === 0) { + if (passkeyResults.length === 0) { logger.warn("Passkey not found", { credentialId: credential.id }); return { verified: false }; } - const passkey = passkeys[0]; + const passkey = passkeyResults[0]; // Parse client data JSON const clientDataJSON = JSON.parse( @@ -66,26 +66,67 @@ export class WebAuthnService { if (expectedChallenge !== receivedChallenge) { logger.warn("Challenge mismatch", { credentialId: credential.id, - expectedChallenge: expectedChallenge.substring(0, 8) + "...", - receivedChallenge: receivedChallenge.substring(0, 8) + "..." + expectedChallenge, + receivedChallenge }); return { verified: false }; } - // Verify the origin (in production, this should match your domain) - // For now, we'll skip this check as it depends on your deployment configuration - logger.debug("Origin verification skipped (implement for production)", { + // Verify the origin matches expected origin (critical for security) + const allowedOrigins = WebAuthnService.getAllowedOrigins(); + if (!allowedOrigins.includes(clientDataJSON.origin)) { + logger.warn("Origin verification failed", { + credentialId: credential.id, + receivedOrigin: clientDataJSON.origin, + allowedOrigins + }); + return { verified: false }; + } + + logger.debug("Origin verification successful", { origin: clientDataJSON.origin }); // Parse authenticator data const authenticatorDataBuffer = Buffer.from(credential.response.authenticatorData, "base64"); - // Extract counter from authenticator data (bytes 33-36) + // Parse authenticator data for detailed debugging + const parsedAuthData = WebAuthnService.parseAuthenticatorData(authenticatorDataBuffer); + + logger.debug("Authenticator data analysis", { + credentialId: credential.id, + bufferLength: authenticatorDataBuffer.length, + bufferHex: authenticatorDataBuffer.toString("hex"), + parsed: parsedAuthData + }); + + // Extract counter from authenticator data + // Format: rpIdHash(32) + flags(1) + counter(4) + attestedCredentialData(variable) + // Counter is at bytes 33-36 (0-indexed) + if (authenticatorDataBuffer.length < 37) { + logger.warn("Authenticator data too short for counter extraction", { + credentialId: credential.id, + bufferLength: authenticatorDataBuffer.length + }); + return { verified: false }; + } + const newCounter = authenticatorDataBuffer.readUInt32BE(33); - // Verify counter is greater than stored counter (prevents replay attacks) - if (newCounter <= passkey.counter) { + logger.debug("Counter extraction", { + credentialId: credential.id, + storedCounter: passkey.counter, + newCounter, + counterBytes: authenticatorDataBuffer.subarray(33, 37).toString("hex") + }); + + // Some authenticators (especially software-based ones) always return 0 + // In that case, we skip counter verification but log it + if (newCounter === 0 && passkey.counter === 0) { + logger.info("Authenticator uses zero counter - skipping counter verification", { + credentialId: credential.id + }); + } else if (newCounter <= passkey.counter) { logger.warn("Counter verification failed - possible replay attack", { credentialId: credential.id, storedCounter: passkey.counter, @@ -116,10 +157,29 @@ export class WebAuthnService { return { verified: false }; } + // Update the counter in the database to prevent replay attacks + // Only update if the authenticator provides a non-zero counter + if (newCounter > 0) { + await db + .update(userPasskey) + .set({ counter: newCounter }) + .where(eq(userPasskey.id, passkey.id)); + + logger.debug("Counter updated in database", { + credentialId: credential.id, + newCounter + }); + } else { + logger.debug("Counter update skipped (zero counter authenticator)", { + credentialId: credential.id + }); + } + logger.debug("WebAuthn authentication successful", { credentialId: credential.id, userId: passkey.userId, - newCounter + newCounter, + counterUpdated: true }); return { @@ -189,16 +249,14 @@ export class WebAuthnService { * This should be stored in the session and used for verification */ static generateChallenge(): string { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const crypto = require("node:crypto"); - return crypto.randomBytes(32).toString("base64url"); + return randomBytes(32).toString("base64url"); } /** * Update the counter for a passkey after successful authentication */ static async updatePasskeyCounter(passkeyId: string, newCounter: number): Promise { - await centralDb + await db .update(userPasskey) .set({ counter: newCounter, @@ -214,6 +272,127 @@ export class WebAuthnService { * Get all passkeys for a user */ static async getUserPasskeys(userId: string) { - return await centralDb.select().from(userPasskey).where(eq(userPasskey.userId, userId)); + return await db.select().from(userPasskey).where(eq(userPasskey.userId, userId)); + } + + /** + * Get allowed origins for WebAuthn verification + * Uses SERVER_DOMAIN in production, localhost variants in development + */ + private static getAllowedOrigins(): string[] { + const origins: string[] = []; + + // Check if we're in development (NODE_ENV or presence of dev indicators) + const isDevelopment = + process.env.NODE_ENV === "development" || + process.env.NODE_ENV === "dev" || + !process.env.SERVER_DOMAIN; + + if (isDevelopment) { + // Development origins + origins.push( + "http://localhost:5173", + "http://127.0.0.1:5173", + "http://localhost:4173", + "http://127.0.0.1:4173" + ); + } else { + // Production: Use SERVER_DOMAIN + const serverDomain = process.env.SERVER_DOMAIN; + if (serverDomain) { + // Add main domain with HTTPS + origins.push(`https://${serverDomain}`); + + // Add www variant if it doesn't already start with www + if (!serverDomain.startsWith("www.")) { + origins.push(`https://www.${serverDomain}`); + } + + // TODO: Webauthn does not support wildcard domains. We'll need another solution for that. + } + } + + // Allow manual override via WEBAUTHN_ALLOWED_ORIGINS + const envOrigins = process.env.WEBAUTHN_ALLOWED_ORIGINS; + if (envOrigins) { + origins.push(...envOrigins.split(",").map((o) => o.trim())); + } + + // Ensure we have at least one allowed origin + if (origins.length === 0) { + logger.error("No WebAuthn allowed origins configured - this is a security risk!", { + NODE_ENV: process.env.NODE_ENV, + SERVER_DOMAIN: process.env.SERVER_DOMAIN, + WEBAUTHN_ALLOWED_ORIGINS: process.env.WEBAUTHN_ALLOWED_ORIGINS + }); + // Fallback to localhost in development only + if (isDevelopment) { + origins.push("http://localhost:5173"); + } + } + + logger.debug("WebAuthn allowed origins configured", { + origins, + isDevelopment, + serverDomain: process.env.SERVER_DOMAIN + }); + + return origins; + } + + /** + * Parse authenticator data structure for debugging + */ + private static parseAuthenticatorData(authenticatorDataBuffer: Buffer) { + if (authenticatorDataBuffer.length < 37) { + return { error: "Buffer too short" }; + } + + const rpIdHash = authenticatorDataBuffer.subarray(0, 32); + const flags = authenticatorDataBuffer.readUInt8(32); + const counter = authenticatorDataBuffer.readUInt32BE(33); + + // Parse flags + const userPresent = !!(flags & 0x01); + const userVerified = !!(flags & 0x04); + const attestedCredentialDataIncluded = !!(flags & 0x40); + const extensionDataIncluded = !!(flags & 0x80); + + return { + rpIdHash: rpIdHash.toString("hex"), + flags: { + raw: flags, + userPresent, + userVerified, + attestedCredentialDataIncluded, + extensionDataIncluded + }, + counter, + totalLength: authenticatorDataBuffer.length + }; + } + + /** + * Extract counter from WebAuthn credential response + * Used during registration to get the initial counter value + */ + static extractCounterFromCredential(credential: { + response: { + authenticatorData: string; + }; + }): number { + try { + // Parse authenticator data + const authenticatorDataBuffer = Buffer.from(credential.response.authenticatorData, "base64"); + + // Extract counter from authenticator data (bytes 33-36) + const counter = authenticatorDataBuffer.readUInt32BE(33); + + logger.debug("Counter extracted from credential", { counter }); + return counter; + } catch (error) { + logger.error("Failed to extract counter from credential", { error: String(error) }); + return 0; // Fallback to 0 if extraction fails + } } } diff --git a/src/lib/server/email/__tests__/generate-base-url.test.ts b/src/lib/server/email/__tests__/generate-base-url.test.ts new file mode 100644 index 0000000..d76f5ad --- /dev/null +++ b/src/lib/server/email/__tests__/generate-base-url.test.ts @@ -0,0 +1,327 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { generateBaseUrl } from "../email-service"; +import type { SelectTenant } from "$lib/server/db/central-schema"; + +// Mock NODE_ENV +const mockEnv = vi.hoisted(() => ({ NODE_ENV: "development" })); + +vi.mock("$env/dynamic/private", () => ({ + env: mockEnv +})); + +describe("generateBaseUrl", () => { + beforeEach(() => { + // Reset NODE_ENV to development for each test + mockEnv.NODE_ENV = "development"; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("Development/Local Environment", () => { + it("should return localhost URL regardless of tenant", () => { + const requestUrl = new URL("http://localhost:5173"); + const tenant: SelectTenant = { + id: "tenant-1", + shortName: "acme", + longName: "ACME Corp", + logo: null, + createdAt: new Date(), + updatedAt: new Date(), + description: "", + databaseUrl: "", + setupState: "NEW" + }; + + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("http://localhost:5173"); + }); + + it("should return localhost URL for null tenant", () => { + const requestUrl = new URL("http://localhost:3000"); + + const result = generateBaseUrl(requestUrl, null); + expect(result).toBe("http://localhost:3000"); + }); + + it("should preserve port for localhost", () => { + const requestUrl = new URL("http://localhost:8080"); + const tenant: SelectTenant = { + id: "tenant-1", + shortName: "test", + longName: "Test Corp", + description: "", + databaseUrl: "", + setupState: "NEW", + logo: null, + createdAt: new Date(), + updatedAt: new Date() + }; + + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("http://localhost:8080"); + }); + + it("should handle 127.x.x.x addresses", () => { + const requestUrl = new URL("http://127.0.0.1:3000"); + const tenant: SelectTenant = { + id: "tenant-1", + shortName: "acme", + longName: "ACME Corp", + description: "", + databaseUrl: "", + setupState: "NEW", + logo: null, + createdAt: new Date(), + updatedAt: new Date() + }; + + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("http://127.0.0.1:3000"); + }); + + it("should handle 192.168.x.x addresses", () => { + const requestUrl = new URL("http://192.168.1.100:8080"); + const tenant: SelectTenant = { + id: "tenant-1", + shortName: "acme", + longName: "ACME Corp", + description: "", + databaseUrl: "", + setupState: "NEW", + logo: null, + createdAt: new Date(), + updatedAt: new Date() + }; + + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("http://192.168.1.100:8080"); + }); + }); + + describe("Production Environment", () => { + beforeEach(() => { + mockEnv.NODE_ENV = "production"; + }); + + it("should return main domain for null tenant", () => { + const requestUrl = new URL("https://example.com"); + + const result = generateBaseUrl(requestUrl, null); + expect(result).toBe("https://example.com"); + }); + + it("should return main domain with port for null tenant", () => { + const requestUrl = new URL("https://example.com:8443"); + + const result = generateBaseUrl(requestUrl, null); + expect(result).toBe("https://example.com:8443"); + }); + + it("should create subdomain URL for tenant on main domain", () => { + const requestUrl = new URL("https://example.com"); + const tenant: SelectTenant = { + id: "tenant-1", + shortName: "acme", + longName: "ACME Corp", + description: "", + databaseUrl: "", + setupState: "NEW", + logo: null, + createdAt: new Date(), + updatedAt: new Date() + }; + + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("https://acme.example.com"); + }); + + it("should create subdomain URL with port", () => { + const requestUrl = new URL("https://example.com:8443"); + const tenant: SelectTenant = { + id: "tenant-1", + shortName: "acme", + longName: "ACME Corp", + description: "", + databaseUrl: "", + setupState: "NEW", + logo: null, + createdAt: new Date(), + updatedAt: new Date() + }; + + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("https://acme.example.com:8443"); + }); + + it("should replace existing subdomain with tenant shortName", () => { + const requestUrl = new URL("https://old-tenant.example.com"); + const tenant: SelectTenant = { + id: "tenant-2", + shortName: "new-tenant", + longName: "New Tenant Corp", + description: "", + databaseUrl: "", + setupState: "NEW", + logo: null, + createdAt: new Date(), + updatedAt: new Date() + }; + + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("https://new-tenant.example.com"); + }); + + it("should replace existing subdomain with port", () => { + const requestUrl = new URL("https://old-tenant.example.com:8443"); + const tenant: SelectTenant = { + id: "tenant-2", + shortName: "new-tenant", + longName: "New Tenant Corp", + description: "", + databaseUrl: "", + setupState: "NEW", + logo: null, + createdAt: new Date(), + updatedAt: new Date() + }; + + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("https://new-tenant.example.com:8443"); + }); + + it("should handle complex subdomains (keep last two parts)", () => { + const requestUrl = new URL("https://admin.api.example.com"); + const tenant: SelectTenant = { + id: "tenant-1", + shortName: "tenant", + longName: "Tenant Corp", + description: "", + databaseUrl: "", + setupState: "NEW", + logo: null, + createdAt: new Date(), + updatedAt: new Date() + }; + + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("https://tenant.example.com"); + }); + + it("should handle http protocol", () => { + const requestUrl = new URL("http://example.com"); + const tenant: SelectTenant = { + id: "tenant-1", + shortName: "acme", + longName: "ACME Corp", + description: "", + databaseUrl: "", + setupState: "NEW", + logo: null, + createdAt: new Date(), + updatedAt: new Date() + }; + + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("http://acme.example.com"); + }); + + it("should return main domain when tenant has no shortName", () => { + const requestUrl = new URL("https://example.com"); + const tenant: SelectTenant = { + id: "tenant-1", + shortName: "", // Empty shortName + longName: "ACME Corp", + description: "", + databaseUrl: "", + setupState: "NEW", + logo: null, + createdAt: new Date(), + updatedAt: new Date() + }; + + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("https://example.com"); + }); + }); + + describe("Edge Cases", () => { + it("should handle single domain names in production", () => { + mockEnv.NODE_ENV = "production"; + const requestUrl = new URL("https://app"); + const tenant: SelectTenant = { + id: "tenant-1", + shortName: "acme", + longName: "ACME Corp", + description: "", + databaseUrl: "", + setupState: "NEW", + logo: null, + createdAt: new Date(), + updatedAt: new Date() + }; + + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("https://acme.app"); + }); + + it("should handle localhost in production (still treated as development)", () => { + mockEnv.NODE_ENV = "production"; + const requestUrl = new URL("https://localhost:8443"); + const tenant: SelectTenant = { + id: "tenant-1", + shortName: "acme", + longName: "ACME Corp", + description: "", + databaseUrl: "", + setupState: "NEW", + logo: null, + createdAt: new Date(), + updatedAt: new Date() + }; + + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("https://localhost:8443"); + }); + + it("should handle IP addresses as development", () => { + mockEnv.NODE_ENV = "production"; + const requestUrl = new URL("https://192.168.1.100:8443"); + const tenant: SelectTenant = { + id: "tenant-1", + shortName: "acme", + longName: "ACME Corp", + description: "", + databaseUrl: "", + setupState: "NEW", + logo: null, + createdAt: new Date(), + updatedAt: new Date() + }; + + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("https://192.168.1.100:8443"); + }); + + it("should handle default HTTP port (80)", () => { + mockEnv.NODE_ENV = "production"; + const requestUrl = new URL("http://example.com:80"); + + // URL constructor should handle port 80 correctly + const result = generateBaseUrl(requestUrl, null); + // Default HTTP port shouldn't be included in URL + expect(result).toBe("http://example.com"); + }); + + it("should handle default HTTPS port (443)", () => { + mockEnv.NODE_ENV = "production"; + const requestUrl = new URL("https://example.com:443"); + + // URL constructor should handle port 443 correctly + const result = generateBaseUrl(requestUrl, null); + // Default HTTPS port shouldn't be included in URL + expect(result).toBe("https://example.com"); + }); + }); +}); diff --git a/src/lib/server/email/email-service.ts b/src/lib/server/email/email-service.ts index 949cab4..643492f 100644 --- a/src/lib/server/email/email-service.ts +++ b/src/lib/server/email/email-service.ts @@ -187,12 +187,47 @@ export async function sendAppointmentUpdatedEmail( }); } +/** + * Generate base URL for email templates based on request URL and tenant + * @param {URL} requestUrl - The request URL + * @param {SelectTenant | null} tenant - Tenant information (null for global admin) + * @returns {string} The appropriate base URL + */ +export function generateBaseUrl(requestUrl: URL, tenant: SelectTenant | null): string { + const protocol = requestUrl.protocol; + const port = requestUrl.port ? `:${requestUrl.port}` : ""; + const hostname = requestUrl.hostname; + + // In development, always use the original hostname regardless of tenant + if (hostname === "localhost" || hostname.startsWith("127.") || hostname.startsWith("192.168.")) { + return `${protocol}//${hostname}${port}`; + } + + // In production, handle tenant subdomains + if (tenant?.shortName) { + const parts = hostname.split("."); + + if (parts.length > 2) { + // Complex subdomain - use only the last two parts (domain.tld) and add tenant + const domain = parts.slice(-2).join("."); + return `${protocol}//${tenant.shortName}.${domain}${port}`; + } else { + // Main domain, prepend tenant subdomain + return `${protocol}//${tenant.shortName}.${hostname}${port}`; + } + } + + // For global admin or no tenant, use main domain + return `${protocol}//${hostname}${port}`; +} + /** * Send registration confirmation email with one-time code * @param {SelectClient | SelectStaff} user - Database user object * @param {SelectTenant} tenant - Tenant information for branding * @param {string} confirmationCode - One-time confirmation code * @param {number} [expirationMinutes=15] - Code expiration time in minutes + * @param {URL} [requestUrl] - Request URL for generating baseUrl * @throws {Error} When email sending fails * @returns {Promise} */ @@ -200,13 +235,23 @@ export async function sendConfirmationEmail( user: { id: string; email: string | null; name: string | null; language?: string | null }, tenant: SelectTenant, confirmationCode: string, - expirationMinutes: number = 15 + expirationMinutes: number = 15, + requestUrl?: URL ): Promise { const recipient = createEmailRecipient(user); const language = (recipient.language as Language) || "en"; const subject = language === "en" ? "Confirm Your Registration" : "Registrierung bestätigen"; - await sendTemplatedEmail("confirmation", recipient, subject, language, tenant, { + // Generate appropriate base URL if request URL is provided + const baseUrl = requestUrl ? generateBaseUrl(requestUrl, tenant) : "http://localhost:5173"; + + // Create enhanced tenant object with baseUrl + const tenantWithBaseUrl = { + ...tenant, + baseUrl + }; + + await sendTemplatedEmail("confirmation", recipient, subject, language, tenantWithBaseUrl, { confirmationCode, expirationMinutes }); diff --git a/src/lib/server/email/templates/confirmation.de.html b/src/lib/server/email/templates/confirmation.de.html index 9a9d6c6..7a15d11 100644 --- a/src/lib/server/email/templates/confirmation.de.html +++ b/src/lib/server/email/templates/confirmation.de.html @@ -6,72 +6,115 @@ Registrierung bestätigen +
@@ -100,6 +143,7 @@
Geben Sie diesen Code in das Bestätigungsfeld ein
+ Oder hier klicken.
diff --git a/src/lib/server/email/templates/confirmation.de.txt b/src/lib/server/email/templates/confirmation.de.txt index 084f14c..b2da80d 100644 --- a/src/lib/server/email/templates/confirmation.de.txt +++ b/src/lib/server/email/templates/confirmation.de.txt @@ -10,6 +10,8 @@ vielen Dank für Ihre Registrierung bei {{tenant.longName}}. Um Ihre Registrieru Geben Sie diesen Code in das Bestätigungsfeld ein, um Ihre Registrierung zu vervollständigen. +Alternativ können Sie direkt diese Seite besuchen: {{tenant.baseUrl}}/confirm/{{confirmationCode}} + WICHTIGER HINWEIS: Dieser Code ist nur für {{expirationMinutes}} Minuten gültig und kann nur einmal verwendet werden. Falls Sie sich nicht bei {{tenant.longName}} registriert haben, können Sie diese E-Mail ignorieren. diff --git a/src/lib/server/email/templates/confirmation.en.html b/src/lib/server/email/templates/confirmation.en.html index cadd7ce..10cd866 100644 --- a/src/lib/server/email/templates/confirmation.en.html +++ b/src/lib/server/email/templates/confirmation.en.html @@ -100,6 +100,7 @@
Enter this code in the confirmation field
+ Or simply click here.
diff --git a/src/lib/server/email/templates/confirmation.en.txt b/src/lib/server/email/templates/confirmation.en.txt index 1b575e9..ae7c750 100644 --- a/src/lib/server/email/templates/confirmation.en.txt +++ b/src/lib/server/email/templates/confirmation.en.txt @@ -10,6 +10,8 @@ Thank you for registering with {{tenant.longName}}. To complete your registratio Enter this code in the confirmation field to complete your registration. +Or simply visit this site: {{tenant.baseUrl}}/confirm/{{confirmationCode}} + IMPORTANT NOTICE: This code is valid for {{expirationMinutes}} minutes only and can be used only once. If you did not register with {{tenant.longName}}, you can safely ignore this email. diff --git a/src/lib/server/services/__tests__/user-service.test.ts b/src/lib/server/services/__tests__/user-service.test.ts index 33a4078..b1c4196 100644 --- a/src/lib/server/services/__tests__/user-service.test.ts +++ b/src/lib/server/services/__tests__/user-service.test.ts @@ -176,18 +176,25 @@ describe("UserService", () => { limit: vi.fn().mockResolvedValue([{ id: "user-123", recoveryPassphrase: "recovery-123" }]) }; + const mockCountSelectBuilder = { + from: vi.fn().mockResolvedValue([{ count: 1 }]) + }; + const mockUpdateBuilder = { set: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(), execute: vi.fn().mockResolvedValue({ count: 1 }) }; - mockCentralDb.select.mockReturnValue(mockSelectBuilder); + // First call for user lookup, second call for count query + mockCentralDb.select + .mockReturnValueOnce(mockSelectBuilder) + .mockReturnValueOnce(mockCountSelectBuilder); mockCentralDb.update.mockReturnValue(mockUpdateBuilder); const result = await UserService.confirm(token); - expect(mockCentralDb.select).toHaveBeenCalled(); + expect(mockCentralDb.select).toHaveBeenCalledTimes(2); expect(mockCentralDb.update).toHaveBeenCalled(); expect(mockUpdateBuilder.set).toHaveBeenCalledWith({ confirmed: true, @@ -195,6 +202,7 @@ describe("UserService", () => { recoveryPassphrase: null }); expect(result.recoveryPassphrase).toBe("recovery-123"); + expect(result.isSetup).toBe(true); }); it("should throw NotFoundError for invalid token", async () => { diff --git a/src/lib/server/services/user-service.ts b/src/lib/server/services/user-service.ts index efb9939..3458c85 100644 --- a/src/lib/server/services/user-service.ts +++ b/src/lib/server/services/user-service.ts @@ -1,6 +1,6 @@ import { centralDb } from "../db"; import * as centralSchema from "../db/central-schema"; -import { eq, desc, gt, and } from "drizzle-orm"; +import { eq, desc, gt, and, count } from "drizzle-orm"; import type { InferInsertModel } from "drizzle-orm"; import z from "zod/v4"; import { NotFoundError, ValidationError } from "../utils/errors"; @@ -71,7 +71,7 @@ export class UserService { /** * Create a new user */ - static async createUser(userData: UserCreation) { + static async createUser(userData: UserCreation, requestUrl?: URL) { const log = logger.setContext("UserService"); log.debug("Creating new user account", { email: userData.email, @@ -138,7 +138,8 @@ export class UserService { result[0], tenant, result[0].token, - 10 // 10 minutes expiration to match tokenValidUntil + 10, // 10 minutes expiration to match tokenValidUntil + requestUrl ); log.debug("Confirmation email sent successfully", { userId: result[0].id, @@ -165,8 +166,9 @@ export class UserService { /** * Resend the confirmation email for a user * @param email - Email of the user to confirm + * @param requestUrl - Optional request URL for generating correct baseUrl */ - static async resendConfirmationEmail(email: string): Promise { + static async resendConfirmationEmail(email: string, requestUrl?: URL): Promise { const log = logger.setContext("UserService"); log.debug("Resending confirmation email", { email }); @@ -195,7 +197,8 @@ export class UserService { user, tenant, token, - 10 // 10 minutes expiration to match tokenValidUntil + 10, // 10 minutes expiration to match tokenValidUntil + requestUrl ); log.debug("Confirmation email sent successfully", { userId: user.id, @@ -221,7 +224,9 @@ export class UserService { * Confirm and activate user after confirmation link was clicked * @param linkToken - The token from the link */ - static async confirm(linkToken: string): Promise<{ recoveryPassphrase?: string }> { + static async confirm( + linkToken: string + ): Promise<{ recoveryPassphrase?: string; isSetup: boolean }> { const log = logger.setContext("UserService"); log.debug("Confirming user account", { token: linkToken.substring(0, 8) + "..." }); @@ -265,6 +270,8 @@ export class UserService { throw new NotFoundError("Failed to confirm user"); } + const countResult = await centralDb.select({ count: count() }).from(centralSchema.user); + log.debug("User account confirmed successfully", { userId: user.id, token: linkToken.substring(0, 8) + "...", @@ -272,7 +279,8 @@ export class UserService { }); return { - recoveryPassphrase: user.recoveryPassphrase || undefined + recoveryPassphrase: user.recoveryPassphrase || undefined, + isSetup: countResult[0].count === 1 }; } catch (error) { if (error instanceof NotFoundError) throw error; diff --git a/src/lib/utils/passkey.ts b/src/lib/utils/passkey.ts new file mode 100644 index 0000000..f40258b --- /dev/null +++ b/src/lib/utils/passkey.ts @@ -0,0 +1,109 @@ +export const arrayBufferToBase64 = (buffer: ArrayBuffer): string => { + const bytes = new Uint8Array(buffer); + let binary = ""; + + // Simple loop, no fancy operations + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + + return window.btoa(binary); +}; + +export function base64ToArrayBuffer(base64: string) { + const binaryString = atob(base64); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes.buffer; +} + +export const fetchChallenge = async (email: string) => { + const resp = await fetch("/api/auth/challenge", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ email }) + }); + + try { + const data = await resp.json(); + return { + id: data.rpId, + challenge: data.challenge + }; + } catch { + return null; + } +}; + +export type GeneratePasskeyResponse = { + response: AuthenticatorAttestationResponse; + id: string; + getClientExtensionResults: () => { deviceName?: string }; +} | null; +export const generatePasskey = async ({ + id, + challenge, + email +}: { + id: string; + challenge: string; + email: string; +}): Promise => { + const publicKey: PublicKeyCredentialCreationOptions = { + challenge: base64ToArrayBuffer(challenge), + rp: { + id, + name: "Open Reception" + }, + user: { + id: new Uint8Array(16), + name: email, + displayName: email + }, + pubKeyCredParams: [ + { alg: -7, type: "public-key" } // ES256 + ] + }; + return (await navigator.credentials.create({ publicKey })) as GeneratePasskeyResponse; +}; + +export type GetCredentialResponse = PublicKeyCredential & { + response: PublicKeyCredential; + id: string; +}; +export const getCredential = async ({ + id, + challenge, + email +}: { + id: string; + challenge: string; + email: string; +}) => { + const publicKey: PublicKeyCredentialCreationOptions = { + challenge: base64ToArrayBuffer(challenge), + rp: { + id, + name: "Open Reception" + }, + user: { + id: new Uint8Array(16), + name: email, + displayName: email + }, + pubKeyCredParams: [ + { alg: -7, type: "public-key" } // ES256 + ] + }; + return (await navigator.credentials.get({ publicKey })) as GetCredentialResponse; +}; + +export const getCounterFromAuthenticatorData = (authenticatorData: ArrayBuffer) => { + const view = new DataView(authenticatorData); + // Counter is at offset 33, 4 bytes, big-endian + return view.getUint32(33, false); // false = big-endian +}; diff --git a/src/routes/(pages)/+layout.svelte b/src/routes/(pages)/+layout.svelte index 4b048da..a7f5835 100644 --- a/src/routes/(pages)/+layout.svelte +++ b/src/routes/(pages)/+layout.svelte @@ -1,7 +1,8 @@ + {@render children()} diff --git a/src/routes/(pages)/+page.server.ts b/src/routes/(pages)/+page.server.ts index e8d4d7c..347fa94 100644 --- a/src/routes/(pages)/+page.server.ts +++ b/src/routes/(pages)/+page.server.ts @@ -1,4 +1,16 @@ +import { ROUTES } from "$lib/const/routes.js"; +import { UserService } from "$lib/server/services/user-service"; +import { redirect } from "@sveltejs/kit"; + export const load = async (event) => { + // Check if global admin exists + // If not, redirect to setup page + const adminExists = await UserService.adminExists(); + if (!adminExists) { + redirect(302, ROUTES.SETUP.MAIN); + } + + // Dummy placeholder for streaming data const fetchEnvOk = async () => { const response = await event.fetch("/api/env"); try { diff --git a/src/routes/(pages)/+page.svelte b/src/routes/(pages)/+page.svelte index 5cb8e51..e16f841 100644 --- a/src/routes/(pages)/+page.svelte +++ b/src/routes/(pages)/+page.svelte @@ -1,6 +1,4 @@ + + + {m["confirm.title"]()} - OpenReception + + + + + + {#await data.streaming.confirmation} + + {:then confirmation} + {#if confirmation.success} + {#if confirmation.isSetup} + + {:else} + + {/if} + {:else} + + {/if} + {/await} + + + {#await data.streaming.confirmation} + + + + {:then confirmation} + {#if confirmation.success} + {#if confirmation.isSetup} + + {m["setup.confirm.success.hint"]()} + + + {:else} + + {/if} + {:else} + + {/if} + {/await} + + + diff --git a/src/routes/(pages)/confirm/resend/+page.server.ts b/src/routes/(pages)/confirm/resend/+page.server.ts new file mode 100644 index 0000000..c215441 --- /dev/null +++ b/src/routes/(pages)/confirm/resend/+page.server.ts @@ -0,0 +1,33 @@ +import { fail } from "@sveltejs/kit"; +import { superValidate } from "sveltekit-superforms"; +import { zod } from "sveltekit-superforms/adapters"; +import type { Actions, PageServerLoad } from "./$types"; +import { formSchema } from "./schema"; + +export const load: PageServerLoad = async () => { + return { + form: await superValidate(zod(formSchema)) + }; +}; + +export const actions: Actions = { + default: async (event) => { + const form = await superValidate(event, zod(formSchema)); + + if (!form.valid) { + return fail(400, { + form + }); + } + + await event.fetch("/api/auth/resend-confirmation", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ email: form.data.email }) + }); + + return { form }; + } +}; diff --git a/src/routes/(pages)/confirm/resend/+page.svelte b/src/routes/(pages)/confirm/resend/+page.svelte new file mode 100644 index 0000000..a65b080 --- /dev/null +++ b/src/routes/(pages)/confirm/resend/+page.svelte @@ -0,0 +1,53 @@ + + + + {m["confirm.resend.title"]()} - OpenReception + + + + + + + {m["confirm.resend.title"]()} + + + {m["confirm.resend.description"]()} + + + + + + + + {m["confirm.resend.action"]()} + + + + diff --git a/src/routes/(pages)/confirm/resend/resend-confirmation-form.svelte b/src/routes/(pages)/confirm/resend/resend-confirmation-form.svelte new file mode 100644 index 0000000..d506b48 --- /dev/null +++ b/src/routes/(pages)/confirm/resend/resend-confirmation-form.svelte @@ -0,0 +1,43 @@ + + + + + + {#snippet children({ props })} + {m["form.email"]()} + + {/snippet} + + + + diff --git a/src/routes/(pages)/confirm/resend/schema.ts b/src/routes/(pages)/confirm/resend/schema.ts new file mode 100644 index 0000000..9c07ee8 --- /dev/null +++ b/src/routes/(pages)/confirm/resend/schema.ts @@ -0,0 +1,8 @@ +import { m } from "$i18n/messages"; +import { z } from "zod"; + +export const formSchema = z.object({ + email: z.string().email(m["form.errors.email"]()) +}); + +export type FormSchema = typeof formSchema; diff --git a/src/routes/(pages)/dashboard/+page.svelte b/src/routes/(pages)/dashboard/+page.svelte new file mode 100644 index 0000000..f29a407 --- /dev/null +++ b/src/routes/(pages)/dashboard/+page.svelte @@ -0,0 +1,2 @@ +Hello from Dashboard
+Logout diff --git a/src/routes/(pages)/login/+page.server.ts b/src/routes/(pages)/login/+page.server.ts new file mode 100644 index 0000000..b2699d0 --- /dev/null +++ b/src/routes/(pages)/login/+page.server.ts @@ -0,0 +1,63 @@ +import { fail } from "@sveltejs/kit"; +import { superValidate } from "sveltekit-superforms"; +import { zod } from "sveltekit-superforms/adapters"; +import type { Actions, PageServerLoad } from "./$types"; +import { formSchema } from "./schema"; +import type { WebAuthnCredential } from "$lib/server/auth/webauthn-service"; + +export const load: PageServerLoad = async () => { + return { + form: await superValidate(zod(formSchema)) + }; +}; + +export const actions: Actions = { + default: async (event) => { + const form = await superValidate(event, zod(formSchema)); + + if (!form.valid) { + if (!form.valid) { + return fail(400, { + form: { ...form, data: { ...form.data, type: "passkey" } } + }); + } + } + + let body: { credential?: WebAuthnCredential; email: string; passphrase?: string } = { + email: form.data.email + }; + if (form.data.type === "passphrase") { + body = { ...body, passphrase: form.data.passphrase }; + } + + if (form.data.type === "passkey") { + body = { + ...body, + credential: { + id: form.data.id, + response: { + clientDataJSON: form.data.clientDataBase64, + authenticatorData: form.data.authenticatorDataBase64, + signature: form.data.signatureBase64 + } + } + }; + } + + const resp = await event.fetch("/api/auth/login", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(body) + }); + + if (resp.status < 400) { + return { form }; + } else { + return fail(400, { + form + }); + } + } +}; diff --git a/src/routes/(pages)/login/+page.svelte b/src/routes/(pages)/login/+page.svelte new file mode 100644 index 0000000..24de273 --- /dev/null +++ b/src/routes/(pages)/login/+page.svelte @@ -0,0 +1,53 @@ + + + + {m["login.title"]()} - OpenReception + + + + + + + {m["login.title"]()} + + + {m["login.description_passphrase"]()} + + + + + + + + {m["login.action"]()} + + + + diff --git a/src/routes/(pages)/login/login-form.svelte b/src/routes/(pages)/login/login-form.svelte new file mode 100644 index 0000000..d3a5183 --- /dev/null +++ b/src/routes/(pages)/login/login-form.svelte @@ -0,0 +1,228 @@ + + + + + + + {#snippet children({ props })} + {m["form.email"]()} + + {/snippet} + + + + {#if $formData.type === "passphrase"} + + + {#snippet children({ props })} + {m["form.passphrase"]()} + + + {/snippet} + + + + {m["login.or"]()} + + + + {/if} + {#if $formData.type === "passkey"} +
+ + + + + + + + {m["login.or"]()} + . + +
+ {/if} +
diff --git a/src/routes/(pages)/login/schema.ts b/src/routes/(pages)/login/schema.ts new file mode 100644 index 0000000..0d4ded3 --- /dev/null +++ b/src/routes/(pages)/login/schema.ts @@ -0,0 +1,22 @@ +import { m } from "$i18n/messages"; +import { z } from "zod"; + +export const baseSchema = z.object({ + email: z.string().email(m["form.errors.email"]()) +}); + +const passkeySchema = baseSchema.extend({ + type: z.literal("passkey"), + id: z.string().min(3), + authenticatorDataBase64: z.string().base64(), + clientDataBase64: z.string().base64(), + signatureBase64: z.string().base64() +}); +const passphraseSchema = baseSchema.extend({ + type: z.literal("passphrase"), + passphrase: z.string().min(30, m["form.errors.passphrase"]()) +}); + +export const formSchema = z.discriminatedUnion("type", [passkeySchema, passphraseSchema]); + +export type FormSchema = typeof formSchema; diff --git a/src/routes/(pages)/logout/+page.server.ts b/src/routes/(pages)/logout/+page.server.ts new file mode 100644 index 0000000..9a447df --- /dev/null +++ b/src/routes/(pages)/logout/+page.server.ts @@ -0,0 +1,17 @@ +import type { PageServerLoad } from "./$types"; + +export const load: PageServerLoad = async (event) => { + const success: Promise = event + .fetch("/api/auth/logout", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + credentials: "same-origin" + }) + .then(async (resp) => { + return resp.status < 400; + }); + + return { streaming: { success } }; +}; diff --git a/src/routes/(pages)/logout/+page.svelte b/src/routes/(pages)/logout/+page.svelte new file mode 100644 index 0000000..27a0cdc --- /dev/null +++ b/src/routes/(pages)/logout/+page.svelte @@ -0,0 +1,41 @@ + + + + {m["logout.title"]()} - OpenReception + + + + + + {#await data.streaming.success} + + {:then} + + {/await} + + + {#await data.streaming.success} + + {:then} + + {/await} + + + diff --git a/src/routes/(pages)/setup/+layout.server.ts b/src/routes/(pages)/setup/+layout.server.ts new file mode 100644 index 0000000..bdeb9ff --- /dev/null +++ b/src/routes/(pages)/setup/+layout.server.ts @@ -0,0 +1,15 @@ +import { ROUTES } from "$lib/const/routes.js"; +import { UserService } from "$lib/server/services/user-service"; +import { redirect } from "@sveltejs/kit"; + +export const load = async (event) => { + // Some routes should not be accessed, once an admin is created + const cleanedId = event.route.id.replace("/(pages)", ""); + const blocklist = [ROUTES.SETUP.MAIN, ROUTES.SETUP.CREATE_ADMIN_ACCOUNT]; + + // Check if global admin exists + const adminExists = await UserService.adminExists(); + if (blocklist.includes(cleanedId) && adminExists) { + redirect(302, ROUTES.LOGIN); + } +}; diff --git a/src/routes/(pages)/setup/+page.svelte b/src/routes/(pages)/setup/+page.svelte new file mode 100644 index 0000000..24fe485 --- /dev/null +++ b/src/routes/(pages)/setup/+page.svelte @@ -0,0 +1,27 @@ + + + + {m.welcome()} - OpenReception + + + + + + {m.logo()} + {m.welcome()} + {m.slogan()} + + + + + + diff --git a/src/routes/(pages)/setup/check-email/+page.server.ts b/src/routes/(pages)/setup/check-email/+page.server.ts new file mode 100644 index 0000000..4f5f243 --- /dev/null +++ b/src/routes/(pages)/setup/check-email/+page.server.ts @@ -0,0 +1,37 @@ +import { superValidate } from "sveltekit-superforms"; +import { zod } from "sveltekit-superforms/adapters"; +import type { Actions, PageServerLoad } from "./$types"; +import { formSchema } from "./schema"; +import { UserService } from "$lib/server/services/user-service"; +import { fail } from "@sveltejs/kit"; +import logger from "$lib/logger"; + +const log = logger.setContext("Setup"); + +export const load: PageServerLoad = async () => { + return { + form: await superValidate(zod(formSchema)) + }; +}; + +export const actions: Actions = { + default: async (event) => { + const form = await superValidate(event, zod(formSchema)); + + if (!form.valid) { + return fail(400, { + form + }); + } + + await UserService.resendConfirmationEmail(form.data.email, event.url); + + log.debug("Resent confirmation e-mail", { + email: form.data.email + }); + + return { + form + }; + } +}; diff --git a/src/routes/(pages)/setup/check-email/+page.svelte b/src/routes/(pages)/setup/check-email/+page.svelte new file mode 100644 index 0000000..5339a5f --- /dev/null +++ b/src/routes/(pages)/setup/check-email/+page.svelte @@ -0,0 +1,91 @@ + + + + {m["setup.verify_email.title"]()} - OpenReception + + + + + + + + + + + + {m["setup.verify_email.action"]()} + + + + + diff --git a/src/routes/(pages)/setup/check-email/schema.ts b/src/routes/(pages)/setup/check-email/schema.ts new file mode 100644 index 0000000..9c07ee8 --- /dev/null +++ b/src/routes/(pages)/setup/check-email/schema.ts @@ -0,0 +1,8 @@ +import { m } from "$i18n/messages"; +import { z } from "zod"; + +export const formSchema = z.object({ + email: z.string().email(m["form.errors.email"]()) +}); + +export type FormSchema = typeof formSchema; diff --git a/src/routes/(pages)/setup/create-admin-account/+page.server.ts b/src/routes/(pages)/setup/create-admin-account/+page.server.ts new file mode 100644 index 0000000..82a6def --- /dev/null +++ b/src/routes/(pages)/setup/create-admin-account/+page.server.ts @@ -0,0 +1,63 @@ +import logger from "$lib/logger"; +import { UserService } from "$lib/server/services/user-service"; +import { fail } from "@sveltejs/kit"; +import { superValidate } from "sveltekit-superforms"; +import { zod } from "sveltekit-superforms/adapters"; +import type { Actions, PageServerLoad } from "./$types"; +import { formSchema } from "./schema"; +import { base64ToArrayBuffer, getCounterFromAuthenticatorData } from "$lib/utils/passkey"; + +const log = logger.setContext("Setup"); + +export const load: PageServerLoad = async () => { + return { + form: await superValidate(zod(formSchema)) + }; +}; + +export const actions: Actions = { + default: async (event) => { + const form = await superValidate(event, zod(formSchema)); + if (!form.valid) { + return fail(400, { + form: { ...form, data: { ...form.data, type: "passkey" } } + }); + } + + // Create admin account + const admin = await UserService.createUser( + { + name: "Admin", + email: form.data.email, + passphrase: + form.data.type === "passphrase" && form.data.passphrase + ? form.data.passphrase + : undefined, + language: form.data.language + }, + event.url + ); + + if (form.data.type === "passkey") { + const publicKey = form.data.publicKeyBase64; + const authenticatorData = base64ToArrayBuffer(form.data.authenticatorDataBase64); + const counter = getCounterFromAuthenticatorData(authenticatorData); + + await UserService.addPasskey(admin.id, { + id: form.data.id, + publicKey: publicKey, + counter, + deviceName: "Unknown Device" + }); + } + + log.debug("Admin account created successfully", { + adminId: admin.id, + email: admin.email, + authMethod: form.data.type === "passkey" ? "passkey" : "passphrase", + passkeyId: form.data.type === "passkey" ? form.data.id : undefined + }); + + return { form }; + } +}; diff --git a/src/routes/(pages)/setup/create-admin-account/+page.svelte b/src/routes/(pages)/setup/create-admin-account/+page.svelte new file mode 100644 index 0000000..c7a3574 --- /dev/null +++ b/src/routes/(pages)/setup/create-admin-account/+page.svelte @@ -0,0 +1,53 @@ + + + + {m["setup.create_admin_account.title"]()} - OpenReception + + + + + + + {m["setup.create_admin_account.title"]()} + + + {m["setup.create_admin_account.description"]()} + + + + + + + + {m["setup.create_admin_account.action"]()} + + + + diff --git a/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte b/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte new file mode 100644 index 0000000..431b79c --- /dev/null +++ b/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte @@ -0,0 +1,222 @@ + + + + + + + + {#snippet children({ props })} + {m["form.email"]()} + + {/snippet} + + + + {#if $formData.type === "passphrase"} + + + {#snippet children({ props })} + {m["form.passphrase"]()} + + + {/snippet} + + + + {m["form.passphraseRequirements"]()} + {m["login.or"]()} + . + + + {/if} + {#if $formData.type === "passkey"} +
+ + + + + + + {m["login.or"]()} + . + +
+ {/if} +
diff --git a/src/routes/(pages)/setup/create-admin-account/schema.ts b/src/routes/(pages)/setup/create-admin-account/schema.ts new file mode 100644 index 0000000..efc0bef --- /dev/null +++ b/src/routes/(pages)/setup/create-admin-account/schema.ts @@ -0,0 +1,22 @@ +import { m } from "$i18n/messages"; +import { z } from "zod"; + +export const baseSchema = z.object({ + email: z.string().email(m["form.errors.email"]()), + language: z.enum(["de", "en"]) +}); + +const passkeySchema = baseSchema.extend({ + type: z.literal("passkey"), + id: z.string().min(3), + publicKeyBase64: z.string().base64(), + authenticatorDataBase64: z.string().base64() +}); +const passphraseSchema = baseSchema.extend({ + type: z.literal("passphrase"), + passphrase: z.string().min(30, m["form.errors.passphrase"]()) +}); + +export const formSchema = z.discriminatedUnion("type", [passkeySchema, passphraseSchema]); + +export type FormSchema = typeof formSchema; diff --git a/src/routes/api/admin/init/+server.ts b/src/routes/api/admin/init/+server.ts index a970806..13ef8fa 100644 --- a/src/routes/api/admin/init/+server.ts +++ b/src/routes/api/admin/init/+server.ts @@ -1,5 +1,6 @@ import { json } from "@sveltejs/kit"; import { UserService } from "$lib/server/services/user-service"; +import { WebAuthnService } from "$lib/server/auth/webauthn-service"; import { ValidationError } from "$lib/server/utils/errors"; import type { RequestHandler } from "./$types"; import { registerOpenAPIRoute } from "$lib/server/openapi"; @@ -105,7 +106,7 @@ registerOpenAPIRoute("/admin/init", "POST", { } }); -export const POST: RequestHandler = async ({ request }) => { +export const POST: RequestHandler = async ({ request, cookies, url }) => { const log = logger.setContext("API"); try { @@ -135,21 +136,45 @@ export const POST: RequestHandler = async ({ request }) => { }); // Create admin account - const admin = await UserService.createUser({ - name: body.name, - email: body.email, - passphrase: body.passphrase, // Will be undefined if passkey is used - language: body.language || "de" - }); + const admin = await UserService.createUser( + { + name: body.name, + email: body.email, + passphrase: body.passphrase, // Will be undefined if passkey is used + language: body.language || "de" + }, + url + ); // Add the passkey to the admin account if provided if (hasPasskey) { + // Validate that this registration was preceded by a challenge request + const registrationEmail = cookies.get("webauthn-registration-email"); + + if (!registrationEmail || registrationEmail !== body.email) { + return json( + { error: "Invalid passkey registration. Please request a new challenge first." }, + { status: 400 } + ); + } + + // Clear the registration cookie after validation (challenge cookie is cleared by login route) + cookies.delete("webauthn-registration-email", { path: "/" }); + + // Extract counter from WebAuthn credential + const counter = WebAuthnService.extractCounterFromCredential(body.passkey); + await UserService.addPasskey(admin.id, { id: body.passkey.id, publicKey: body.passkey.publicKey, - counter: body.passkey.counter || 0, + counter, deviceName: body.passkey.deviceName || "Unknown Device" }); + + log.debug("Passkey added to admin account", { + adminId: admin.id, + passkeyId: body.passkey.id + }); } log.debug("Admin account created successfully", { diff --git a/src/routes/api/auth/challenge/+server.ts b/src/routes/api/auth/challenge/+server.ts index 5b942bd..03fee91 100644 --- a/src/routes/api/auth/challenge/+server.ts +++ b/src/routes/api/auth/challenge/+server.ts @@ -5,12 +5,14 @@ import { NotFoundError } from "$lib/server/utils/errors"; import type { RequestHandler } from "./$types"; import { registerOpenAPIRoute } from "$lib/server/openapi"; import { UniversalLogger } from "$lib/logger"; +import { env } from "$env/dynamic/private"; const logger = new UniversalLogger().setContext("AuthChallengeAPI"); registerOpenAPIRoute("/auth/challenge", "POST", { summary: "Generate WebAuthn authentication challenge", - description: "Generate a challenge for WebAuthn authentication and return registered passkeys", + description: + "Generate a challenge for WebAuthn authentication (login) or registration and return registered passkeys if user exists", tags: ["Authentication"], requestBody: { description: "User email to generate challenge for", @@ -33,7 +35,7 @@ registerOpenAPIRoute("/auth/challenge", "POST", { }, responses: { "200": { - description: "Challenge generated successfully", + description: "Challenge generated successfully for login or registration", content: { "application/json": { schema: { @@ -45,7 +47,7 @@ registerOpenAPIRoute("/auth/challenge", "POST", { }, allowCredentials: { type: "array", - description: "List of registered passkeys for this user", + description: "List of registered passkeys for this user (empty for registration)", items: { type: "object", properties: { @@ -62,6 +64,10 @@ registerOpenAPIRoute("/auth/challenge", "POST", { timeout: { type: "number", description: "Timeout in milliseconds" + }, + isRegistration: { + type: "boolean", + description: "True if this is for user registration (user not found)" } }, required: ["challenge", "allowCredentials"] @@ -69,15 +75,6 @@ registerOpenAPIRoute("/auth/challenge", "POST", { } } }, - "404": { - description: "User not found", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - example: { error: "User not found" } - } - } - }, "500": { description: "Internal server error", content: { @@ -90,58 +87,122 @@ registerOpenAPIRoute("/auth/challenge", "POST", { } }); -export const POST: RequestHandler = async ({ request, cookies }) => { +/** + * Get the appropriate rpId (Relying Party ID) for WebAuthn based on environment + */ +function getRpId(requestUrl: URL): string { + if (env.NODE_ENV === "production") { + // In production, use the hostname (without subdomain for main domain) + const hostname = requestUrl.hostname; + const parts = hostname.split("."); + + // If it's a subdomain (e.g., tenant.example.com), use the main domain (example.com) + // This allows passkeys to work across all subdomains + if (parts.length > 2) { + return parts.slice(-2).join("."); + } + return hostname; + } + + // Development: use localhost + return "localhost"; +} + +export const POST: RequestHandler = async ({ request, cookies, url }) => { try { const body = await request.json(); logger.debug("Generating WebAuthn challenge", { email: body.email }); - // Get user by email - let user; + // Try to get user by email - but don't fail if not found + let user = null; + let isRegistration = false; + try { user = await UserService.getUserByEmail(body.email); } catch (error) { if (error instanceof NotFoundError) { - return json({ error: "User not found" }, { status: 404 }); + // User doesn't exist yet - this is a registration flow + isRegistration = true; + logger.debug("User not found - generating challenge for registration", { + email: body.email + }); + } else { + throw error; } - throw error; } // Generate challenge const challenge = WebAuthnService.generateChallenge(); - // Store challenge in session cookie (in production, use proper session storage) - cookies.set("webauthn-challenge", challenge, { - httpOnly: true, - secure: true, - sameSite: "strict", - path: "/", - maxAge: 60 * 5 // 5 minutes - }); + if (isRegistration) { + // For registration, only store the email for validation + cookies.set("webauthn-registration-email", body.email, { + httpOnly: true, + secure: true, + sameSite: "strict", + path: "/", + maxAge: 60 * 5 // 5 minutes + }); + } else { + // For login, store the challenge for signature verification + cookies.set("webauthn-challenge", challenge, { + httpOnly: true, + secure: true, + sameSite: "strict", + path: "/", + maxAge: 60 * 5 // 5 minutes + }); + } - // Get user's registered passkeys - const passkeys = await WebAuthnService.getUserPasskeys(user.id); + let allowCredentials: Array<{ + id: string; + type: "public-key"; + transports: string[]; + }> = []; - // Format passkeys for WebAuthn API - const allowCredentials = passkeys.map((passkey) => ({ - id: passkey.id, - type: "public-key" as const, - transports: ["usb", "nfc", "ble", "internal"] // All possible transports - })); + if (user) { + // Get user's registered passkeys for login + const passkeys = await WebAuthnService.getUserPasskeys(user.id); - logger.debug("WebAuthn challenge generated", { - userId: user.id, - email: user.email, - passkeyCount: passkeys.length, - challenge: challenge.substring(0, 8) + "..." + // Format passkeys for WebAuthn API + allowCredentials = passkeys.map((passkey) => ({ + id: passkey.id, + type: "public-key" as const, + transports: ["usb", "nfc", "ble", "internal"] // All possible transports + })); + + logger.debug("WebAuthn challenge generated for login", { + userId: user.id, + email: user.email, + passkeyCount: passkeys.length, + challenge: challenge.substring(0, 8) + "..." + }); + } else { + logger.debug("WebAuthn challenge generated for registration", { + email: body.email, + challenge: challenge.substring(0, 8) + "..." + }); + } + + const rpId = getRpId(url); + + logger.debug("Returning challenge data", { + challenge, + allowCredentials, + timeout: 60000, // 60 seconds + rpId, + userVerification: "preferred", + isRegistration }); return json({ challenge, allowCredentials, timeout: 60000, // 60 seconds - rpId: "localhost", // TODO: Configure for production - userVerification: "preferred" + rpId, + userVerification: "preferred", + isRegistration }); } catch (error) { logger.error("Challenge generation error", { error: String(error) }); diff --git a/src/routes/api/auth/confirm/+server.ts b/src/routes/api/auth/confirm/+server.ts index 27eae45..7b5557e 100644 --- a/src/routes/api/auth/confirm/+server.ts +++ b/src/routes/api/auth/confirm/+server.ts @@ -36,7 +36,11 @@ registerOpenAPIRoute("/auth/confirm", "POST", { schema: { type: "object", properties: { - message: { type: "string", description: "Success message" } + message: { type: "string", description: "Success message" }, + isSetup: { + type: "string", + description: "Whether this is the first account that was setup on the server" + } }, required: ["message"] }, @@ -73,8 +77,9 @@ export const POST: RequestHandler = async ({ request }) => { const confirmationResult = await UserService.confirm(body.token); - const response: Record = { - message: "User account confirmed successfully. You can now log in." + const response: Record = { + message: "User account confirmed successfully. You can now log in.", + isSetup: confirmationResult.isSetup }; // Include recovery passphrase if it exists (for WebAuthn-only users) diff --git a/src/routes/api/auth/passkeys/+server.ts b/src/routes/api/auth/passkeys/+server.ts index c78dbd3..80206a5 100644 --- a/src/routes/api/auth/passkeys/+server.ts +++ b/src/routes/api/auth/passkeys/+server.ts @@ -1,5 +1,6 @@ import { json } from "@sveltejs/kit"; import { UserService } from "$lib/server/services/user-service"; +import { WebAuthnService } from "$lib/server/auth/webauthn-service"; import { NotFoundError, ValidationError } from "$lib/server/utils/errors"; import type { RequestHandler } from "./$types"; import { registerOpenAPIRoute } from "$lib/server/openapi"; @@ -115,12 +116,15 @@ export const POST: RequestHandler = async ({ request }) => { deviceName: body.passkey.deviceName }); + // Extract counter from WebAuthn credential + const counter = WebAuthnService.extractCounterFromCredential(body.passkey); + // Add the passkey using the UserService await UserService.addAdditionalPasskey(body.userId, { id: body.passkey.id, userId: body.userId, publicKey: body.passkey.publicKey, - counter: body.passkey.counter || 0, + counter, deviceName: body.passkey.deviceName || "Unknown Device" }); diff --git a/src/routes/api/auth/register/+server.ts b/src/routes/api/auth/register/+server.ts index 65449a7..89beabc 100644 --- a/src/routes/api/auth/register/+server.ts +++ b/src/routes/api/auth/register/+server.ts @@ -1,6 +1,7 @@ import { json } from "@sveltejs/kit"; import { UserService } from "$lib/server/services/user-service"; import { InviteService } from "$lib/server/services/invite-service"; +import { WebAuthnService } from "$lib/server/auth/webauthn-service"; import { ValidationError } from "$lib/server/utils/errors"; import type { RequestHandler } from "./$types"; import { registerOpenAPIRoute } from "$lib/server/openapi"; @@ -123,7 +124,7 @@ registerOpenAPIRoute("/auth/register", "POST", { } }); -export const POST: RequestHandler = async ({ request }) => { +export const POST: RequestHandler = async ({ request, cookies, url }) => { const log = logger.setContext("API"); try { @@ -180,23 +181,47 @@ export const POST: RequestHandler = async ({ request }) => { }); // Create user account - const user = await UserService.createUser({ - name: body.name, - email: body.email, - role: finalRole, - tenantId: finalTenantId, - passphrase: body.passphrase, - language: inviteUsed?.language || body.language || "de" - }); + const user = await UserService.createUser( + { + name: body.name, + email: body.email, + role: finalRole, + tenantId: finalTenantId, + passphrase: body.passphrase, + language: inviteUsed?.language || body.language || "de" + }, + url + ); // Add the passkey to the user account if provided if (body.passkey) { + // Validate that this registration was preceded by a challenge request + const registrationEmail = cookies.get("webauthn-registration-email"); + + if (!registrationEmail || registrationEmail !== body.email) { + return json( + { error: "Invalid passkey registration. Please request a new challenge first." }, + { status: 400 } + ); + } + + // Clear the registration cookie after validation (challenge cookie is cleared by login route) + cookies.delete("webauthn-registration-email", { path: "/" }); + + // Extract counter from WebAuthn credential + const counter = WebAuthnService.extractCounterFromCredential(body.passkey); + await UserService.addPasskey(user.id, { id: body.passkey.id, publicKey: body.passkey.publicKey, - counter: body.passkey.counter || 0, + counter: counter - 1, // Should start at -1, first login is counter 0 deviceName: body.passkey.deviceName || "Unknown Device" }); + + log.debug("Passkey added to user account", { + userId: user.id, + passkeyId: body.passkey.id + }); } // Mark invitation as used if one was provided diff --git a/src/routes/api/auth/resend-confirmation/+server.ts b/src/routes/api/auth/resend-confirmation/+server.ts index 4b4b405..8d0d288 100644 --- a/src/routes/api/auth/resend-confirmation/+server.ts +++ b/src/routes/api/auth/resend-confirmation/+server.ts @@ -68,12 +68,12 @@ registerOpenAPIRoute("/auth/resend-confirmation", "POST", { } }); -export const POST: RequestHandler = async ({ request }) => { +export const POST: RequestHandler = async ({ request, url }) => { try { const body = await request.json(); // Resend confirmation email - await UserService.resendConfirmationEmail(body.email); + await UserService.resendConfirmationEmail(body.email, url); return json( { diff --git a/src/server-hooks/authHandle.ts b/src/server-hooks/apiAuthHandle.ts similarity index 84% rename from src/server-hooks/authHandle.ts rename to src/server-hooks/apiAuthHandle.ts index 1d6d252..3a65f9a 100644 --- a/src/server-hooks/authHandle.ts +++ b/src/server-hooks/apiAuthHandle.ts @@ -2,12 +2,13 @@ import type { Handle } from "@sveltejs/kit"; import { SessionService } from "$lib/server/auth/session-service"; import { UniversalLogger } from "$lib/logger"; import { AuthorizationService } from "$lib/server/auth/authorization-service"; +import { getAccessToken } from "./utils/accessToken"; const logger = new UniversalLogger().setContext("AuthHandle"); -const ACCESS_TOKEN_COOKIE_NAME = "access_token"; const PROTECTED_PATHS = ["/api/admin", "/api/tenant-admin", "/api/tenants", "/api/auth/register"]; const PUBLIC_PATHS = [ + "/", "/api/auth/challenge", "/api/auth/login", "/api/auth/register", @@ -33,10 +34,14 @@ const PROTECTED_AUTH_PATHS = [ "/api/auth/invite" ]; -export const authHandle: Handle = async ({ event, resolve }) => { - const { url, request } = event; +export const apiAuthHandle: Handle = async ({ event, resolve }) => { + const { url } = event; const path = url.pathname; + if (!path.startsWith("/api")) { + return resolve(event); + } + const isProtectedPath = PROTECTED_PATHS.some((protectedPath) => path.startsWith(protectedPath)); const isPublicPath = PUBLIC_PATHS.some((publicPath) => path.startsWith(publicPath)); const isProtectedAuthPath = PROTECTED_AUTH_PATHS.some((authPath) => path.startsWith(authPath)); @@ -44,7 +49,13 @@ export const authHandle: Handle = async ({ event, resolve }) => { const isAdminPath = ADMIN_PATHS.some((gadPath) => path.startsWith(gadPath)); // Allow public paths - if (isPublicPath) { + if ( + isPublicPath && + !isProtectedAuthPath && + !isProtectedPath && + !isAdminPath && + !isGlobalAdminPath + ) { return resolve(event); } @@ -59,22 +70,7 @@ export const authHandle: Handle = async ({ event, resolve }) => { ); } - let accessToken: string | null = null; - - // Get access token from cookie - const accessTokenCookie = event.cookies.get(ACCESS_TOKEN_COOKIE_NAME); - if (accessTokenCookie) { - accessToken = accessTokenCookie; - } - - // Fallback: check Authorization header - if (!accessToken) { - const authHeader = request.headers.get("authorization"); - if (authHeader?.startsWith("Bearer ")) { - accessToken = authHeader.substring(7); - } - } - + const accessToken: string | null = getAccessToken(event); if (!accessToken) { logger.warn(`Authentication required for ${path}`); return new Response(JSON.stringify({ error: "Authentication required" }), { diff --git a/src/server-hooks/authGuard.ts b/src/server-hooks/authGuard.ts new file mode 100644 index 0000000..37bf404 --- /dev/null +++ b/src/server-hooks/authGuard.ts @@ -0,0 +1,42 @@ +import { redirect, type Handle } from "@sveltejs/kit"; +import { SessionService } from "$lib/server/auth/session-service"; +import { AuthorizationService } from "$lib/server/auth/authorization-service"; +import { getAccessToken } from "./utils/accessToken"; +import { ROUTES } from "$lib/const/routes"; + +export const authGuard: Handle = async ({ event, resolve }) => { + const { url } = event; + const path = url.pathname; + + // Do not handle api paths + if (path.startsWith("/api")) { + return resolve(event); + } + + // Only guard protected routes + const isDashboardRoute = path.startsWith(ROUTES.DASHBOARD.MAIN); + if (isDashboardRoute) { + // Check for accessToken + const accessToken: string | null = getAccessToken(event); + if (!accessToken) redirect(302, ROUTES.LOGIN); + + // Verify access token with database session check + const sessionData = await SessionService.validateTokenWithDB(accessToken); + if (!sessionData) redirect(302, ROUTES.LOGIN); + + switch (true) { + case isDashboardRoute && + AuthorizationService.hasAnyRole(sessionData.user, [ + "GLOBAL_ADMIN", + "TENANT_ADMIN", + "STAFF" + ]): + return resolve(event); + default: + // Access not granted + redirect(302, ROUTES.LOGIN); + } + } + + return resolve(event); +}; diff --git a/src/server-hooks/loggingHandle.ts b/src/server-hooks/loggingHandle.ts index 3649c6d..6d163a6 100644 --- a/src/server-hooks/loggingHandle.ts +++ b/src/server-hooks/loggingHandle.ts @@ -10,6 +10,10 @@ import { logger } from "$lib/logger"; * @returns {Promise} The response with applied headers and rate limiting */ export const loggingHandle: Handle = async ({ event, resolve }) => { + if (!event.url.pathname.startsWith("/api")) { + return resolve(event); + } + const start = Date.now(); const requestLogger = logger.setContext("REQUEST"); diff --git a/src/server-hooks/utils/accessToken.ts b/src/server-hooks/utils/accessToken.ts new file mode 100644 index 0000000..0fcb0d8 --- /dev/null +++ b/src/server-hooks/utils/accessToken.ts @@ -0,0 +1,24 @@ +import type { RequestEvent } from "@sveltejs/kit"; + +const ACCESS_TOKEN_COOKIE_NAME = "access_token"; +export const getAccessToken = ( + event: RequestEvent>, string | null> +): string | null => { + let accessToken: string | null = null; + + // Get access token from cookie + const accessTokenCookie = event.cookies.get(ACCESS_TOKEN_COOKIE_NAME); + if (accessTokenCookie) { + accessToken = accessTokenCookie; + } + + // Fallback: check Authorization header + if (!accessToken) { + const authHeader = event.request.headers.get("authorization"); + if (authHeader?.startsWith("Bearer ")) { + accessToken = authHeader.substring(7); + } + } + + return accessToken; +};