Merge pull request #61 from open-reception/feat/admin-setup

Admin Setup
This commit is contained in:
Hendrik
2025-08-29 12:57:59 +02:00
committed by GitHub
98 changed files with 3873 additions and 216 deletions
+8
View File
@@ -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:
+689
View File
@@ -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"
}
}
}
}
+3
View File
@@ -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",
+86
View File
@@ -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"
}
}
}
+94
View File
@@ -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": "Well 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": "Weve send you a magic link to {email}",
"action": "Resend E-Mail",
"success": "E-Mail was resent"
},
"confirm": {
"success": {
"title": "Good job!",
"description": "Youve 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 youve 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": "Youre all set!",
"description": "Youve successfully secured your account",
"action": "Proceed"
},
"error": {
"title": "Verification failed",
"description": "The magic link youve 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"
}
}
}
+3
View File
@@ -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 {
+3
View File
@@ -13,6 +13,9 @@ declare global {
// interface PageState {}
// interface Platform {}
}
interface PageState {
email?: string;
}
}
export {};
+4 -2
View File
@@ -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
);
@@ -0,0 +1,10 @@
<script lang="ts">
import { Text } from "$lib/components/ui/typography";
import type { HTMLAttributes } from "svelte/elements";
let { children }: HTMLAttributes<HTMLDivElement> = $props();
</script>
<Text style="xs" class="text-medium px-5 text-center">
{@render children?.()}
</Text>
@@ -0,0 +1,9 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
let { children }: HTMLAttributes<HTMLDivElement> = $props();
</script>
<div class="flex flex-col gap-2">
{@render children?.()}
</div>
@@ -0,0 +1,11 @@
<script lang="ts">
import { Text } from "$lib/components/ui/typography";
import { cn } from "$lib/utils";
import type { HTMLAttributes } from "svelte/elements";
let { class: className, children }: HTMLAttributes<HTMLDivElement> = $props();
</script>
<Text style="md" color="medium" class={cn(className)}>
{@render children?.()}
</Text>
@@ -0,0 +1,9 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
let { children }: HTMLAttributes<HTMLDivElement> = $props();
</script>
<div class="flex flex-col justify-between gap-2">
{@render children?.()}
</div>
@@ -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 };
@@ -0,0 +1,10 @@
<script lang="ts">
import { cn } from "$lib/utils";
import type { HTMLAttributes } from "svelte/elements";
let { class: className, children, ...restProps }: HTMLAttributes<HTMLDivElement> = $props();
</script>
<div class={cn("flex grow flex-col gap-2", className)} {...restProps}>
{@render children?.()}
</div>
@@ -0,0 +1,21 @@
<script lang="ts">
import { cn } from "$lib/utils";
import type { HTMLAttributes } from "svelte/elements";
import { HorizontalPagePadding } from "../../ui/page";
import { Card } from "$lib/components/ui/card";
let { class: className, children, ...restProps }: HTMLAttributes<HTMLDivElement> = $props();
</script>
<HorizontalPagePadding
as="main"
class={cn(
"mx-auto flex w-full max-w-(--max-w-md) grow flex-col items-start pt-3 pb-4 sm:max-w-(--max-w-sm) sm:justify-center",
className
)}
{...restProps}
>
<Card class="w-full grow sm:grow-0">
{@render children?.()}
</Card>
</HorizontalPagePadding>
@@ -0,0 +1,11 @@
<script lang="ts">
import { Headline } from "$lib/components/ui/typography";
import { cn } from "$lib/utils";
import type { HTMLAttributes } from "svelte/elements";
let { class: className, children }: HTMLAttributes<HTMLHeadingElement> = $props();
</script>
<Headline level="h1" style="h4" class={cn(className)}>
{@render children?.()}
</Headline>
@@ -0,0 +1,7 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
let { children }: HTMLAttributes<HTMLDivElement> = $props();
</script>
{@render children?.()}
@@ -0,0 +1,5 @@
import Root from "./root.svelte";
import Action from "./action.svelte";
import Main from "./main.svelte";
export { Root, Action, Main };
@@ -0,0 +1,16 @@
<script lang="ts">
import { cn } from "$lib/utils";
import type { HTMLAttributes } from "svelte/elements";
let { class: className, children, ...restProps }: HTMLAttributes<HTMLDivElement> = $props();
</script>
<div
class={cn(
"flex grow flex-col items-center justify-center gap-5 text-center md:grow-0",
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,18 @@
<script lang="ts">
import { cn } from "$lib/utils";
import type { HTMLAttributes } from "svelte/elements";
import { HorizontalPagePadding } from "../../ui/page";
let { class: className, children, ...restProps }: HTMLAttributes<HTMLDivElement> = $props();
</script>
<HorizontalPagePadding
as="main"
data-slot="card-action"
class={cn("mx-auto flex max-w-(--max-w-sm) grow flex-col items-start py-10", className)}
{...restProps}
>
<div class="flex grow flex-col items-center justify-between gap-10 md:justify-center">
{@render children?.()}
</div>
</HorizontalPagePadding>
+4
View File
@@ -0,0 +1,4 @@
import * as EmptyLayout from "./empty-layout";
import * as CenteredCard from "./centered-card";
export { EmptyLayout, CenteredCard };
@@ -0,0 +1,14 @@
<script lang="ts">
import { Skeleton } from "$lib/components/ui/skeleton";
import { cn } from "$lib/utils";
import Loader from "@lucide/svelte/icons/loader-2";
let { class: className = "" }: { class?: string } = $props();
</script>
<div class={cn("my-10 flex grow flex-col justify-center gap-2 text-center", className)}>
<Loader class="mx-auto mb-3 size-20 animate-spin" strokeWidth={1} />
<Skeleton class="mx-auto h-10 w-45" />
<Skeleton class="mx-auto h-5 w-60" />
<Skeleton class="mx-auto h-5 w-35" />
</div>
@@ -0,0 +1,18 @@
<script lang="ts">
import { Headline, Text } from "$lib/components/ui/typography";
import { cn } from "$lib/utils";
import type { Component } from "svelte";
let {
class: className = "",
Icon,
headline,
description
}: { class?: string; Icon: Component; headline: string; description: string } = $props();
</script>
<div class={cn("my-10 flex grow flex-col justify-center gap-2 text-center", className)}>
<Icon class="mx-auto mb-3 size-20" strokeWidth={1} />
<Headline level="h1" style="h2">{headline}</Headline>
<Text style="lg" class="text-medium">{description}</Text>
</div>
@@ -0,0 +1,4 @@
import CenterState from "./empty-state.svelte";
import CenterLoadingState from "./center-loading-state.svelte";
export { CenterState, CenterLoadingState };
@@ -1,10 +1,17 @@
<script lang="ts">
import { m } from "$i18n/messages.js";
import { getLocale, setLocale } from "$i18n/runtime.js";
import type { ButtonSize, ButtonVariant } from "$lib/components/ui/button";
import { ComboBox } from "$lib/components/ui/combobox";
let {
class: className = "",
triggerSize = "default",
triggerVariant = "ghost"
}: { triggerVariant?: ButtonVariant; triggerSize?: ButtonSize; class?: string } = $props();
</script>
<div>
<div class={className}>
<ComboBox
options={[
{ label: "Deutsch", value: "de", keywords: ["german", "deutsch"] },
@@ -19,5 +26,8 @@
search: m["i18n.search"](),
notFound: m["i18n.notFound"]()
}}
{triggerVariant}
{triggerSize}
class="w-auto"
/>
</div>
+11 -4
View File
@@ -1,10 +1,11 @@
<script lang="ts" module>
import { cn, type WithElementRef } from "$lib/utils.js";
import Loader2Icon from "@lucide/svelte/icons/loader-2";
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
import { type VariantProps, tv } from "tailwind-variants";
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
@@ -14,11 +15,12 @@
"bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border",
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline"
link: "text-primary underline-offset-1 !p-0 !ml-0.5 !mr-0 underline cursor-pointer"
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
default: "h-9 px-4 py-2 has-[>svg]:px-3 text-sm",
xs: "gap-1.5 rounded-xs px-1 has-[>svg]:px-2.5 text-xs",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5 text-sm",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9"
}
@@ -36,6 +38,7 @@
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
isLoading?: boolean;
};
</script>
@@ -49,6 +52,7 @@
type = "button",
disabled,
children,
isLoading,
...restProps
}: ButtonProps = $props();
</script>
@@ -75,6 +79,9 @@
{disabled}
{...restProps}
>
{#if isLoading}
<Loader2Icon class="animate-spin" />
{/if}
{@render children?.()}
</button>
{/if}
+11 -4
View File
@@ -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 @@
<Popover.Root bind:open>
<Popover.Trigger bind:ref={triggerRef}>
<Button
variant="outline"
class="w-[200px] justify-between"
variant={triggerVariant}
size={triggerSize}
class={cn("w-[200px] justify-between", className)}
role="combobox"
aria-expanded={open}
>
@@ -0,0 +1,8 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import { type Props } from "$lib/components/ui/button";
let { ref = $bindable(null), ...restProps }: Props = $props();
</script>
<Button bind:ref type="submit" {...restProps} />
@@ -0,0 +1,23 @@
<script lang="ts">
import * as FormPrimitive from "formsnap";
import { cn, type WithoutChild } from "$lib/utils.js";
import { Text } from "../typography";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithoutChild<FormPrimitive.DescriptionProps> = $props();
</script>
<FormPrimitive.Description
bind:ref
data-slot="form-description"
class={cn("text-muted-foreground -mt-2 text-sm", className)}
{...restProps}
>
<Text style="sm" color="medium" class="text-medium">
{@render children?.()}
</Text>
</FormPrimitive.Description>
@@ -0,0 +1,24 @@
<script lang="ts" generics="T extends Record<string, unknown>, U extends FormPathLeaves<T>">
import * as FormPrimitive from "formsnap";
import type { FormPathLeaves } from "sveltekit-superforms";
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
form,
name,
children: childrenProp,
...restProps
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> &
FormPrimitive.ElementFieldProps<T, U> = $props();
</script>
<FormPrimitive.ElementField {form} {name}>
{#snippet children({ constraints, errors, tainted, value })}
<div bind:this={ref} class={cn("space-y-2", className)} {...restProps}>
{@render childrenProp?.({ constraints, errors, tainted, value: value as T[U] })}
</div>
{/snippet}
</FormPrimitive.ElementField>
@@ -0,0 +1,33 @@
<script lang="ts">
import * as FormPrimitive from "formsnap";
import { cn, type WithoutChild } from "$lib/utils.js";
import { Text } from "../typography";
let {
ref = $bindable(null),
class: className,
errorClasses,
children: childrenProp,
...restProps
}: WithoutChild<FormPrimitive.FieldErrorsProps> & {
errorClasses?: string | undefined | null;
} = $props();
</script>
<FormPrimitive.FieldErrors
bind:ref
class={cn("text-destructive -mt-1 text-sm font-medium", className)}
{...restProps}
>
{#snippet children({ errors, errorProps })}
{#if childrenProp}
{@render childrenProp({ errors, errorProps })}
{:else}
<Text style="xs">
{#each errors as error (error)}
<div {...errorProps} class={cn(errorClasses)}>{error}</div>
{/each}
</Text>
{/if}
{/snippet}
</FormPrimitive.FieldErrors>
@@ -0,0 +1,24 @@
<script lang="ts" generics="T extends Record<string, unknown>, U extends FormPath<T>">
import * as FormPrimitive from "formsnap";
import type { FormPath } from "sveltekit-superforms";
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
form,
name,
children: childrenProp,
...restProps
}: FormPrimitive.FieldProps<T, U> &
WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> = $props();
</script>
<FormPrimitive.Field {form} {name}>
{#snippet children({ constraints, errors, tainted, value })}
<div bind:this={ref} data-slot="form-item" class={cn("space-y-2", className)} {...restProps}>
{@render childrenProp?.({ constraints, errors, tainted, value: value as T[U] })}
</div>
{/snippet}
</FormPrimitive.Field>
@@ -0,0 +1,15 @@
<script lang="ts" generics="T extends Record<string, unknown>, U extends FormPath<T>">
import * as FormPrimitive from "formsnap";
import type { FormPath } from "sveltekit-superforms";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
form,
name,
...restProps
}: WithoutChild<FormPrimitive.FieldsetProps<T, U>> = $props();
</script>
<FormPrimitive.Fieldset bind:ref {form} {name} class={cn("space-y-2", className)} {...restProps} />
@@ -0,0 +1,24 @@
<script lang="ts">
import * as FormPrimitive from "formsnap";
import { Label } from "$lib/components/ui/label/index.js";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
children,
class: className,
...restProps
}: WithoutChild<FormPrimitive.LabelProps> = $props();
</script>
<FormPrimitive.Label {...restProps} bind:ref>
{#snippet child({ props })}
<Label
{...props}
data-slot="form-label"
class={cn("data-[fs-error]:text-destructive", className)}
>
{@render children?.()}
</Label>
{/snippet}
</FormPrimitive.Label>
@@ -0,0 +1,16 @@
<script lang="ts">
import * as FormPrimitive from "formsnap";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChild<FormPrimitive.LegendProps> = $props();
</script>
<FormPrimitive.Legend
bind:ref
class={cn("data-[fs-error]:text-destructive text-sm leading-none font-medium", className)}
{...restProps}
/>
@@ -0,0 +1,24 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import type { Action } from "svelte/action";
import type { HTMLAttributes } from "svelte/elements";
export type EventReporter = (params: { isSubmitting?: boolean }) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type EnhanceFunction = Action<HTMLFormElement, any>;
let {
formId,
enhance,
class: className,
children
}: {
formId: string;
enhance: EnhanceFunction;
class?: string;
} & HTMLAttributes<HTMLFormElement> = $props();
</script>
<form id={formId} method="POST" use:enhance class={cn("flex flex-col gap-3", className)}>
{@render children?.()}
</form>
+35
View File
@@ -0,0 +1,35 @@
import * as FormPrimitive from "formsnap";
import Root from "./form-root.svelte";
import Description from "./form-description.svelte";
import Label from "./form-label.svelte";
import FieldErrors from "./form-field-errors.svelte";
import Field from "./form-field.svelte";
import Fieldset from "./form-fieldset.svelte";
import Legend from "./form-legend.svelte";
import ElementField from "./form-element-field.svelte";
import Button from "./form-button.svelte";
const Control = FormPrimitive.Control;
export {
Root,
Field,
Control,
Label,
Button,
FieldErrors,
Description,
Fieldset,
Legend,
ElementField,
//
Field as FormField,
Control as FormControl,
Description as FormDescription,
Label as FormLabel,
FieldErrors as FormFieldErrors,
Fieldset as FormFieldset,
Legend as FormLegend,
ElementField as FormElementField,
Button as FormButton
};
+7
View File
@@ -0,0 +1,7 @@
import Root from "./input.svelte";
export {
Root,
//
Root as Input
};
+51
View File
@@ -0,0 +1,51 @@
<script lang="ts">
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
type Props = WithElementRef<
Omit<HTMLInputAttributes, "type"> &
({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
>;
let {
ref = $bindable(null),
value = $bindable(),
type,
files = $bindable(),
class: className,
...restProps
}: Props = $props();
</script>
{#if type === "file"}
<input
bind:this={ref}
data-slot="input"
class={cn(
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
type="file"
bind:files
bind:value
{...restProps}
/>
{:else}
<input
bind:this={ref}
data-slot="input"
class={cn(
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{type}
bind:value
{...restProps}
/>
{/if}
+7
View File
@@ -0,0 +1,7 @@
import Root from "./label.svelte";
export {
Root,
//
Root as Label
};
+20
View File
@@ -0,0 +1,20 @@
<script lang="ts">
import { Label as LabelPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: LabelPrimitive.RootProps = $props();
</script>
<LabelPrimitive.Root
bind:ref
data-slot="label"
class={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...restProps}
/>
@@ -3,13 +3,14 @@
import { cn, type WithElementRef } from "$lib/utils.js";
let {
as = "div",
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { as?: "div" | "main" | "footer" } = $props();
</script>
<div bind:this={ref} class={cn("px-4", className)} {...restProps}>
<svelte:element this={as} bind:this={ref} class={cn("px-4", className)} {...restProps}>
{@render children?.()}
</div>
</svelte:element>
@@ -2,47 +2,52 @@
import { dev } from "$app/environment";
import { m } from "$i18n/messages";
import { cn, type WithElementRef } from "$lib/utils";
import { toggleMode } from "mode-watcher";
import { toggleMode, mode } from "mode-watcher";
import type { HTMLAttributes } from "svelte/elements";
import { Text } from "../typography";
import HorizontalPagePadding from "./horizontal-page-padding.svelte";
import { LanguageSwitch } from "$lib/components/templates/language-switch";
let {
ref = $bindable(null),
class: className,
children,
isWithLanguageSwitch = false,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { isWithLanguageSwitch?: boolean } = $props();
</script>
<div
bind:this={ref}
class={cn("flex min-h-dvh flex-col justify-between", className)}
{...restProps}
>
{@render children?.()}
<footer>
<HorizontalPagePadding
class="bg-muted text-muted-foreground flex items-center justify-between py-2"
>
{#if dev}
<Text style="xs" class="flex gap-1">
Viewport:
<div class="sm:hidden">xs</div>
<div class="hidden sm:block md:hidden">sm</div>
<div class="hidden md:block lg:hidden">md</div>
<div class="hidden lg:block xl:hidden">lg</div>
<div class="hidden xl:block 2xl:hidden">xl</div>
<div class="hidden 2xl:block">2xl</div>
</Text>
{/if}
<Text style="xs" class="mx-auto">
{m.poweredBy()}
<a href="https://open-reception.org" target="_blank" class="underline">OpenReception</a>
</Text>
{#if dev}
<button onclick={toggleMode} class="cursor-pointer text-xs">Toggle Mode</button>
{/if}
<div bind:this={ref} class={cn("flex min-h-dvh flex-col", className)} {...restProps}>
{#if isWithLanguageSwitch}
<HorizontalPagePadding class="flex pt-4">
<LanguageSwitch class="ml-auto" triggerSize="sm" />
</HorizontalPagePadding>
</footer>
{/if}
{@render children?.()}
<HorizontalPagePadding
as="footer"
class="bg-muted text-muted-foreground mt-auto flex items-center justify-between py-2"
>
{#if dev}
<Text style="xs" class="flex gap-1">
Viewport:
<div class="sm:hidden">xs</div>
<div class="hidden sm:block md:hidden">sm</div>
<div class="hidden md:block lg:hidden">md</div>
<div class="hidden lg:block xl:hidden">lg</div>
<div class="hidden xl:block 2xl:hidden">xl</div>
<div class="hidden 2xl:block">2xl</div>
</Text>
{/if}
<Text style="xs" class="mx-auto">
{m.poweredBy()}
<a href="https://open-reception.org" target="_blank" class="underline">OpenReception</a>
</Text>
{#if dev}
<button onclick={toggleMode} class="cursor-pointer text-xs">
{mode?.current === "dark" ? "dark" : "light"}Mode
</button>
{/if}
</HorizontalPagePadding>
</div>
+5
View File
@@ -0,0 +1,5 @@
import State from "./state.svelte";
const Passkey = { State };
export { Passkey };
@@ -0,0 +1,55 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { cn } from "$lib/utils.js";
import AtSign from "@lucide/svelte/icons/at-sign";
import Error from "@lucide/svelte/icons/ban";
import Check from "@lucide/svelte/icons/check-circle";
import Loader2Icon from "@lucide/svelte/icons/loader-2";
import Pointer from "@lucide/svelte/icons/pointer";
import { Button } from "../button";
export type PasskeyState = "initial" | "click" | "loading" | "user" | "success" | "error";
let {
state,
class: className,
onclick
}: {
state: PasskeyState;
class?: string;
onclick?: () => void;
} = $props();
const disabledStates: PasskeyState[] = ["initial", "loading", "user", "success"];
const onClick = () => {
if (!disabledStates.includes(state)) {
onclick?.();
}
};
</script>
<Button
variant="outline"
onclick={onClick}
class={cn("flex w-full justify-start", className)}
disabled={disabledStates.includes(state)}
>
{#if state === "initial"}
<AtSign class="size-4" />
{m["passkey.add.initial"]()}
{:else if state === "click"}
<Pointer class="size-4" />
{m["passkey.add.click"]()}
{:else if state === "loading"}
<Loader2Icon class="size-4 animate-spin" />
{m["passkey.add.loading"]()}
{:else if state === "user"}
<Loader2Icon class="size-4 animate-spin" />
{m["passkey.add.user"]()}
{:else if state === "success"}
<Check class="size-4" />
{m["passkey.add.success"]()}
{:else if state === "error"}
<Error class="size-4" />
{m["passkey.add.error"]()}
{/if}
</Button>
+7
View File
@@ -0,0 +1,7 @@
import Root from "./skeleton.svelte";
export {
Root,
//
Root as Skeleton
};
@@ -0,0 +1,17 @@
<script lang="ts">
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> = $props();
</script>
<div
bind:this={ref}
data-slot="skeleton"
class={cn("bg-lighter dark:bg-accent animate-pulse rounded-md", className)}
{...restProps}
></div>
+1
View File
@@ -0,0 +1 @@
export { default as Toaster } from "./sonner.svelte";
@@ -0,0 +1,13 @@
<script lang="ts">
import { Toaster as Sonner, type ToasterProps as SonnerProps } from "svelte-sonner";
import { mode } from "mode-watcher";
let { ...restProps }: SonnerProps = $props();
</script>
<Sonner
theme={mode.current}
class="toaster group"
style="--normal-bg: var(--color-popover); --normal-text: var(--color-popover-foreground); --normal-border: var(--color-border);"
{...restProps}
/>
@@ -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"
}
+14 -3
View File
@@ -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<HTMLAttributes<HTMLSpanElement>> & {
style: VariantProps<typeof variants>["style"];
color?: VariantProps<typeof variants>["color"];
} = $props();
</script>
<span bind:this={ref} class={cn(variants({ style }), className)} {...restProps}>
<span bind:this={ref} class={cn(variants({ style, color }), className)} {...restProps}>
{@render children?.()}
</span>
+13
View File
@@ -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"
}
};
+198 -19
View File
@@ -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<void> {
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
}
}
}
@@ -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");
});
});
});
+47 -2
View File
@@ -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<void>}
*/
@@ -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<void> {
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
});
@@ -6,72 +6,115 @@
<title>Registrierung bestätigen</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
margin: 0;
padding: 20px;
background-color: {{tenant.backgroundColor}};
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
margin: 0;
padding: 20px;
background-color: {
{
tenant.backgroundColor
}
}
;
}
.container {
max-width: 600px;
margin: 0 auto;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
overflow: hidden;
max-width: 600px;
margin: 0 auto;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.header {
background-color: {{tenant.primaryColor}};
color: white;
padding: 30px;
text-align: center;
background-color: {
{
tenant.primaryColor
}
}
;
color: white;
padding: 30px;
text-align: center;
}
.logo {
color: white;
font-size: 24px;
font-weight: bold;
margin-bottom: 10px;
color: white;
font-size: 24px;
font-weight: bold;
margin-bottom: 10px;
}
.content {
padding: 30px;
padding: 30px;
}
.confirmation-code {
background-color: #f8f9fa;
border: 2px solid {{tenant.primaryColor}};
border-radius: 8px;
padding: 20px;
text-align: center;
margin: 20px 0;
background-color: #f8f9fa;
border: 2px solid {
{
tenant.primaryColor
}
}
;
border-radius: 8px;
padding: 20px;
text-align: center;
margin: 20px 0;
}
.code {
font-family: 'Courier New', monospace;
font-size: 32px;
font-weight: bold;
color: {{tenant.primaryColor}};
letter-spacing: 4px;
margin: 10px 0;
font-family: 'Courier New', monospace;
font-size: 32px;
font-weight: bold;
color: {
{
tenant.primaryColor
}
}
;
letter-spacing: 4px;
margin: 10px 0;
}
.footer {
background-color: #f8f9fa;
padding: 20px;
text-align: center;
font-size: 14px;
color: #666;
background-color: #f8f9fa;
padding: 20px;
text-align: center;
font-size: 14px;
color: #666;
}
.footer a {
color: {{tenant.primaryColor}};
text-decoration: none;
color: {
{
tenant.primaryColor
}
}
;
text-decoration: none;
}
.warning {
background-color: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 4px;
padding: 15px;
margin: 20px 0;
color: #856404;
background-color: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 4px;
padding: 15px;
margin: 20px 0;
color: #856404;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
@@ -100,6 +143,7 @@
<div style="font-size: 14px; color: #666; margin-top: 10px">
Geben Sie diesen Code in das Bestätigungsfeld ein
</div>
<a href="{{tenant.baseUrl}}/confirm/{{confirmationCode}}">Oder hier klicken.</a>
</div>
<div class="warning">
@@ -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.
@@ -100,6 +100,7 @@
<div style="font-size: 14px; color: #666; margin-top: 10px">
Enter this code in the confirmation field
</div>
<a href="{{tenant.baseUrl}}/confirm/{{confirmationCode}}">Or simply click here.</a>
</div>
<div class="warning">
@@ -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.
@@ -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 () => {
+15 -7
View File
@@ -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<void> {
static async resendConfirmationEmail(email: string, requestUrl?: URL): Promise<void> {
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;
+109
View File
@@ -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<GeneratePasskeyResponse> => {
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
};
+3 -1
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import { browser } from "$app/environment";
import "../../app.css";
import { Toaster } from "$lib/components/ui/sonner/index.js";
import { ModeWatcher, setMode } from "mode-watcher";
import "../../app.css";
$effect(() => {
if (!browser) return;
@@ -20,4 +21,5 @@
</script>
<ModeWatcher track={false} />
<Toaster richColors />
{@render children()}
+12
View File
@@ -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 {
+1 -5
View File
@@ -1,6 +1,4 @@
<script lang="ts">
import { m } from "$i18n/messages.js";
import LanguageSwitch from "$lib/components/templates/language-switch.svelte";
import { HorizontalPagePadding, PageWithClaim } from "$lib/components/ui/page";
import { Headline, Text } from "$lib/components/ui/typography";
@@ -14,7 +12,7 @@
<PageWithClaim>
<main>
<HorizontalPagePadding class="flex flex-col items-start gap-4">
<Headline level="h1" style="h1">{m.welcome()}</Headline>
<Headline level="h1" style="h3">Hello World</Headline>
<Text style="md">
Environment configuration is
{#await data.streamed.isEnvOk}
@@ -23,8 +21,6 @@
{isEnvOk ? "OK" : "NOT OK"}
{/await}.
</Text>
<LanguageSwitch />
</HorizontalPagePadding>
</main>
</PageWithClaim>
@@ -0,0 +1,32 @@
import logger from "$lib/logger";
import type { PageServerLoad } from "./$types";
const log = logger.setContext("BFF");
export const load: PageServerLoad = async (event) => {
const confirmation: Promise<{ success: boolean; isSetup: boolean }> = event
.fetch("/api/auth/confirm", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ token: event.params.token })
})
.then(async (resp) => {
const success = resp.status < 400;
try {
const body = await resp.json();
const isSetup = body.isSetup ?? false;
return { success, isSetup };
} catch (error) {
log.error("Unable to parse JSON from API repsonse", { path: "/confirm/[token]", error });
return { success, isSetup: false };
}
});
return {
streaming: {
confirmation
}
};
};
@@ -0,0 +1,76 @@
<script lang="ts">
import { m } from "$i18n/messages.js";
import { CenteredCard } from "$lib/components/layouts";
import { CenterLoadingState, CenterState } from "$lib/components/templates/empty-state";
import { Button } from "$lib/components/ui/button";
import { PageWithClaim } from "$lib/components/ui/page";
import { Skeleton } from "$lib/components/ui/skeleton";
import Ban from "@lucide/svelte/icons/ban";
import Check from "@lucide/svelte/icons/check";
import type { PageData } from "./$types";
import { ROUTES } from "$lib/const/routes";
export let data: PageData;
</script>
<svelte:head>
<title>{m["confirm.title"]()} - OpenReception</title>
</svelte:head>
<PageWithClaim isWithLanguageSwitch>
<CenteredCard.Root>
<CenteredCard.Main>
{#await data.streaming.confirmation}
<CenterLoadingState />
{:then confirmation}
{#if confirmation.success}
{#if confirmation.isSetup}
<CenterState
Icon={Check}
headline={m["setup.confirm.success.title"]()}
description={m["setup.confirm.success.description"]()}
/>
{:else}
<CenterState
Icon={Check}
headline={m["confirm.success.title"]()}
description={m["confirm.success.description"]()}
/>
{/if}
{:else}
<CenterState
Icon={Ban}
headline={m["confirm.error.title"]()}
description={m["confirm.error.description"]()}
/>
{/if}
{/await}
</CenteredCard.Main>
<CenteredCard.Action>
{#await data.streaming.confirmation}
<Skeleton class="mx-auto h-2 w-3/4" />
<Skeleton class="mx-auto h-2 w-1/4" />
<Skeleton class="h-10 w-full" />
{:then confirmation}
{#if confirmation.success}
{#if confirmation.isSetup}
<CenteredCard.ActionHint>
{m["setup.confirm.success.hint"]()}
</CenteredCard.ActionHint>
<Button size="lg" class="w-full" href={ROUTES.LOGIN}>
{m["setup.confirm.success.action"]()}
</Button>
{:else}
<Button size="lg" class="w-full" href={ROUTES.LOGIN}>
{m["confirm.success.action"]()}
</Button>
{/if}
{:else}
<Button size="lg" class="w-full" href={ROUTES.RESEND_CONFIRMATION}>
{m["confirm.error.action"]()}
</Button>
{/if}
{/await}
</CenteredCard.Action>
</CenteredCard.Root>
</PageWithClaim>
@@ -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 };
}
};
@@ -0,0 +1,53 @@
<script lang="ts">
import * as Form from "$lib/components/ui/form";
import { m } from "$i18n/messages.js";
import { CenteredCard } from "$lib/components/layouts";
import { PageWithClaim } from "$lib/components/ui/page";
import ResendConfirmationForm from "./resend-confirmation-form.svelte";
import type { EventReporter } from "$lib/components/ui/form/form-root.svelte";
export let data;
const formId = "resend-confirmation-form";
let isSubmitting = false;
const onEvent: EventReporter = (params) => {
if (params.isSubmitting) {
isSubmitting = true;
}
if (params.isSubmitting === false) {
isSubmitting = false;
}
};
</script>
<svelte:head>
<title>{m["confirm.resend.title"]()} - OpenReception</title>
</svelte:head>
<PageWithClaim isWithLanguageSwitch>
<CenteredCard.Root>
<CenteredCard.Header>
<CenteredCard.Title>
{m["confirm.resend.title"]()}
</CenteredCard.Title>
<CenteredCard.Description>
{m["confirm.resend.description"]()}
</CenteredCard.Description>
</CenteredCard.Header>
<CenteredCard.Main>
<ResendConfirmationForm {formId} {data} {onEvent} />
</CenteredCard.Main>
<CenteredCard.Action>
<Form.Button
size="lg"
class="w-full"
form={formId}
isLoading={isSubmitting}
disabled={isSubmitting}
>
{m["confirm.resend.action"]()}
</Form.Button>
</CenteredCard.Action>
</CenteredCard.Root>
</PageWithClaim>
@@ -0,0 +1,43 @@
<script lang="ts">
import { m } from "$i18n/messages.js";
import * as Form from "$lib/components/ui/form";
import type { EventReporter } from "$lib/components/ui/form/form-root.svelte";
import { Input } from "$lib/components/ui/input";
import { toast } from "svelte-sonner";
import { type Infer, superForm, type SuperValidated } from "sveltekit-superforms";
import { zodClient } from "sveltekit-superforms/adapters";
import { formSchema, type FormSchema } from "./schema";
let {
data,
formId,
onEvent
}: { formId: string; onEvent: EventReporter; data: { form: SuperValidated<Infer<FormSchema>> } } =
$props();
const form = superForm(data.form, {
validators: zodClient(formSchema),
onResult: async (event) => {
if (event.result.type === "success") {
toast.success(m["confirm.resend.success"]());
}
onEvent({ isSubmitting: false });
},
onSubmit: () => onEvent({ isSubmitting: true })
});
const { form: formData, enhance } = form;
</script>
<Form.Root {formId} {enhance}>
<Form.Field {form} name="email">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["form.email"]()}</Form.Label>
<Input {...props} bind:value={$formData.email} type="email" />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
</Form.Root>
@@ -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;
@@ -0,0 +1,2 @@
Hello from Dashboard<br />
<a href="/logout" class="underline">Logout</a>
+63
View File
@@ -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
});
}
}
};
+53
View File
@@ -0,0 +1,53 @@
<script lang="ts">
import * as Form from "$lib/components/ui/form";
import { m } from "$i18n/messages.js";
import { CenteredCard } from "$lib/components/layouts";
import { PageWithClaim } from "$lib/components/ui/page";
import LoginForm from "./login-form.svelte";
import type { EventReporter } from "$lib/components/ui/form/form-root.svelte";
export let data;
const formId = "create-account-form";
let isSubmitting = false;
const onEvent: EventReporter = (params) => {
if (params.isSubmitting) {
isSubmitting = true;
}
if (params.isSubmitting === false) {
isSubmitting = false;
}
};
</script>
<svelte:head>
<title>{m["login.title"]()} - OpenReception</title>
</svelte:head>
<PageWithClaim isWithLanguageSwitch>
<CenteredCard.Root>
<CenteredCard.Header>
<CenteredCard.Title>
{m["login.title"]()}
</CenteredCard.Title>
<CenteredCard.Description>
{m["login.description_passphrase"]()}
</CenteredCard.Description>
</CenteredCard.Header>
<CenteredCard.Main>
<LoginForm {formId} {data} {onEvent} />
</CenteredCard.Main>
<CenteredCard.Action>
<Form.Button
size="lg"
class="w-full"
form={formId}
isLoading={isSubmitting}
disabled={isSubmitting}
>
{m["login.action"]()}
</Form.Button>
</CenteredCard.Action>
</CenteredCard.Root>
</PageWithClaim>
+228
View File
@@ -0,0 +1,228 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { m } from "$i18n/messages.js";
import { Button } from "$lib/components/ui/button";
import * as Form from "$lib/components/ui/form";
import type { EventReporter } from "$lib/components/ui/form/form-root.svelte";
import { Input } from "$lib/components/ui/input";
import { ROUTES } from "$lib/const/routes";
import { toast } from "svelte-sonner";
import { writable, type Writable } from "svelte/store";
import { type Infer, superForm, type SuperValidated } from "sveltekit-superforms";
import { zodClient } from "sveltekit-superforms/adapters";
import { baseSchema, formSchema, type FormSchema } from "./schema";
import { onMount } from "svelte";
import { Passkey } from "$lib/components/ui/passkey";
import { Text } from "$lib/components/ui/typography";
import type { PasskeyState } from "$lib/components/ui/passkey/state.svelte";
import { arrayBufferToBase64, fetchChallenge, getCredential } from "$lib/utils/passkey";
import { Label } from "$lib/components/ui/label";
let {
data,
formId,
onEvent
}: { formId: string; onEvent: EventReporter; data: { form: SuperValidated<Infer<FormSchema>> } } =
$props();
const form = superForm(data.form, {
validators: zodClient(formSchema),
onChange: (event) => {
if (event.paths.includes("email")) {
setProperPasskeyState();
}
},
onResult: async (event) => {
if (event.result.type === "success") {
await goto(ROUTES.DASHBOARD.MAIN);
} else {
toast.error(m["login.error"]());
}
onEvent({ isSubmitting: false });
},
onSubmit: () => onEvent({ isSubmitting: true })
});
onMount(() => {
$formData.type = "passkey";
});
const onToggle = () => {
if ($formData.type === "passkey") {
$formData = {
...$formData,
type: "passphrase",
passphrase: ""
};
} else {
$formData = {
...$formData,
type: "passkey",
id: "",
clientDataBase64: "",
authenticatorDataBase64: "",
signatureBase64: ""
};
setProperPasskeyState();
}
};
const setProperPasskeyState = () => {
const isOk = baseSchema.shape.email.safeParse($formData.email).success;
if (isOk) {
$passkeyLoading = "click";
} else {
$passkeyLoading = "initial";
}
};
const onSetPasskey = async () => {
$passkeyLoading = "loading";
const challenge = await fetchChallenge($formData.email);
if (!challenge) {
$passkeyLoading = "error";
} else {
$passkeyLoading = "user";
const credentialResp = await getCredential({ ...challenge, email: $formData.email }).catch(
() => {
$passkeyLoading = "error";
}
);
if (!credentialResp) {
$passkeyLoading = "error";
return;
}
// Update form data with passkey info
const clientDataBase64 = arrayBufferToBase64(credentialResp.response.clientDataJSON);
const authenticatorDataBase64 = arrayBufferToBase64(
// @ts-expect-error response type needs to be fixed
credentialResp.response.authenticatorData
);
// @ts-expect-error response type needs to be fixed
const signatureBase64 = arrayBufferToBase64(credentialResp.response.signature);
$formData = {
...$formData,
type: "passkey",
id: credentialResp.id,
authenticatorDataBase64,
clientDataBase64,
signatureBase64
};
// Update UI to show passkey is ready
$passkeyLoading = "success";
}
};
const { form: formData, enhance } = form;
const passkeyLoading: Writable<PasskeyState> = writable("initial");
type FormDataPassphrase = Extract<typeof $formData, { type: "passphrase" }>;
type FormDataPasskey = Extract<typeof $formData, { type: "passkey" }>;
</script>
<Form.Root {formId} {enhance}>
<Form.Field {form} name="type" class="hidden">
<Form.Control>
{#snippet children({ props })}
<Input {...props} bind:value={$formData.type} type="text" />
{/snippet}
</Form.Control>
</Form.Field>
<Form.Field {form} name="email">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["form.email"]()}</Form.Label>
<Input {...props} bind:value={$formData.email} type="email" />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
{#if $formData.type === "passphrase"}
<Form.Field {form} name="passphrase">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["form.passphrase"]()}</Form.Label>
<!-- prettier-ignore -->
<Input
{...props}
bind:value={($formData as FormDataPassphrase).passphrase}
type="password"
minlength={30}
maxlength={100}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
<Form.Description>
{m["login.or"]()}
<Button variant="link" size="sm" onclick={onToggle} class="text-inherit">
{m["login.usePasskey"]()}
</Button>
</Form.Description>
</Form.Field>
{/if}
{#if $formData.type === "passkey"}
<div>
<Form.Field {form} name="id" class="hidden">
<Form.Control>
{#snippet children({ props })}
<!-- prettier-ignore -->
<Input
{...props}
bind:value={($formData as FormDataPasskey).id}
type="hidden"
/>
{/snippet}
</Form.Control>
</Form.Field>
<Form.Field {form} name="authenticatorDataBase64" class="hidden">
<Form.Control>
{#snippet children({ props })}
<!-- prettier-ignore -->
<Input
{...props}
bind:value={($formData as FormDataPasskey).authenticatorDataBase64}
type="hidden"
/>
{/snippet}
</Form.Control>
</Form.Field>
<Form.Field {form} name="clientDataBase64" class="hidden">
<Form.Control>
{#snippet children({ props })}
<!-- prettier-ignore -->
<Input
{...props}
bind:value={($formData as FormDataPasskey).clientDataBase64}
type="hidden"
/>
{/snippet}
</Form.Control>
</Form.Field>
<Form.Field {form} name="signatureBase64" class="hidden">
<Form.Control>
{#snippet children({ props })}
<!-- prettier-ignore -->
<Input
{...props}
bind:value={($formData as FormDataPasskey).signatureBase64}
type="hidden"
/>
{/snippet}
</Form.Control>
</Form.Field>
<Label class="mb-2">{m["form.passkey"]()}</Label>
<Passkey.State state={$passkeyLoading} onclick={onSetPasskey} />
<Text style="md" color="medium">
{m["login.or"]()}
<Button variant="link" size="sm" onclick={onToggle} class="text-inherit">
{m["login.usePassphrase"]()}
</Button>.
</Text>
</div>
{/if}
</Form.Root>
+22
View File
@@ -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;
+17
View File
@@ -0,0 +1,17 @@
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async (event) => {
const success: Promise<boolean> = 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 } };
};
+41
View File
@@ -0,0 +1,41 @@
<script lang="ts">
import { m } from "$i18n/messages.js";
import { CenteredCard } from "$lib/components/layouts";
import { CenterLoadingState, CenterState } from "$lib/components/templates/empty-state";
import { Button } from "$lib/components/ui/button";
import { PageWithClaim } from "$lib/components/ui/page";
import { Skeleton } from "$lib/components/ui/skeleton";
import { ROUTES } from "$lib/const/routes.js";
import Check from "@lucide/svelte/icons/check";
export let data;
</script>
<svelte:head>
<title>{m["logout.title"]()} - OpenReception</title>
</svelte:head>
<PageWithClaim isWithLanguageSwitch>
<CenteredCard.Root>
<CenteredCard.Main>
{#await data.streaming.success}
<CenterLoadingState />
{:then}
<CenterState
Icon={Check}
headline={m["logout.title"]()}
description={m["logout.description"]()}
/>
{/await}
</CenteredCard.Main>
<CenteredCard.Action>
{#await data.streaming.success}
<Skeleton class="h-10 w-full" />
{:then}
<Button size="lg" class="w-full" href={ROUTES.LOGIN}>
{m["logout.action"]()}
</Button>
{/await}
</CenteredCard.Action>
</CenteredCard.Root>
</PageWithClaim>
@@ -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);
}
};
+27
View File
@@ -0,0 +1,27 @@
<script lang="ts">
import { m } from "$i18n/messages.js";
import { EmptyLayout } from "$lib/components/layouts";
import { Button } from "$lib/components/ui/button";
import { PageWithClaim } from "$lib/components/ui/page";
import { Headline, Text } from "$lib/components/ui/typography";
import { ROUTES } from "$lib/const/routes.js";
</script>
<svelte:head>
<title>{m.welcome()} - OpenReception</title>
</svelte:head>
<PageWithClaim isWithLanguageSwitch>
<EmptyLayout.Root>
<EmptyLayout.Main>
<img class="mb-5 size-50" alt={m.logo()} src="/favicon.svg" />
<Headline level="h1" style="h1">{m.welcome()}</Headline>
<Text style="lg" color="medium">{m.slogan()}</Text>
</EmptyLayout.Main>
<EmptyLayout.Action>
<Button size="lg" class="w-full" href={ROUTES.SETUP.CREATE_ADMIN_ACCOUNT}>
{m["setup.start"]()}
</Button>
</EmptyLayout.Action>
</EmptyLayout.Root>
</PageWithClaim>
@@ -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
};
}
};
@@ -0,0 +1,91 @@
<script lang="ts">
import { m } from "$i18n/messages.js";
import * as Form from "$lib/components/ui/form";
import { CenteredCard } from "$lib/components/layouts";
import { PageWithClaim } from "$lib/components/ui/page";
import { zodClient } from "sveltekit-superforms/adapters";
import { CenterState } from "$lib/components/templates/empty-state";
import MailPlus from "@lucide/svelte/icons/mail-plus";
import { superForm, type Infer, type SuperValidated } from "sveltekit-superforms/client";
import { formSchema, type FormSchema } from "./schema";
import { toast } from "svelte-sonner";
import { page } from "$app/state";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { ROUTES } from "$lib/const/routes";
import { Input } from "$lib/components/ui/input";
let { data }: { data: { form: SuperValidated<Infer<FormSchema>> } } = $props();
const formId = "resend-confirmation-email";
let email = $state("");
let isSubmitting = $state(false);
const form = superForm(data.form, {
resetForm: false,
validators: zodClient(formSchema),
onResult: (event) => {
if (event.result.type === "success") {
toast.success(m["setup.verify_email.success"]());
}
isSubmitting = false;
},
onSubmit: () => {
isSubmitting = true;
}
});
const { form: formData, enhance } = form;
onMount(async () => {
// @ts-expect-error should be valid
email = page.state.email;
// This page is only of help, if you have just created an admin account
if (!email) {
await goto(ROUTES.SETUP.CREATE_ADMIN_ACCOUNT);
}
// Set email from previous step
// Setting it hopefully after form is hydrated from load function
setTimeout(() => {
$formData.email = email;
}, 150);
});
</script>
<svelte:head>
<title>{m["setup.verify_email.title"]()} - OpenReception</title>
</svelte:head>
<PageWithClaim isWithLanguageSwitch>
<CenteredCard.Root>
<CenteredCard.Main>
<CenterState
Icon={MailPlus}
headline={m["setup.verify_email.title"]()}
description={m["setup.verify_email.description"]({
email: email
})}
/>
</CenteredCard.Main>
<CenteredCard.Action>
<Form.Root {formId} {enhance}>
<Form.Field {form} name="email" class="hidden">
<Form.Control>
{#snippet children({ props })}
<Input {...props} bind:value={$formData.email} type="hidden" />
{/snippet}
</Form.Control>
</Form.Field>
<Form.Button
size="lg"
class="w-full"
type="submit"
isLoading={isSubmitting}
disabled={isSubmitting}
>
{m["setup.verify_email.action"]()}
</Form.Button>
</Form.Root>
</CenteredCard.Action>
</CenteredCard.Root>
</PageWithClaim>
@@ -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;
@@ -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 };
}
};
@@ -0,0 +1,53 @@
<script lang="ts">
import * as Form from "$lib/components/ui/form";
import { m } from "$i18n/messages.js";
import { CenteredCard } from "$lib/components/layouts";
import { PageWithClaim } from "$lib/components/ui/page";
import CreateAdminAccountForm from "./create-account-form.svelte";
import type { EventReporter } from "$lib/components/ui/form/form-root.svelte";
export let data;
const formId = "create-account-form";
let isSubmitting = false;
const onEvent: EventReporter = (params) => {
if (params.isSubmitting) {
isSubmitting = true;
}
if (params.isSubmitting === false) {
isSubmitting = false;
}
};
</script>
<svelte:head>
<title>{m["setup.create_admin_account.title"]()} - OpenReception</title>
</svelte:head>
<PageWithClaim isWithLanguageSwitch>
<CenteredCard.Root>
<CenteredCard.Header>
<CenteredCard.Title>
{m["setup.create_admin_account.title"]()}
</CenteredCard.Title>
<CenteredCard.Description>
{m["setup.create_admin_account.description"]()}
</CenteredCard.Description>
</CenteredCard.Header>
<CenteredCard.Main>
<CreateAdminAccountForm {formId} {data} {onEvent} />
</CenteredCard.Main>
<CenteredCard.Action>
<Form.Button
size="lg"
class="w-full"
form={formId}
isLoading={isSubmitting}
disabled={isSubmitting}
>
{m["setup.create_admin_account.action"]()}
</Form.Button>
</CenteredCard.Action>
</CenteredCard.Root>
</PageWithClaim>
@@ -0,0 +1,222 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { m } from "$i18n/messages.js";
import { getLocale } from "$i18n/runtime.js";
import { Button } from "$lib/components/ui/button";
import * as Form from "$lib/components/ui/form";
import type { EventReporter } from "$lib/components/ui/form/form-root.svelte";
import { Input } from "$lib/components/ui/input";
import { Passkey } from "$lib/components/ui/passkey";
import { ROUTES } from "$lib/const/routes";
import { onMount } from "svelte";
import { toast } from "svelte-sonner";
import { writable, type Writable } from "svelte/store";
import { type Infer, type SuperValidated } from "sveltekit-superforms";
import { zodClient } from "sveltekit-superforms/adapters";
import { superForm } from "sveltekit-superforms/client";
import { baseSchema, formSchema, type FormSchema } from "./schema";
import { Text } from "$lib/components/ui/typography";
import { Label } from "$lib/components/ui/label";
import type { PasskeyState } from "$lib/components/ui/passkey/state.svelte";
import { arrayBufferToBase64, fetchChallenge, generatePasskey } from "$lib/utils/passkey";
let {
data,
formId,
onEvent
}: { formId: string; onEvent: EventReporter; data: { form: SuperValidated<Infer<FormSchema>> } } =
$props();
const form = superForm(data.form, {
validators: zodClient(formSchema),
onChange: (event) => {
if (event.paths.includes("email")) {
setProperPasskeyState();
}
},
onResult: async (event) => {
if (event.result.type === "success") {
toast.success(m["setup.create_admin_account.success"]());
await goto(ROUTES.SETUP.CHECK_EMAIL, {
state: { email: event.result.data?.form.data.email }
});
}
onEvent({ isSubmitting: false });
},
onSubmit: () => onEvent({ isSubmitting: true })
});
const { form: formData, enhance } = form;
const passkeyLoading: Writable<PasskeyState> = writable("initial");
onMount(() => {
$formData.type = "passkey";
if (!$formData.language) {
$formData.language = getLocale() ?? "en";
}
});
const setProperPasskeyState = () => {
const isOk = baseSchema.shape.email.safeParse($formData.email).success;
if (isOk) {
$passkeyLoading = "click";
} else {
$passkeyLoading = "initial";
}
};
const onToggle = () => {
if ($formData.type === "passkey") {
$formData = {
...$formData,
type: "passphrase",
passphrase: ""
};
} else {
$formData = {
...$formData,
type: "passkey",
id: "",
publicKeyBase64: "",
authenticatorDataBase64: ""
};
setProperPasskeyState();
}
};
const onSetPasskey = async () => {
$passkeyLoading = "loading";
const challenge = await fetchChallenge($formData.email);
if (!challenge) {
$passkeyLoading = "error";
} else {
$passkeyLoading = "user";
const passkeyResp = await generatePasskey({ ...challenge, email: $formData.email }).catch(
() => {
$passkeyLoading = "error";
}
);
if (!passkeyResp) {
$passkeyLoading = "error";
return;
}
// Returns ArrayBuffer that has to be converted to base64 string
const publicKey = passkeyResp.response.getPublicKey();
if (!publicKey) {
$passkeyLoading = "error";
return;
}
// May include device name and counter
const authenticatorData = passkeyResp.response.getAuthenticatorData();
// Update form data with passkey info
const publicKeyBase64 = arrayBufferToBase64(publicKey);
const authenticatorDataBase64 = arrayBufferToBase64(authenticatorData);
$formData = {
...$formData,
type: "passkey",
id: passkeyResp.id,
publicKeyBase64,
authenticatorDataBase64
};
// Update UI to show passkey is ready
$passkeyLoading = "success";
}
};
type FormDataPassphrase = Extract<typeof $formData, { type: "passphrase" }>;
type FormDataPasskey = Extract<typeof $formData, { type: "passkey" }>;
</script>
<Form.Root {formId} {enhance}>
<Form.Field {form} name="type" class="hidden">
<Form.Control>
{#snippet children({ props })}
<Input {...props} bind:value={$formData.type} type="text" />
{/snippet}
</Form.Control>
</Form.Field>
<Form.Field {form} name="language" class="hidden">
<Form.Control>
{#snippet children({ props })}
<Input {...props} bind:value={$formData.language} type="hidden" />
{/snippet}
</Form.Control>
</Form.Field>
<Form.Field {form} name="email">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["form.email"]()}</Form.Label>
<Input {...props} bind:value={$formData.email} type="email" />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
{#if $formData.type === "passphrase"}
<Form.Field {form} name="passphrase">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["form.passphrase"]()}</Form.Label>
<!-- prettier-ignore -->
<Input
{...props}
bind:value={($formData as FormDataPassphrase).passphrase}
type="password"
minlength={30}
maxlength={100}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
<Form.Description>
{m["form.passphraseRequirements"]()}
{m["login.or"]()}
<Button variant="link" size="sm" onclick={onToggle} class="text-inherit">
{m["login.usePasskey"]()}
</Button>.
</Form.Description>
</Form.Field>
{/if}
{#if $formData.type === "passkey"}
<div>
<Form.Field {form} name="id" class="hidden">
<Form.Control>
{#snippet children({ props })}
<!-- prettier-ignore -->
<Input {...props} bind:value={($formData as FormDataPasskey).id} type="hidden" />
{/snippet}
</Form.Control>
</Form.Field>
<Form.Field {form} name="publicKeyBase64" class="hidden">
<Form.Control>
{#snippet children({ props })}
<!-- prettier-ignore -->
<Input {...props} bind:value={($formData as FormDataPasskey).publicKeyBase64} type="hidden" />
{/snippet}
</Form.Control>
</Form.Field>
<Form.Field {form} name="authenticatorDataBase64" class="hidden">
<Form.Control>
{#snippet children({ props })}
<!-- prettier-ignore -->
<Input {...props} bind:value={($formData as FormDataPasskey).authenticatorDataBase64} type="hidden" />
{/snippet}
</Form.Control>
</Form.Field>
<Label class="mb-2">{m["form.passkey"]()}</Label>
<Passkey.State state={$passkeyLoading} onclick={onSetPasskey} />
<Text style="md" color="medium">
{m["login.or"]()}
<Button variant="link" size="sm" onclick={onToggle} class="text-inherit">
{m["login.usePassphrase"]()}
</Button>.
</Text>
</div>
{/if}
</Form.Root>
@@ -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;
+33 -8
View File
@@ -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", {
+101 -40
View File
@@ -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) });
+8 -3
View File
@@ -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<string, string> = {
message: "User account confirmed successfully. You can now log in."
const response: Record<string, string | boolean> = {
message: "User account confirmed successfully. You can now log in.",
isSetup: confirmationResult.isSetup
};
// Include recovery passphrase if it exists (for WebAuthn-only users)
+5 -1
View File
@@ -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"
});
+35 -10
View File
@@ -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
@@ -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(
{
@@ -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" }), {
+42
View File
@@ -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);
};
+4
View File
@@ -10,6 +10,10 @@ import { logger } from "$lib/logger";
* @returns {Promise<Response>} 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");
+24
View File
@@ -0,0 +1,24 @@
import type { RequestEvent } from "@sveltejs/kit";
const ACCESS_TOKEN_COOKIE_NAME = "access_token";
export const getAccessToken = (
event: RequestEvent<Partial<Record<string, string>>, 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;
};